Repository: Trendyol/stove Branch: main Commit: ca8f6da12b3b Files: 1062 Total size: 3.6 MB Directory structure: gitextract_mbe2tl_w/ ├── .claude/ │ └── skills/ │ └── stove/ │ ├── SKILL.md │ ├── container.md │ ├── custom-systems.md │ ├── go-setup.md │ ├── gradle-config.md │ ├── mcp.md │ ├── other-languages.md │ ├── system-setup.md │ ├── tracing.md │ └── writing-tests.md ├── .editorconfig ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── build-jvm-recipes.yml │ ├── build-process-recipes.yml │ ├── build.yml │ ├── gradle-publish-release.yml │ ├── gradle-publish-snapshot.yml │ ├── publish-to-ghpages.yml │ ├── scorecard.yml │ ├── stove-cli-ci.yml │ └── stove-cli-release.yml ├── .gitignore ├── LICENSE ├── README.md ├── api/ │ └── stove.api ├── build.gradle.kts ├── buildSrc/ │ ├── build.gradle.kts │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ ├── BuildConstants.kt │ ├── CI.kt │ ├── GenerateDashboardVersionSourceTask.kt │ ├── Helpers.kt │ ├── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── gradle/ │ │ └── StoveTracingConfiguration.kt │ └── stove-publishing.gradle.kts ├── codecov.yml ├── detekt.yml ├── docs/ │ ├── Components/ │ │ ├── 01-couchbase.md │ │ ├── 02-kafka.md │ │ ├── 03-elasticsearch.md │ │ ├── 04-wiremock.md │ │ ├── 05-http.md │ │ ├── 06-postgresql.md │ │ ├── 07-mongodb.md │ │ ├── 08-mssql.md │ │ ├── 09-redis.md │ │ ├── 10-bridge.md │ │ ├── 11-provided-instances.md │ │ ├── 12-grpc.md │ │ ├── 13-reporting.md │ │ ├── 14-grpc-mock.md │ │ ├── 15-tracing.md │ │ ├── 16-mysql.md │ │ ├── 17-cassandra.md │ │ ├── 18-dashboard.md │ │ ├── 19-provided-application.md │ │ ├── 20-multiple-systems.md │ │ ├── 21-mcp.md │ │ ├── 22-container.md │ │ └── index.md │ ├── assets/ │ │ ├── rough-notation.iife.js │ │ └── stove_dashboard.webm │ ├── best-practices.md │ ├── blog/ │ │ ├── dashboard-0.23.0.md │ │ ├── polyglot-0.24.0.md │ │ └── tracing-0.21.0.md │ ├── css/ │ │ └── custom.css │ ├── frameworks/ │ │ ├── index.md │ │ ├── ktor.md │ │ ├── micronaut.md │ │ ├── quarkus.md │ │ └── spring-boot.md │ ├── getting-started.md │ ├── index.md │ ├── js/ │ │ └── rough-notation-mkdocs.js │ ├── other-languages/ │ │ ├── go-container.md │ │ ├── go-process.md │ │ ├── go.md │ │ └── index.md │ ├── release-notes/ │ │ ├── 0.15.0.md │ │ ├── 0.19.0.md │ │ ├── 0.20.0.md │ │ ├── 0.21.0.md │ │ ├── 0.21.2.md │ │ ├── 0.22.2.md │ │ ├── 0.23.0.md │ │ └── 0.24.0.md │ ├── troubleshooting.md │ └── writing-custom-systems.md ├── examples/ │ ├── build.gradle.kts │ ├── ktor-example/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── stove/ │ │ │ │ └── ktor/ │ │ │ │ └── example/ │ │ │ │ ├── Application.kt │ │ │ │ ├── app/ │ │ │ │ │ ├── app.kt │ │ │ │ │ ├── configuration.kt │ │ │ │ │ ├── database.kt │ │ │ │ │ ├── kafka.kt │ │ │ │ │ └── routing.kt │ │ │ │ ├── application/ │ │ │ │ │ ├── ExampleAppConsumer.kt │ │ │ │ │ ├── LockProvider.kt │ │ │ │ │ ├── ProductService.kt │ │ │ │ │ └── UpdateProductRequest.kt │ │ │ │ ├── domain/ │ │ │ │ │ ├── Product.kt │ │ │ │ │ └── ProductRepository.kt │ │ │ │ └── infrastructure/ │ │ │ │ ├── FeatureToggleClient.kt │ │ │ │ └── PricingClient.kt │ │ │ ├── proto/ │ │ │ │ ├── feature_toggle.proto │ │ │ │ └── pricing.proto │ │ │ └── resources/ │ │ │ ├── application.yaml │ │ │ └── logback.xml │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── stove/ │ │ │ └── ktor/ │ │ │ └── example/ │ │ │ └── e2e/ │ │ │ ├── ExampleTest.kt │ │ │ ├── StoveConfig.kt │ │ │ └── TestStub.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── micronaut-example/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── stove/ │ │ │ │ └── micronaut/ │ │ │ │ └── example/ │ │ │ │ ├── Application.kt │ │ │ │ ├── application/ │ │ │ │ │ ├── domain/ │ │ │ │ │ │ └── Product.kt │ │ │ │ │ ├── repository/ │ │ │ │ │ │ └── ProductRepository.kt │ │ │ │ │ └── services/ │ │ │ │ │ ├── ProductService.kt │ │ │ │ │ └── SupplierService.kt │ │ │ │ └── infrastructure/ │ │ │ │ ├── ObjectMapperConfig.kt │ │ │ │ ├── api/ │ │ │ │ │ ├── ProductController.kt │ │ │ │ │ └── model/ │ │ │ │ │ └── request/ │ │ │ │ │ └── CreateProductRequest.kt │ │ │ │ ├── http/ │ │ │ │ │ └── SupplierHttpService.kt │ │ │ │ ├── persistence/ │ │ │ │ │ └── ProductJdbcRepository.kt │ │ │ │ └── postgres/ │ │ │ │ └── PostgresConfiguration.kt │ │ │ └── resources/ │ │ │ ├── application.yml │ │ │ └── logback.xml │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── stove/ │ │ │ └── micronaut/ │ │ │ └── example/ │ │ │ └── e2e/ │ │ │ ├── CreateProductsTableMigration.kt │ │ │ ├── ProductControllerTest.kt │ │ │ └── StoveConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── quarkus-example/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── stove/ │ │ │ │ └── quarkus/ │ │ │ │ └── example/ │ │ │ │ ├── QuarkusMainApp.kt │ │ │ │ ├── StoveStartupSignal.kt │ │ │ │ ├── api/ │ │ │ │ │ └── ProductResource.kt │ │ │ │ ├── application/ │ │ │ │ │ ├── Models.kt │ │ │ │ │ └── ProductCreator.kt │ │ │ │ └── infrastructure/ │ │ │ │ ├── http/ │ │ │ │ │ └── SupplierHttpService.kt │ │ │ │ ├── kafka/ │ │ │ │ │ ├── CustomProducerInterceptor.kt │ │ │ │ │ ├── KafkaClientConfiguration.kt │ │ │ │ │ ├── KafkaSerde.kt │ │ │ │ │ ├── ProductCommandConsumer.kt │ │ │ │ │ └── ProductEventPublisher.kt │ │ │ │ └── postgres/ │ │ │ │ └── ProductRepository.kt │ │ │ └── resources/ │ │ │ ├── application.properties │ │ │ └── db/ │ │ │ └── migration/ │ │ │ └── V1__create_products.sql │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── stove/ │ │ │ └── quarkus/ │ │ │ └── example/ │ │ │ └── e2e/ │ │ │ ├── ExampleTest.kt │ │ │ └── StoveConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-4x-example/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── stove/ │ │ │ │ └── spring/ │ │ │ │ └── example4x/ │ │ │ │ ├── ExampleApp.kt │ │ │ │ ├── application/ │ │ │ │ │ └── handlers/ │ │ │ │ │ └── ProductCreator.kt │ │ │ │ └── infrastructure/ │ │ │ │ ├── api/ │ │ │ │ │ └── ProductController.kt │ │ │ │ └── messaging/ │ │ │ │ └── kafka/ │ │ │ │ ├── KafkaConfiguration.kt │ │ │ │ ├── KafkaProducer.kt │ │ │ │ └── ProductCreateConsumer.kt │ │ │ └── resources/ │ │ │ └── application.yml │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── stove/ │ │ │ └── spring/ │ │ │ └── example4x/ │ │ │ └── e2e/ │ │ │ ├── ExampleTest.kt │ │ │ ├── StoveConfig.kt │ │ │ └── jackson3.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-example/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── stove/ │ │ │ │ └── spring/ │ │ │ │ └── example/ │ │ │ │ ├── ExampleApp.kt │ │ │ │ ├── application/ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ └── ProductCreator.kt │ │ │ │ │ └── services/ │ │ │ │ │ └── SupplierService.kt │ │ │ │ ├── domain/ │ │ │ │ │ └── ProductTable.kt │ │ │ │ └── infrastructure/ │ │ │ │ ├── Constants.kt │ │ │ │ ├── ObjectMapperConfig.kt │ │ │ │ ├── api/ │ │ │ │ │ └── ProductController.kt │ │ │ │ ├── http/ │ │ │ │ │ ├── SupplierHttpService.kt │ │ │ │ │ ├── WebClientConfiguration.kt │ │ │ │ │ └── WebClientConfigurationProperties.kt │ │ │ │ ├── messaging/ │ │ │ │ │ └── kafka/ │ │ │ │ │ ├── KafkaProducer.kt │ │ │ │ │ ├── configuration/ │ │ │ │ │ │ ├── ConsumerSettings.kt │ │ │ │ │ │ ├── KafkaConsumerConfiguration.kt │ │ │ │ │ │ ├── KafkaProducerConfiguration.kt │ │ │ │ │ │ ├── KafkaProperties.kt │ │ │ │ │ │ ├── MapBasedSettings.kt │ │ │ │ │ │ └── ProducerSettings.kt │ │ │ │ │ ├── consumers/ │ │ │ │ │ │ ├── FailingProductCreateConsumer.kt │ │ │ │ │ │ ├── JobTopicConfig.kt │ │ │ │ │ │ └── ProductCreateConsumers.kt │ │ │ │ │ └── interceptors/ │ │ │ │ │ ├── CustomConsumerInterceptor.kt │ │ │ │ │ └── CustomProducerInterceptor.kt │ │ │ │ └── postgres/ │ │ │ │ └── ExposedConfiguration.kt │ │ │ └── resources/ │ │ │ └── application.yml │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── stove/ │ │ │ └── spring/ │ │ │ └── example/ │ │ │ └── e2e/ │ │ │ ├── CreateProductsTableMigration.kt │ │ │ ├── ExampleTest.kt │ │ │ ├── StoveConfig.kt │ │ │ ├── TestSystemInitializer.kt │ │ │ ├── TracingValidationTest.kt │ │ │ └── hierarchy/ │ │ │ ├── BehaviorSpecHierarchyTest.kt │ │ │ ├── DescribeSpecHierarchyTest.kt │ │ │ ├── FunSpecContextHierarchyTest.kt │ │ │ ├── NestedJunitHierarchyTest.kt │ │ │ └── StringSpecHierarchyTest.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-standalone-example/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── stove/ │ │ │ │ └── spring/ │ │ │ │ └── standalone/ │ │ │ │ └── example/ │ │ │ │ ├── ExampleApp.kt │ │ │ │ ├── application/ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ └── ProductCreator.kt │ │ │ │ │ └── services/ │ │ │ │ │ └── SupplierService.kt │ │ │ │ ├── domain/ │ │ │ │ │ └── ProductTable.kt │ │ │ │ └── infrastructure/ │ │ │ │ ├── Constants.kt │ │ │ │ ├── ObjectMapperConfig.kt │ │ │ │ ├── api/ │ │ │ │ │ └── ProductController.kt │ │ │ │ ├── http/ │ │ │ │ │ ├── SupplierHttpService.kt │ │ │ │ │ ├── WebClientConfiguration.kt │ │ │ │ │ └── WebClientConfigurationProperties.kt │ │ │ │ ├── messaging/ │ │ │ │ │ └── kafka/ │ │ │ │ │ ├── KafkaProducer.kt │ │ │ │ │ ├── configuration/ │ │ │ │ │ │ ├── ConsumerSettings.kt │ │ │ │ │ │ ├── KafkaConsumerConfiguration.kt │ │ │ │ │ │ ├── KafkaProducerConfiguration.kt │ │ │ │ │ │ ├── KafkaProperties.kt │ │ │ │ │ │ ├── MapBasedSettings.kt │ │ │ │ │ │ └── ProducerSettings.kt │ │ │ │ │ ├── consumers/ │ │ │ │ │ │ ├── FailingProductCreateConsumer.kt │ │ │ │ │ │ ├── JobTopicConfig.kt │ │ │ │ │ │ └── ProductCreateConsumers.kt │ │ │ │ │ └── interceptors/ │ │ │ │ │ ├── CustomConsumerInterceptor.kt │ │ │ │ │ └── CustomProducerInterceptor.kt │ │ │ │ └── postgres/ │ │ │ │ └── ExposedConfiguration.kt │ │ │ └── resources/ │ │ │ └── application.yml │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── stove/ │ │ │ └── spring/ │ │ │ └── standalone/ │ │ │ └── example/ │ │ │ └── e2e/ │ │ │ ├── CreateProductsTableMigration.kt │ │ │ ├── ExampleTest.kt │ │ │ ├── ReportingIntegrationTest.kt │ │ │ └── StoveConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ └── spring-streams-example/ │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ └── stove/ │ │ │ └── spring/ │ │ │ └── streams/ │ │ │ └── example/ │ │ │ ├── ExampleApp.kt │ │ │ └── kafka/ │ │ │ ├── CustomDeserializationExceptionHandler.kt │ │ │ ├── CustomProductionExceptionHandler.kt │ │ │ ├── CustomSerDe.kt │ │ │ ├── StreamsConfig.kt │ │ │ └── application/ │ │ │ └── processor/ │ │ │ └── ExampleJoin.kt │ │ ├── proto/ │ │ │ ├── Input1-value.proto │ │ │ ├── Input2-value.proto │ │ │ └── Output-value.proto │ │ └── resources/ │ │ └── application.properties │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── stove/ │ │ └── spring/ │ │ └── streams/ │ │ └── example/ │ │ └── e2e/ │ │ ├── ExampleTest.kt │ │ ├── StoveConfig.kt │ │ └── TestHelper.kt │ └── resources/ │ ├── kotest.properties │ └── logback-test.xml ├── go/ │ └── stove-kafka/ │ ├── bridge.go │ ├── bridge_test.go │ ├── franz/ │ │ ├── hooks.go │ │ └── hooks_test.go │ ├── go.mod │ ├── go.sum │ ├── sarama/ │ │ ├── interceptors.go │ │ └── interceptors_test.go │ ├── segmentio/ │ │ ├── bridge.go │ │ └── bridge_test.go │ └── stoveobserver/ │ ├── messages.pb.go │ └── messages_grpc.pb.go ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── jreleaser.yml ├── lib/ │ ├── stove/ │ │ ├── api/ │ │ │ └── stove.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ ├── containers/ │ │ │ │ ├── ContainerOptions.kt │ │ │ │ ├── ProvidedRegistry.kt │ │ │ │ └── StoveContainer.kt │ │ │ ├── database/ │ │ │ │ └── migrations/ │ │ │ │ ├── DatabaseMigration.kt │ │ │ │ ├── MigrationCollection.kt │ │ │ │ └── SupportsMigrations.kt │ │ │ ├── functional/ │ │ │ │ ├── Extensions.kt │ │ │ │ ├── Reflect.kt │ │ │ │ └── Try.kt │ │ │ ├── http/ │ │ │ │ └── StoveHttpResponse.kt │ │ │ ├── messaging/ │ │ │ │ └── Observation.kt │ │ │ ├── reporting/ │ │ │ │ ├── JsonReportRenderer.kt │ │ │ │ ├── PrettyConsoleRenderer.kt │ │ │ │ ├── ReportEntry.kt │ │ │ │ ├── ReportEventListener.kt │ │ │ │ ├── ReportRenderer.kt │ │ │ │ ├── Reports.kt │ │ │ │ ├── SpanEventListener.kt │ │ │ │ ├── SpanListenerRegistry.kt │ │ │ │ ├── StoveReporter.kt │ │ │ │ ├── StoveTestContext.kt │ │ │ │ ├── StoveTestContextHolder.kt │ │ │ │ ├── StoveTestExceptions.kt │ │ │ │ ├── SystemSnapshot.kt │ │ │ │ ├── TestReport.kt │ │ │ │ └── TraceProvider.kt │ │ │ ├── serialization/ │ │ │ │ ├── gson.kt │ │ │ │ ├── jackson.kt │ │ │ │ ├── kotlinx.kt │ │ │ │ └── serialization.kt │ │ │ ├── system/ │ │ │ │ ├── BridgeSystem.kt │ │ │ │ ├── PortFinder.kt │ │ │ │ ├── PropertiesFile.kt │ │ │ │ ├── ProvidedApplicationUnderTest.kt │ │ │ │ ├── ReadinessChecker.kt │ │ │ │ ├── ReadinessStrategy.kt │ │ │ │ ├── Runner.kt │ │ │ │ ├── Stove.kt │ │ │ │ ├── StoveOptions.kt │ │ │ │ ├── StoveOptionsDsl.kt │ │ │ │ ├── ValidationDsl.kt │ │ │ │ ├── WithDsl.kt │ │ │ │ ├── abstractions/ │ │ │ │ │ ├── ApplicationUnderTest.kt │ │ │ │ │ ├── Exceptions.kt │ │ │ │ │ ├── ExposesConfiguration.kt │ │ │ │ │ ├── PluggedSystem.kt │ │ │ │ │ ├── ReadyStove.kt │ │ │ │ │ ├── RunnableSystemWithContext.kt │ │ │ │ │ ├── StateStorage.kt │ │ │ │ │ ├── SystemKey.kt │ │ │ │ │ ├── SystemOptions.kt │ │ │ │ │ ├── SystemRuntime.kt │ │ │ │ │ ├── ThenSystemContinuation.kt │ │ │ │ │ └── ValidatedSystem.kt │ │ │ │ ├── annotations/ │ │ │ │ │ └── StoveDsl.kt │ │ │ │ └── application/ │ │ │ │ ├── ApplicationConfigurations.kt │ │ │ │ ├── ArgsProvider.kt │ │ │ │ └── EnvProvider.kt │ │ │ └── tracing/ │ │ │ ├── SpanInfo.kt │ │ │ ├── SpanTree.kt │ │ │ ├── TraceContext.kt │ │ │ ├── TraceTreeRenderer.kt │ │ │ └── TraceVisualization.kt │ │ ├── test/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ ├── containers/ │ │ │ │ │ ├── ContainerOptionsTest.kt │ │ │ │ │ ├── ProvidedRegistryTest.kt │ │ │ │ │ └── StoveContainerTest.kt │ │ │ │ ├── database/ │ │ │ │ │ └── migrations/ │ │ │ │ │ ├── MigrationCollectionTest.kt │ │ │ │ │ └── SupportsMigrationsTest.kt │ │ │ │ ├── http/ │ │ │ │ │ └── StoveHttpResponseTest.kt │ │ │ │ ├── messaging/ │ │ │ │ │ └── ObservationTest.kt │ │ │ │ ├── reporting/ │ │ │ │ │ ├── JsonReportRendererTest.kt │ │ │ │ │ ├── PrettyConsoleRendererTest.kt │ │ │ │ │ ├── ReportEntryTest.kt │ │ │ │ │ ├── ReportEventListenerTest.kt │ │ │ │ │ ├── ReportsTest.kt │ │ │ │ │ ├── StoveReporterTest.kt │ │ │ │ │ ├── StoveTestContextTest.kt │ │ │ │ │ ├── StoveTestExceptionsTest.kt │ │ │ │ │ ├── SystemSnapshotTest.kt │ │ │ │ │ ├── TestReportTest.kt │ │ │ │ │ └── TraceProviderTest.kt │ │ │ │ ├── serialization/ │ │ │ │ │ └── SerializationTests.kt │ │ │ │ ├── system/ │ │ │ │ │ ├── BridgeSystemGuardTest.kt │ │ │ │ │ ├── BridgeSystemTest.kt │ │ │ │ │ ├── KeyedSystemTest.kt │ │ │ │ │ ├── PortFinderTest.kt │ │ │ │ │ ├── ProvidedApplicationUnderTestTest.kt │ │ │ │ │ ├── ReadinessCheckerTest.kt │ │ │ │ │ ├── StoveOptionsDslTest.kt │ │ │ │ │ ├── StoveTest.kt │ │ │ │ │ ├── ValidationDslTest.kt │ │ │ │ │ └── abstractions/ │ │ │ │ │ ├── ProvidedSystemOptionsTest.kt │ │ │ │ │ ├── StateStorageKeyTest.kt │ │ │ │ │ └── SystemKeyTest.kt │ │ │ │ └── tracing/ │ │ │ │ ├── SpanInfoTest.kt │ │ │ │ ├── SpanTreeTest.kt │ │ │ │ ├── TraceContextTest.kt │ │ │ │ ├── TraceTreeRendererTest.kt │ │ │ │ └── TraceVisualizationTest.kt │ │ │ └── resources/ │ │ │ └── logback.xml │ │ └── testFixtures/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── CapturedOutput.kt │ ├── stove-bom/ │ │ └── build.gradle.kts │ ├── stove-cassandra/ │ │ ├── api/ │ │ │ └── stove-cassandra.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── cassandra/ │ │ │ ├── CassandraDsl.kt │ │ │ ├── CassandraSystem.kt │ │ │ ├── CassandraSystemOptions.kt │ │ │ └── Options.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── cassandra/ │ │ │ ├── CassandraOptionsTests.kt │ │ │ └── CassandraSystemTests.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback.xml │ ├── stove-couchbase/ │ │ ├── api/ │ │ │ └── stove-couchbase.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── couchbase/ │ │ │ ├── CouchbaseDsl.kt │ │ │ ├── CouchbaseSystem.kt │ │ │ ├── Options.kt │ │ │ ├── StoveCouchbaseContainer.kt │ │ │ └── util.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── couchbase/ │ │ │ ├── CouchbaseOptionsTest.kt │ │ │ ├── CouchbaseTestSystemTests.kt │ │ │ └── TestSystemConfig.kt │ │ └── resources/ │ │ └── kotest.properties │ ├── stove-dashboard/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── dashboard/ │ │ │ ├── DashboardDsl.kt │ │ │ ├── DashboardEmitter.kt │ │ │ ├── DashboardOptions.kt │ │ │ └── DashboardSystem.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── dashboard/ │ │ ├── DashboardEmitterTest.kt │ │ └── DashboardSystemTest.kt │ ├── stove-dashboard-api/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── proto/ │ │ └── stove/ │ │ └── dashboard/ │ │ └── v1/ │ │ ├── dashboard_events.proto │ │ └── dashboard_service.proto │ ├── stove-elasticsearch/ │ │ ├── api/ │ │ │ └── stove-elasticsearch.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── elasticsearch/ │ │ │ ├── ElasticsearchExposedCertificate.kt │ │ │ ├── ElasticsearchSystem.kt │ │ │ ├── Extensions.kt │ │ │ ├── Options.kt │ │ │ └── util.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── elasticsearch/ │ │ │ ├── ElasticsearchExposedCertificateTest.kt │ │ │ ├── ElasticsearchOptionsTest.kt │ │ │ └── ElasticsearchTestSystemTests.kt │ │ └── resources/ │ │ └── kotest.properties │ ├── stove-grpc/ │ │ ├── api/ │ │ │ └── stove-grpc.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── grpc/ │ │ │ ├── GrpcDsl.kt │ │ │ ├── GrpcSystem.kt │ │ │ └── GrpcSystemOptions.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── grpc/ │ │ │ ├── GrpcAuthInterceptorTest.kt │ │ │ ├── GrpcSystemStubTest.kt │ │ │ ├── GrpcSystemWireTest.kt │ │ │ ├── StoveConfig.kt │ │ │ └── TestGrpcServer.kt │ │ ├── proto/ │ │ │ └── test_service.proto │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── stove-grpc-mock/ │ │ ├── api/ │ │ │ └── stove-grpc-mock.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── testing/ │ │ │ └── grpcmock/ │ │ │ ├── GrpcMockDsl.kt │ │ │ ├── GrpcMockSystem.kt │ │ │ ├── GrpcMockSystemOptions.kt │ │ │ └── StubDefinition.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── testing/ │ │ │ └── grpcmock/ │ │ │ ├── GrpcMockSystemTest.kt │ │ │ └── StoveConfig.kt │ │ ├── proto/ │ │ │ └── test_service.proto │ │ └── resources/ │ │ └── kotest.properties │ ├── stove-http/ │ │ ├── api/ │ │ │ └── stove-http.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── http/ │ │ │ ├── HttpClientFactory.kt │ │ │ ├── HttpDsl.kt │ │ │ ├── HttpSystem.kt │ │ │ ├── StoveMultiPartContent.kt │ │ │ ├── streaming.kt │ │ │ └── websocket.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── http/ │ │ │ ├── HttpSystemTests.kt │ │ │ └── WebSocketTests.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── stove-kafka/ │ │ ├── api/ │ │ │ └── stove-kafka.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── kafka/ │ │ │ │ ├── Caching.kt │ │ │ │ ├── Extensions.kt │ │ │ │ ├── KafkaContainerOptions.kt │ │ │ │ ├── KafkaContext.kt │ │ │ │ ├── KafkaExposedConfiguration.kt │ │ │ │ ├── KafkaSystem.kt │ │ │ │ ├── KafkaSystemOptions.kt │ │ │ │ ├── SerDe.kt │ │ │ │ ├── coroutines.kt │ │ │ │ ├── intercepting/ │ │ │ │ │ ├── CommonOps.kt │ │ │ │ │ ├── GrpcUtils.kt │ │ │ │ │ ├── MessageSinkOps.kt │ │ │ │ │ ├── MessageSinkPublishOps.kt │ │ │ │ │ ├── MessageStore.kt │ │ │ │ │ ├── StoveKafkaBridge.kt │ │ │ │ │ ├── StoveKafkaObserverGrpcServer.kt │ │ │ │ │ └── StoveMessageSink.kt │ │ │ │ └── messages.kt │ │ │ └── proto/ │ │ │ └── messages.proto │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── kafka/ │ │ │ ├── setup/ │ │ │ │ ├── TestSystemConfig.kt │ │ │ │ └── example/ │ │ │ │ ├── DomainEvents.kt │ │ │ │ ├── KafkaTestShared.kt │ │ │ │ ├── StoveListener.kt │ │ │ │ └── consumers/ │ │ │ │ ├── ProductConsumer.kt │ │ │ │ └── ProductFailingConsumer.kt │ │ │ └── tests/ │ │ │ ├── CoroutineExecutorServiceTests.kt │ │ │ ├── KafkaOptionsTests.kt │ │ │ ├── KafkaSystemTests.kt │ │ │ ├── MessageStoreTests.kt │ │ │ └── TopicSuffixesTests.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── stove-mongodb/ │ │ ├── api/ │ │ │ └── stove-mongodb.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── mongodb/ │ │ │ ├── MongoDsl.kt │ │ │ ├── MongodbSystem.kt │ │ │ ├── MongodbSystemOptions.kt │ │ │ ├── ObjectIdJsonOperations.kt │ │ │ └── Options.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── mongodb/ │ │ │ ├── MongodbOptionsTests.kt │ │ │ ├── MongodbTestSystemTests.kt │ │ │ └── ObjectIdJsonOperationsTest.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── stove-mssql/ │ │ ├── api/ │ │ │ └── stove-mssql.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── mssql/ │ │ │ ├── MsSqlOptions.kt │ │ │ └── MsSqlSystem.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── mssql/ │ │ │ ├── MsSqlOptionsTest.kt │ │ │ └── MssqlSystemTest.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── stove-mysql/ │ │ ├── api/ │ │ │ └── stove-mysql.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── mysql/ │ │ │ ├── MySqlSystem.kt │ │ │ └── Options.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── mysql/ │ │ │ ├── MySqlOptionsTest.kt │ │ │ ├── MySqlSystemTests.kt │ │ │ └── TestSystemConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback.xml │ ├── stove-postgres/ │ │ ├── api/ │ │ │ └── stove-postgres.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── postgres/ │ │ │ ├── Options.kt │ │ │ └── PostgresqlSystem.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── postgres/ │ │ │ ├── PostgresqlOptionsTest.kt │ │ │ ├── PostgresqlSystemTest.kt │ │ │ └── TestSystemConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback.xml │ ├── stove-rdbms/ │ │ ├── api/ │ │ │ └── stove-rdbms.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── rdbms/ │ │ │ ├── NativeSqlOperations.kt │ │ │ ├── RelationalDatabaseContext.kt │ │ │ ├── RelationalDatabaseExposedConfiguration.kt │ │ │ └── RelationalDatabaseSystem.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── rdbms/ │ │ ├── RelationalDatabaseContextTest.kt │ │ └── RelationalDatabaseSystemTest.kt │ ├── stove-redis/ │ │ ├── api/ │ │ │ └── stove-redis.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── redis/ │ │ │ ├── RedisOptions.kt │ │ │ └── RedisSystem.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── redis/ │ │ │ ├── RedisOptionsTest.kt │ │ │ └── RedisSystemTests.kt │ │ └── resources/ │ │ └── kotest.properties │ ├── stove-tracing/ │ │ ├── api/ │ │ │ └── stove-tracing.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── tracing/ │ │ │ ├── Constants.kt │ │ │ ├── OTLPSpanReceiver.kt │ │ │ ├── StoveTraceCollector.kt │ │ │ ├── TraceReportBuilder.kt │ │ │ ├── TraceValidation.kt │ │ │ ├── TracingOptions.kt │ │ │ └── TracingSystem.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── tracing/ │ │ ├── OtlpSpanReceiverTest.kt │ │ ├── SpanEventListenerTest.kt │ │ ├── SpanInfoTest.kt │ │ ├── SpanTreeTest.kt │ │ ├── StoveTraceCollectorTest.kt │ │ ├── TraceReportBuilderTest.kt │ │ ├── TraceTreeRendererTest.kt │ │ ├── TraceValidationTest.kt │ │ ├── TracingDslTest.kt │ │ ├── TracingOptionsTest.kt │ │ ├── TracingSystemTest.kt │ │ └── TracingValidationScopeTest.kt │ └── stove-wiremock/ │ ├── api/ │ │ └── stove-wiremock.api │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── wiremock/ │ │ ├── Extensions.kt │ │ ├── Options.kt │ │ ├── WireMockBodyMatching.kt │ │ ├── WireMockCallJournal.kt │ │ ├── WireMockReportConstants.kt │ │ ├── WireMockRequestListener.kt │ │ ├── WireMockSnapshot.kt │ │ ├── WireMockSystem.kt │ │ ├── WireMockVacuumCleaner.kt │ │ ├── WireMockVerification.kt │ │ ├── WiremockDsl.kt │ │ └── stubbing.kt │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── wiremock/ │ │ ├── ExtensionsTest.kt │ │ ├── StoveConfig.kt │ │ ├── WireMockDeletionTest.kt │ │ ├── WireMockExposedConfigurationTest.kt │ │ ├── WireMockOperationsTest.kt │ │ ├── WireMockPartialMockingTest.kt │ │ ├── WireMockSystemTests.kt │ │ └── WireMockVerificationTest.kt │ └── resources/ │ └── kotest.properties ├── lint.sh ├── mkdocs.yml ├── plugins/ │ └── stove-tracing-gradle-plugin/ │ ├── build.gradle.kts │ ├── gradle/ │ │ └── libs.versions.toml │ ├── gradle.properties │ ├── settings.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── gradle/ │ │ ├── StoveTracingExtension.kt │ │ ├── StoveTracingPlugin.kt │ │ └── internal/ │ │ ├── JvmArgsBuilder.kt │ │ ├── TestTaskConfigurator.kt │ │ └── TracingDefaults.kt │ └── test/ │ └── kotlin/ │ └── com/ │ └── trendyol/ │ └── stove/ │ └── gradle/ │ └── StoveTracingPluginFunctionalTest.kt ├── pre-commit.sh ├── recipes/ │ ├── jvm/ │ │ ├── .editorconfig │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── buildSrc/ │ │ │ ├── build.gradle.kts │ │ │ ├── settings.gradle.kts │ │ │ └── src/ │ │ │ └── main/ │ │ │ └── kotlin/ │ │ │ └── TestFolders.kt │ │ ├── detekt.yml │ │ ├── gradle/ │ │ │ ├── libs.versions.toml │ │ │ └── wrapper/ │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ │ ├── gradle.properties │ │ ├── gradlew │ │ ├── gradlew.bat │ │ ├── java-recipes/ │ │ │ ├── build.gradle.kts │ │ │ ├── quarkus-basic-recipe/ │ │ │ │ ├── build.gradle.kts │ │ │ │ └── src/ │ │ │ │ ├── main/ │ │ │ │ │ ├── java/ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ └── trendyol/ │ │ │ │ │ │ └── stove/ │ │ │ │ │ │ └── recipes/ │ │ │ │ │ │ └── quarkus/ │ │ │ │ │ │ ├── EnglishGreetingService.java │ │ │ │ │ │ ├── GreetingResource.java │ │ │ │ │ │ ├── GreetingService.java │ │ │ │ │ │ ├── HelloService.java │ │ │ │ │ │ ├── HelloServiceImpl.java │ │ │ │ │ │ ├── InMemoryItemRepository.java │ │ │ │ │ │ ├── Item.java │ │ │ │ │ │ ├── ItemRepository.java │ │ │ │ │ │ ├── QuarkusMainApp.java │ │ │ │ │ │ ├── SpanishGreetingService.java │ │ │ │ │ │ ├── StoveStartupSignal.java │ │ │ │ │ │ └── TurkishGreetingService.java │ │ │ │ │ └── resources/ │ │ │ │ │ └── application.properties │ │ │ │ └── test-e2e/ │ │ │ │ ├── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── trendyol/ │ │ │ │ │ └── stove/ │ │ │ │ │ └── recipes/ │ │ │ │ │ └── quarkus/ │ │ │ │ │ └── e2e/ │ │ │ │ │ ├── setup/ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ └── StoveConfig.kt │ │ │ │ │ └── tests/ │ │ │ │ │ └── IndexTests.kt │ │ │ │ └── resources/ │ │ │ │ ├── kotest.properties │ │ │ │ └── logback-test.xml │ │ │ └── spring-boot-postgres-recipe/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── java/ │ │ │ │ │ └── com/ │ │ │ │ │ └── trendyol/ │ │ │ │ │ └── stove/ │ │ │ │ │ └── examples/ │ │ │ │ │ └── java/ │ │ │ │ │ └── spring/ │ │ │ │ │ ├── ExampleSpringBootApp.java │ │ │ │ │ ├── application/ │ │ │ │ │ │ ├── external/ │ │ │ │ │ │ │ └── category/ │ │ │ │ │ │ │ ├── CategoryApiSpringConfiguration.java │ │ │ │ │ │ │ ├── CategoryHttpApi.java │ │ │ │ │ │ │ ├── CategoryHttpApiConfiguration.java │ │ │ │ │ │ │ └── CategoryHttpApiImpl.java │ │ │ │ │ │ └── product/ │ │ │ │ │ │ ├── command/ │ │ │ │ │ │ │ └── ProductApplicationService.java │ │ │ │ │ │ └── messaging/ │ │ │ │ │ │ └── ProductEventHandlerListener.java │ │ │ │ │ ├── domain/ │ │ │ │ │ │ └── ProductReactiveRepository.java │ │ │ │ │ └── infra/ │ │ │ │ │ ├── boilerplate/ │ │ │ │ │ │ ├── http/ │ │ │ │ │ │ │ └── ControllerAdvice.java │ │ │ │ │ │ ├── kafka/ │ │ │ │ │ │ │ ├── KafkaBeanConfiguration.java │ │ │ │ │ │ │ ├── KafkaConfiguration.java │ │ │ │ │ │ │ ├── KafkaDomainEventPublisher.java │ │ │ │ │ │ │ ├── Topic.java │ │ │ │ │ │ │ └── TopicResolver.java │ │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ │ └── PostgresConfiguration.java │ │ │ │ │ │ └── serialization/ │ │ │ │ │ │ └── JacksonConfiguration.java │ │ │ │ │ └── components/ │ │ │ │ │ ├── index/ │ │ │ │ │ │ └── IndexController.java │ │ │ │ │ └── product/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── ProductController.java │ │ │ │ │ │ └── ProductCreateRequest.java │ │ │ │ │ └── persistency/ │ │ │ │ │ └── JdbcProductRepository.java │ │ │ │ └── resources/ │ │ │ │ └── application.yml │ │ │ └── test-e2e/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── example/ │ │ │ │ └── java/ │ │ │ │ └── spring/ │ │ │ │ └── e2e/ │ │ │ │ ├── setup/ │ │ │ │ │ ├── CreateProductsTableMigration.kt │ │ │ │ │ ├── Stove.kt │ │ │ │ │ └── TestData.kt │ │ │ │ └── tests/ │ │ │ │ ├── IndexTests.kt │ │ │ │ └── product/ │ │ │ │ └── CreateTests.kt │ │ │ └── resources/ │ │ │ ├── kotest.properties │ │ │ └── logback-test.xml │ │ ├── kotlin-recipes/ │ │ │ ├── build.gradle.kts │ │ │ ├── ktor-mongo-recipe/ │ │ │ │ ├── build.gradle.kts │ │ │ │ └── src/ │ │ │ │ ├── main/ │ │ │ │ │ ├── kotlin/ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ └── trendyol/ │ │ │ │ │ │ └── stove/ │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ └── kotlin/ │ │ │ │ │ │ └── ktor/ │ │ │ │ │ │ ├── ExampleStoveKtorApp.kt │ │ │ │ │ │ ├── application/ │ │ │ │ │ │ │ ├── RecipeAppConfig.kt │ │ │ │ │ │ │ ├── external/ │ │ │ │ │ │ │ │ ├── CategoryHttpApi.kt │ │ │ │ │ │ │ │ └── CategoryHttpApiImpl.kt │ │ │ │ │ │ │ └── product/ │ │ │ │ │ │ │ └── command/ │ │ │ │ │ │ │ ├── ProductCommandHandler.kt │ │ │ │ │ │ │ └── handling.kt │ │ │ │ │ │ ├── domain/ │ │ │ │ │ │ │ └── product/ │ │ │ │ │ │ │ └── ProductRepository.kt │ │ │ │ │ │ └── infra/ │ │ │ │ │ │ ├── boilerplate/ │ │ │ │ │ │ │ ├── http/ │ │ │ │ │ │ │ │ └── http.kt │ │ │ │ │ │ │ ├── kafka/ │ │ │ │ │ │ │ │ ├── ConsumerEngine.kt │ │ │ │ │ │ │ │ ├── ConsumerSupervisor.kt │ │ │ │ │ │ │ │ ├── KafkaDomainEventPublisher.kt │ │ │ │ │ │ │ │ ├── SerDe.kt │ │ │ │ │ │ │ │ ├── Topic.kt │ │ │ │ │ │ │ │ ├── TopicResolver.kt │ │ │ │ │ │ │ │ └── kafka.kt │ │ │ │ │ │ │ ├── kediatr/ │ │ │ │ │ │ │ │ └── kediatr.kt │ │ │ │ │ │ │ ├── mongo/ │ │ │ │ │ │ │ │ └── mongo.kt │ │ │ │ │ │ │ ├── serialization/ │ │ │ │ │ │ │ │ └── JacksonConfiguration.kt │ │ │ │ │ │ │ └── util.kt │ │ │ │ │ │ └── components/ │ │ │ │ │ │ ├── external/ │ │ │ │ │ │ │ └── category.kt │ │ │ │ │ │ └── product/ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── routing.kt │ │ │ │ │ │ ├── defs.kt │ │ │ │ │ │ ├── messaging/ │ │ │ │ │ │ │ └── ProductAggregateRootEventsConsumer.kt │ │ │ │ │ │ └── persistency/ │ │ │ │ │ │ └── MongoProductRepository.kt │ │ │ │ │ └── resources/ │ │ │ │ │ └── application.yaml │ │ │ │ └── test-e2e/ │ │ │ │ ├── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── trendyol/ │ │ │ │ │ └── stove/ │ │ │ │ │ └── examples/ │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── ktor/ │ │ │ │ │ └── e2e/ │ │ │ │ │ ├── setup/ │ │ │ │ │ │ ├── StoveConfig.kt │ │ │ │ │ │ └── TestData.kt │ │ │ │ │ └── tests/ │ │ │ │ │ ├── IndexTests.kt │ │ │ │ │ ├── configuration/ │ │ │ │ │ │ └── ConfigurationTests.kt │ │ │ │ │ └── product/ │ │ │ │ │ └── CreateTests.kt │ │ │ │ └── resources/ │ │ │ │ ├── kotest.properties │ │ │ │ └── logback-test.xml │ │ │ ├── ktor-postgres-recipe/ │ │ │ │ ├── build.gradle.kts │ │ │ │ └── src/ │ │ │ │ ├── main/ │ │ │ │ │ ├── kotlin/ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ └── trendyol/ │ │ │ │ │ │ └── stove/ │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ └── kotlin/ │ │ │ │ │ │ └── ktor/ │ │ │ │ │ │ ├── ExampleStoveKtorApp.kt │ │ │ │ │ │ ├── application/ │ │ │ │ │ │ │ ├── RecipeAppConfig.kt │ │ │ │ │ │ │ ├── external/ │ │ │ │ │ │ │ │ ├── CategoryHttpApi.kt │ │ │ │ │ │ │ │ └── CategoryHttpApiImpl.kt │ │ │ │ │ │ │ └── product/ │ │ │ │ │ │ │ └── command/ │ │ │ │ │ │ │ ├── ProductCommandHandler.kt │ │ │ │ │ │ │ └── handling.kt │ │ │ │ │ │ ├── domain/ │ │ │ │ │ │ │ └── product/ │ │ │ │ │ │ │ └── ProductRepository.kt │ │ │ │ │ │ └── infra/ │ │ │ │ │ │ ├── boilerplate/ │ │ │ │ │ │ │ ├── http/ │ │ │ │ │ │ │ │ └── http.kt │ │ │ │ │ │ │ ├── kafka/ │ │ │ │ │ │ │ │ ├── ConsumerEngine.kt │ │ │ │ │ │ │ │ ├── ConsumerSupervisor.kt │ │ │ │ │ │ │ │ ├── KafkaDomainEventPublisher.kt │ │ │ │ │ │ │ │ ├── SerDe.kt │ │ │ │ │ │ │ │ ├── Topic.kt │ │ │ │ │ │ │ │ ├── TopicResolver.kt │ │ │ │ │ │ │ │ └── kafka.kt │ │ │ │ │ │ │ ├── kediatr/ │ │ │ │ │ │ │ │ └── kediatr.kt │ │ │ │ │ │ │ ├── serialization/ │ │ │ │ │ │ │ │ └── JacksonConfiguration.kt │ │ │ │ │ │ │ └── util.kt │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── external/ │ │ │ │ │ │ │ │ └── category.kt │ │ │ │ │ │ │ └── product/ │ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ │ └── routing.kt │ │ │ │ │ │ │ ├── defs.kt │ │ │ │ │ │ │ ├── messaging/ │ │ │ │ │ │ │ │ └── ProductAggregateRootEventsConsumer.kt │ │ │ │ │ │ │ └── persistency/ │ │ │ │ │ │ │ ├── PostgresProductRepository.kt │ │ │ │ │ │ │ └── Product.kt │ │ │ │ │ │ └── postgres/ │ │ │ │ │ │ ├── flyway.kt │ │ │ │ │ │ └── postgres.kt │ │ │ │ │ └── resources/ │ │ │ │ │ ├── application.yaml │ │ │ │ │ └── db/ │ │ │ │ │ └── migration/ │ │ │ │ │ └── V1__init.sql │ │ │ │ └── test-e2e/ │ │ │ │ ├── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── trendyol/ │ │ │ │ │ └── stove/ │ │ │ │ │ └── examples/ │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── ktor/ │ │ │ │ │ └── e2e/ │ │ │ │ │ ├── setup/ │ │ │ │ │ │ ├── StoveConfig.kt │ │ │ │ │ │ └── TestData.kt │ │ │ │ │ └── tests/ │ │ │ │ │ ├── IndexTests.kt │ │ │ │ │ ├── configuration/ │ │ │ │ │ │ └── ConfigurationTests.kt │ │ │ │ │ └── product/ │ │ │ │ │ └── CreateTests.kt │ │ │ │ └── resources/ │ │ │ │ ├── kotest.properties │ │ │ │ └── logback-test.xml │ │ │ └── spring-showcase/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── trendyol/ │ │ │ │ │ └── stove/ │ │ │ │ │ └── examples/ │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── spring/ │ │ │ │ │ ├── ExampleStoveSpringBootApp.kt │ │ │ │ │ ├── domain/ │ │ │ │ │ │ ├── order/ │ │ │ │ │ │ │ ├── Order.kt │ │ │ │ │ │ │ ├── OrderController.kt │ │ │ │ │ │ │ ├── OrderRepository.kt │ │ │ │ │ │ │ └── OrderService.kt │ │ │ │ │ │ └── statistics/ │ │ │ │ │ │ └── UserOrderStatistics.kt │ │ │ │ │ ├── events/ │ │ │ │ │ │ ├── OrderCreatedEvent.kt │ │ │ │ │ │ └── PaymentProcessedEvent.kt │ │ │ │ │ └── infra/ │ │ │ │ │ ├── GlobalErrorHandler.kt │ │ │ │ │ ├── clients/ │ │ │ │ │ │ ├── FraudDetectionClient.kt │ │ │ │ │ │ ├── InventoryClient.kt │ │ │ │ │ │ └── PaymentClient.kt │ │ │ │ │ ├── grpc/ │ │ │ │ │ │ ├── GrpcErrorSpanInterceptor.kt │ │ │ │ │ │ ├── GrpcServerConfig.kt │ │ │ │ │ │ └── OrderQueryGrpcService.kt │ │ │ │ │ ├── kafka/ │ │ │ │ │ │ ├── KafkaConfig.kt │ │ │ │ │ │ ├── OrderCreatedEventListener.kt │ │ │ │ │ │ └── OrderEventPublisher.kt │ │ │ │ │ ├── persistence/ │ │ │ │ │ │ ├── DataSourceConfig.kt │ │ │ │ │ │ ├── PostgresOrderRepository.kt │ │ │ │ │ │ └── PostgresUserOrderStatisticsRepository.kt │ │ │ │ │ └── scheduling/ │ │ │ │ │ ├── DbSchedulerConfig.kt │ │ │ │ │ ├── EmailSchedulerService.kt │ │ │ │ │ └── SendOrderEmailTask.kt │ │ │ │ ├── proto/ │ │ │ │ │ ├── fraud_detection.proto │ │ │ │ │ └── order_query.proto │ │ │ │ └── resources/ │ │ │ │ └── application.yml │ │ │ └── test-e2e/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── examples/ │ │ │ │ └── kotlin/ │ │ │ │ └── spring/ │ │ │ │ └── e2e/ │ │ │ │ ├── painful/ │ │ │ │ │ └── BaseIntegrationTest.kt │ │ │ │ ├── setup/ │ │ │ │ │ ├── DbSchedulerSystem.kt │ │ │ │ │ ├── OrderExampleInitialMigration.kt │ │ │ │ │ └── StoveConfig.kt │ │ │ │ └── tests/ │ │ │ │ ├── StreamingTests.kt │ │ │ │ └── TheShowcase.kt │ │ │ └── resources/ │ │ │ ├── kotest.properties │ │ │ └── logback-test.xml │ │ ├── scala-recipes/ │ │ │ ├── build.gradle.kts │ │ │ └── spring-boot-basic-recipe/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ └── scala/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── recipes/ │ │ │ │ └── scala/ │ │ │ │ └── spring/ │ │ │ │ └── SpringBootRecipeApp.scala │ │ │ └── test-e2e/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── recipes/ │ │ │ │ └── scala/ │ │ │ │ └── spring/ │ │ │ │ └── e2e/ │ │ │ │ ├── setup/ │ │ │ │ │ └── StoveConfig.kt │ │ │ │ └── tests/ │ │ │ │ └── IndexTests.kt │ │ │ └── resources/ │ │ │ └── kotest.properties │ │ ├── settings.gradle.kts │ │ └── shared/ │ │ ├── application/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── main/ │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── recipes/ │ │ │ └── shared/ │ │ │ └── application/ │ │ │ ├── BusinessException.java │ │ │ ├── ErrorResponse.java │ │ │ ├── ExternalApiConfiguration.java │ │ │ └── category/ │ │ │ ├── CategoryApiConfiguration.java │ │ │ └── CategoryApiResponse.java │ │ └── domain/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── examples/ │ │ │ └── domain/ │ │ │ ├── ddd/ │ │ │ │ ├── AggregateRoot.java │ │ │ │ ├── DomainEvent.java │ │ │ │ ├── Entity.java │ │ │ │ ├── EventPublisher.java │ │ │ │ ├── EventRecorder.java │ │ │ │ └── EventRouter.java │ │ │ └── product/ │ │ │ ├── Product.java │ │ │ └── events/ │ │ │ ├── ProductCreatedEvent.java │ │ │ ├── ProductNameChangedEvent.java │ │ │ └── ProductPriceChangedEvent.java │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── examples/ │ │ └── domain/ │ │ ├── ProductTests.kt │ │ └── testing/ │ │ └── aggregateroot/ │ │ └── AggregateRootAssertion.kt │ └── process/ │ └── golang/ │ └── go-showcase/ │ ├── .dockerignore │ ├── .editorconfig │ ├── .gitignore │ ├── Dockerfile.container │ ├── build.gradle.kts │ ├── db.go │ ├── go.mod │ ├── go.sum │ ├── gradle/ │ │ ├── libs.versions.toml │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ ├── handlers.go │ ├── kafka.go │ ├── kafka_franz.go │ ├── kafka_sarama.go │ ├── kafka_segmentio.go │ ├── main.go │ ├── settings.gradle.kts │ ├── stovetests/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── examples/ │ │ │ └── go/ │ │ │ └── e2e/ │ │ │ ├── setup/ │ │ │ │ ├── ProductMigration.kt │ │ │ │ └── StoveConfig.kt │ │ │ └── tests/ │ │ │ └── GoShowcaseTest.kt │ │ └── resources/ │ │ └── kotest.properties │ └── tracing.go ├── renovate.json ├── settings.gradle.kts ├── starters/ │ ├── container/ │ │ └── stove-container/ │ │ ├── api/ │ │ │ └── stove-container.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── container/ │ │ │ ├── ContainerApplicationUnderTest.kt │ │ │ ├── ContainerDsl.kt │ │ │ └── ContainerTarget.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── container/ │ │ ├── ContainerApplicationUnderTestTest.kt │ │ ├── ContainerDslTest.kt │ │ └── ContainerTargetTest.kt │ ├── ktor/ │ │ ├── stove-ktor/ │ │ │ ├── api/ │ │ │ │ └── stove-ktor.api │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ └── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── ktor/ │ │ │ │ ├── DependencyResolvers.kt │ │ │ │ ├── KtorApplicationUnderTest.kt │ │ │ │ ├── KtorBridgeSystem.kt │ │ │ │ └── KtorDiCheck.kt │ │ │ └── test/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── ktor/ │ │ │ ├── DependencyResolversLinkageTest.kt │ │ │ └── KtorDiCheckTest.kt │ │ └── tests/ │ │ ├── ktor-di-tests/ │ │ │ ├── api/ │ │ │ │ └── ktor-di-tests.api │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── test/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── ktor/ │ │ │ │ ├── AutoDetectRuntimeStateTest.kt │ │ │ │ ├── StoveConfig.kt │ │ │ │ └── app.kt │ │ │ └── resources/ │ │ │ └── simplelogger.properties │ │ ├── ktor-koin-tests/ │ │ │ ├── api/ │ │ │ │ └── ktor-koin-tests.api │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── test/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── ktor/ │ │ │ │ ├── AutoDetectRuntimeSelectionTest.kt │ │ │ │ ├── StoveConfig.kt │ │ │ │ └── app.kt │ │ │ └── resources/ │ │ │ └── simplelogger.properties │ │ └── ktor-test-fixtures/ │ │ ├── api/ │ │ │ └── ktor-test-fixtures.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── testFixtures/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── ktor/ │ │ ├── BridgeSystemTests.kt │ │ └── TestDomain.kt │ ├── micronaut/ │ │ └── stove-micronaut/ │ │ ├── api/ │ │ │ └── stove-micronaut.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── trendyol/ │ │ │ │ └── stove/ │ │ │ │ └── micronaut/ │ │ │ │ ├── MicronautApplicationUnderTest.kt │ │ │ │ └── MicronautBridgeSystem.kt │ │ │ └── resources/ │ │ │ ├── application.properties │ │ │ └── logback.xml │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── BridgeSystemTestConfig.kt │ │ └── resources/ │ │ └── kotest.properties │ ├── process/ │ │ └── stove-process/ │ │ ├── api/ │ │ │ └── stove-process.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── process/ │ │ │ ├── ArgsProvider.kt │ │ │ ├── EnvProvider.kt │ │ │ ├── ProcessApplicationOptions.kt │ │ │ ├── ProcessApplicationUnderTest.kt │ │ │ └── ProcessDsl.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── process/ │ │ ├── ArgsProviderTest.kt │ │ ├── EnvProviderTest.kt │ │ └── ProcessApplicationUnderTestTest.kt │ ├── quarkus/ │ │ └── stove-quarkus/ │ │ ├── api/ │ │ │ └── stove-quarkus.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── quarkus/ │ │ └── QuarkusApplicationUnderTest.kt │ └── spring/ │ ├── stove-spring/ │ │ ├── api/ │ │ │ ├── stove-spring-common.api │ │ │ └── stove-spring.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── spring/ │ │ │ ├── BridgeSystem.kt │ │ │ ├── SpringApplicationUnderTest.kt │ │ │ ├── SpringBootVersionCheck.kt │ │ │ └── registrar.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ ├── SpringApplicationUnderTestTests.kt │ │ ├── SpringBridgeSystemTests.kt │ │ └── spring/ │ │ └── SpringBootVersionCheckTest.kt │ ├── stove-spring-kafka/ │ │ ├── api/ │ │ │ ├── stove-spring-kafka-common.api │ │ │ └── stove-spring-kafka.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── kafka/ │ │ │ ├── Caching.kt │ │ │ ├── Extensions.kt │ │ │ ├── KafkaDsl.kt │ │ │ ├── KafkaSystem.kt │ │ │ ├── KafkaTemplateCompatibility.kt │ │ │ ├── MessageStore.kt │ │ │ ├── Options.kt │ │ │ ├── SpringKafkaVersionCheck.kt │ │ │ ├── StoveMessage.kt │ │ │ └── TestSystemKafkaInterceptor.kt │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── kafka/ │ │ ├── CachingTests.kt │ │ ├── ExtensionsTests.kt │ │ ├── KafkaOptionsTest.kt │ │ ├── MessageStoreTests.kt │ │ ├── SpringKafkaVersionCheckTest.kt │ │ └── StoveMessageTests.kt │ └── tests/ │ ├── spring-2x-kafka-tests/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── kafka/ │ │ │ ├── protobufserde/ │ │ │ │ ├── ProtobufSerdeKafkaSystemTest.kt │ │ │ │ └── app.kt │ │ │ ├── shared.kt │ │ │ └── stringserde/ │ │ │ ├── StringSerdeKafkaSystemTest.kt │ │ │ └── app.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-2x-tests/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── StoveConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-3x-kafka-tests/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── kafka/ │ │ │ ├── protobufserde/ │ │ │ │ ├── ProtobufSerdeKafkaSystemTest.kt │ │ │ │ └── app.kt │ │ │ ├── shared.kt │ │ │ └── stringserde/ │ │ │ ├── StringSerdeKafkaSystemTest.kt │ │ │ └── app.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-3x-tests/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── StoveConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-4x-kafka-tests/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── kafka/ │ │ │ ├── protobufserde/ │ │ │ │ ├── ProtobufSerdeKafkaSystemTest.kt │ │ │ │ └── app.kt │ │ │ ├── shared.kt │ │ │ └── stringserde/ │ │ │ ├── StringSerdeKafkaSystemTest.kt │ │ │ └── app.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ ├── spring-4x-tests/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── StoveConfig.kt │ │ └── resources/ │ │ ├── kotest.properties │ │ └── logback-test.xml │ └── spring-test-fixtures/ │ ├── build.gradle.kts │ └── src/ │ └── testFixtures/ │ ├── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ ├── BridgeSystemTests.kt │ │ ├── TestDomain.kt │ │ └── kafka/ │ │ ├── KafkaTestDomain.kt │ │ ├── ProtobufSerdeKafkaSystemTests.kt │ │ ├── ProtobufTestUtils.kt │ │ └── StringSerdeKafkaSystemTests.kt │ └── proto/ │ └── example.proto ├── test-extensions/ │ ├── stove-extensions-junit/ │ │ ├── api/ │ │ │ └── stove-extensions-junit.api │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── main/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── extensions/ │ │ │ └── junit/ │ │ │ └── StoveJUnitExtension.kt │ │ └── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── trendyol/ │ │ │ └── stove/ │ │ │ └── extensions/ │ │ │ └── junit/ │ │ │ └── StoveJUnitExtensionTest.kt │ │ └── resources/ │ │ └── logback-test.xml │ └── stove-extensions-kotest/ │ ├── api/ │ │ └── stove-extensions-kotest.api │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── extensions/ │ │ └── kotest/ │ │ └── StoveKotestExtension.kt │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── trendyol/ │ │ └── stove/ │ │ └── extensions/ │ │ └── kotest/ │ │ ├── KotestHierarchyExplorationTest.kt │ │ └── StoveKotestExtensionTest.kt │ └── resources/ │ ├── kotest.properties │ └── logback-test.xml └── tools/ └── stove-cli/ ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── copilot.data.migration.ask2agent.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── modules.xml │ └── vcs.xml ├── Cargo.toml ├── Formula/ │ └── stove.rb ├── build.rs ├── clippy.toml ├── install.sh ├── rustfmt.toml ├── spa/ │ ├── biome.json │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── src/ │ │ ├── App.tsx │ │ ├── api/ │ │ │ ├── client.ts │ │ │ ├── live-cache.ts │ │ │ ├── sse.ts │ │ │ └── types.ts │ │ ├── components/ │ │ │ ├── Badge.tsx │ │ │ ├── CapturedStateLane.tsx │ │ │ ├── Detail.tsx │ │ │ ├── DurationEdge.tsx │ │ │ ├── EntryDetails.tsx │ │ │ ├── EntryRow.tsx │ │ │ ├── FlowDag.tsx │ │ │ ├── FlowTab.tsx │ │ │ ├── GapNode.tsx │ │ │ ├── JsonTree.tsx │ │ │ ├── NodePopup.tsx │ │ │ ├── ResultIcon.tsx │ │ │ ├── SnapshotCards.tsx │ │ │ ├── SnapshotMetricTiles.tsx │ │ │ ├── SnapshotStateDialog.tsx │ │ │ ├── SpanTree.tsx │ │ │ ├── SysBadge.tsx │ │ │ ├── SystemNode.tsx │ │ │ └── VersionMismatchBanner.tsx │ │ ├── hooks/ │ │ │ ├── useAppData.ts │ │ │ └── useTheme.tsx │ │ ├── index.css │ │ ├── layout/ │ │ │ ├── Header.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── TestDetail.tsx │ │ │ ├── detail/ │ │ │ │ ├── TabBar.tsx │ │ │ │ └── TestHeader.tsx │ │ │ └── sidebar/ │ │ │ ├── AppPicker.tsx │ │ │ ├── RunSummary.tsx │ │ │ ├── TestFilters.tsx │ │ │ ├── TestListItem.tsx │ │ │ └── TestTree.tsx │ │ ├── main.tsx │ │ ├── utils/ │ │ │ ├── filters.ts │ │ │ ├── flow.ts │ │ │ ├── format.ts │ │ │ ├── json.ts │ │ │ ├── result.ts │ │ │ ├── snapshot-state.ts │ │ │ ├── status.ts │ │ │ ├── systems.ts │ │ │ └── version-mismatch.ts │ │ └── vite-env.d.ts │ ├── test/ │ │ ├── api-client.test.mjs │ │ ├── flow.test.mjs │ │ ├── json.test.mjs │ │ ├── live-cache.test.mjs │ │ ├── snapshot-state.test.mjs │ │ └── version-mismatch.test.mjs │ ├── tsconfig.json │ └── vite.config.ts ├── src/ │ ├── config.rs │ ├── error.rs │ ├── grpc/ │ │ ├── mod.rs │ │ └── service.rs │ ├── http/ │ │ ├── mod.rs │ │ ├── routes/ │ │ │ ├── meta.rs │ │ │ ├── mod.rs │ │ │ ├── runs.rs │ │ │ ├── sse.rs │ │ │ ├── static_files.rs │ │ │ ├── tests.rs │ │ │ └── traces.rs │ │ └── server.rs │ ├── ingest.rs │ ├── lib.rs │ ├── main.rs │ ├── mcp/ │ │ ├── analysis/ │ │ │ └── evidence.rs │ │ ├── analysis.rs │ │ ├── args.rs │ │ ├── contract.rs │ │ ├── mod.rs │ │ ├── protocol.rs │ │ ├── security.rs │ │ └── tools.rs │ ├── skills.rs │ ├── sse/ │ │ ├── manager.rs │ │ └── mod.rs │ └── storage/ │ ├── database.rs │ ├── migrations/ │ │ ├── V1__initial_schema.sql │ │ ├── V2__run_stove_version.sql │ │ └── V3__test_path.sql │ ├── mod.rs │ ├── models.rs │ └── repository.rs └── tests/ ├── api_e2e.rs ├── common/ │ └── mod.rs └── mcp_e2e.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/skills/stove/SKILL.md ================================================ --- name: stove description: Use when adding Stove e2e tests to a project, configuring Stove systems (HTTP, PostgreSQL, MySQL, MSSQL, Cassandra, MongoDB, Redis, Elasticsearch, Couchbase, Kafka, WireMock, gRPC, Dashboard), writing tests with the stove {} DSL, enabling OpenTelemetry tracing, writing AbstractProjectConfig, extending Stove with custom systems, setting up smoke tests against remote/deployed applications (providedApplication), registering multiple instances of the same system type (keyed systems with SystemKey), testing non-JVM applications (Go, Python, Rust, Node.js) with processApp()/goApp() from stove-process or containerApp() from stove-container (target/readiness, env/args mapping, image-based AUT), the Stove Kafka bridge library (stove-kafka for Go with IBM/sarama, twmb/franz-go, or segmentio/kafka-go), or collecting Go code coverage from Stove black-box tests (go build -cover, GOCOVERDIR, SIGPIPE handling). --- # Setting Up Stove E2E Tests Copy this checklist and track progress: ``` Setup Progress: - [ ] Step 1: Create test-e2e source set layout - [ ] Step 2: Configure Gradle (BOM, source set, e2eTest task) - [ ] Step 3: Extract run() function from application entry point - [ ] Step 4: Create StoveConfig (AbstractProjectConfig) - [ ] Step 5: Create kotest.properties (Kotest only) - [ ] Step 6: Configure systems inside Stove().with { } - [ ] Step 7: Configure tracing (optional) - [ ] Step 8: Write tests using stove {} DSL ``` Important: Stove e2e tests are Kotlin-first. Even if your application is Java/Scala, keep e2e tests under `src/test-e2e/kotlin` and write Stove setup/tests in Kotlin. ## Step 1: Project structure ``` your-module/src/ main/(kotlin|java)/ test/(kotlin|java)/ test-e2e/ kotlin/com/yourcompany/yourapp/e2e/ setup/ StoveConfig.kt InitialMigration.kt tests/ OrderE2ETest.kt resources/ kotest.properties ``` ## Step 2: Gradle configuration For source set registration, e2eTest task, and IDE integration details, see [gradle-config.md](gradle-config.md). Add dependencies using the BOM: ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$stoveVersion")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") testImplementation("com.trendyol:stove-extensions-kotest") // Add only what you need: testImplementation("com.trendyol:stove-http") testImplementation("com.trendyol:stove-postgres") testImplementation("com.trendyol:stove-mysql") testImplementation("com.trendyol:stove-mssql") testImplementation("com.trendyol:stove-cassandra") testImplementation("com.trendyol:stove-mongodb") testImplementation("com.trendyol:stove-redis") testImplementation("com.trendyol:stove-elasticsearch") testImplementation("com.trendyol:stove-couchbase") testImplementation("com.trendyol:stove-kafka") testImplementation("com.trendyol:stove-spring-kafka") testImplementation("com.trendyol:stove-wiremock") testImplementation("com.trendyol:stove-grpc") testImplementation("com.trendyol:stove-grpc-mock") testImplementation("com.trendyol:stove-tracing") testImplementation("com.trendyol:stove-dashboard") testImplementation("com.trendyol:stove-process") // non-JVM apps (Go, Python, etc.) testImplementation("com.trendyol:stove-container") // non-JVM apps as Docker images } ``` For Ktor, replace `stove-spring` with `stove-ktor`. For Quarkus, use `stove-quarkus`. For Micronaut, use `stove-micronaut`. For JUnit, replace `stove-extensions-kotest` with `stove-extensions-junit` and skip Step 5. If you are unsure about Stove API names/signatures, verify from local downloaded artifacts (Gradle cache or Maven local repo) before writing code. See [gradle-config.md](gradle-config.md#resolve-api-ambiguity-from-local-artifacts). ## Step 3: Extract run() Stove starts your application from tests. Extract the entry point: ```kotlin // src/main/kotlin/.../MyApp.kt @SpringBootApplication class MyApp fun main(args: Array) = run(args) fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext = runApplication(*args) { init() } ``` ## Step 4: StoveConfig ```kotlin class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { // Systems go here — see Step 6 }.run() } override suspend fun afterProject() { Stove.stop() } } ``` For JUnit, see [gradle-config.md](gradle-config.md) for the `BaseE2ETest` pattern. ## Step 5: kotest.properties (Kotest only) Create `src/test-e2e/resources/kotest.properties`: ```properties kotest.framework.config.fqn=com.yourcompany.yourapp.e2e.setup.StoveConfig ``` ## Step 6: Configure systems Configure inside `Stove().with { }`. For all options per system, see [system-setup.md](system-setup.md). ```kotlin Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } bridge() // Optional (requires com.trendyol:stove-tracing) tracing { enableSpanReceiver() } wiremock { WireMockSystemOptions( configureExposedConfiguration = { cfg -> listOf("payment.url=${cfg.baseUrl}") } ) } postgresql { PostgresqlOptions( databaseName = "testdb", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(), configureExposedConfiguration = { cfg -> listOf( "spring.kafka.bootstrap-servers=${cfg.bootstrapServers}", "spring.kafka.producer.properties.interceptor.classes=${cfg.interceptorClass}", "spring.kafka.consumer.properties.interceptor.classes=${cfg.interceptorClass}" ) } ) } mongodb { MongodbSystemOptions( databaseOptions = DatabaseOptions( default = DefaultDatabase(name = "testdb", collection = "orders") ), configureExposedConfiguration = { cfg -> listOf("spring.data.mongodb.uri=${cfg.connectionString}") } ) } // Optional: streams test events to stove CLI dashboard dashboard { DashboardSystemOptions(appName = "my-service") } // Application runner goes last springBoot( runner = { params -> com.yourcompany.yourapp.run(params) { addTestDependencies { bean>(isPrimary = true) bean { StoveSerde.jackson.anyByteArraySerde() } } } }, withParameters = listOf("server.port=8080") ) }.run() ``` For Spring Boot 4.x, use: ```kotlin addTestDependencies4x { registerBean>(primary = true) registerBean { StoveSerde.jackson.anyByteArraySerde() } } ``` For Ktor: ```kotlin ktor( runner = { params -> com.yourcompany.yourapp.run(params, wait = false) }, withParameters = listOf("server.port=8080") ) ``` For Quarkus: ```kotlin quarkus( runner = { params -> com.yourcompany.yourapp.main(params) }, withParameters = listOf("quarkus.http.port=8080") ) ``` For Micronaut: ```kotlin micronaut( runner = { params -> com.yourcompany.yourapp.run(params) }, withParameters = listOf("micronaut.server.port=8080") ) ``` ## Step 7: Tracing (optional) For full plugin options, buildSrc alternative, and trace validation DSL, see [tracing.md](tracing.md). ```kotlin plugins { id("com.trendyol.stove.tracing") version "$stoveVersion" } stoveTracing { serviceName.set("my-service") testTaskNames.set(listOf("e2eTest")) } ``` ## Step 8: Write tests For the complete DSL reference (HTTP, PostgreSQL, MySQL, MSSQL, Cassandra, MongoDB, Redis, Elasticsearch, Couchbase, Kafka, WireMock, gRPC Mock, gRPC Client, Bridge, multi-system examples), see [writing-tests.md](writing-tests.md). ```kotlin class OrderE2ETest : FunSpec({ test("should create order and publish event") { stove { val userId = "user-${UUID.randomUUID()}" wiremock { mockGet("/inventory/item-1", 200, InventoryResponse(true).some()) } http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(userId, 99.99).some() ) { response -> response.status shouldBe 201 } } postgresql { shouldQuery( query = "SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> OrderRow(row.string("id"), row.string("status")) } ) { it.size shouldBe 1 } } kafka { shouldBePublished(10.seconds) { actual.userId == userId } } } } }) ``` ## Smoke testing with providedApplication Stove can test against **already-deployed** applications — any language, any framework. Use `providedApplication()` instead of a JVM runner. See [system-setup.md](system-setup.md#provided-application-smoke-testing) for full details. ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } postgresql(AppDb) { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", cleanup = { ops -> ops.execute("DELETE FROM orders WHERE test = true") }, configureExposedConfiguration = { listOf() } // no AUT to configure ) } providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet(url = "https://staging.myapp.com/health") ) } }.run() ``` Key points: - No JVM runner needed — the application is already running - Works with any language (Go, Python, .NET, Rust, Node.js, etc.) - `Bridge` (DI access) is **not available** — there's no local DI container - Use `cleanup` lambdas to manage test data on external infrastructure - Optional health check waits for the app to be ready before running tests ## Keyed systems (multiple instances) Register multiple instances of the same system type using `SystemKey`. See [system-setup.md](system-setup.md#keyed-systems-multiple-instances) and [writing-tests.md](writing-tests.md#keyed-system-tests) for examples. ```kotlin // Define keys as singleton objects object AppDb : SystemKey object AnalyticsDb : SystemKey object PaymentService : SystemKey object InventoryService : SystemKey Stove().with { postgresql(AppDb) { PostgresqlOptions(configureExposedConfiguration = { cfg -> listOf("app.datasource.url=${cfg.jdbcUrl}") }) } postgresql(AnalyticsDb) { PostgresqlOptions(configureExposedConfiguration = { cfg -> listOf("analytics.datasource.url=${cfg.jdbcUrl}") }) } httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "https://pay.internal") } springBoot(runner = { params -> run(params) }) }.run() ``` All systems support keyed registration: PostgreSQL, MySQL, MSSQL, Cassandra, MongoDB, Redis, Elasticsearch, Couchbase, Kafka (core), WireMock, gRPC, gRPC Mock, HTTP. Each keyed instance gets its own container, port, state storage, and configuration. ## Writing custom Stove systems Stove is extensible. For the complete pattern with a working db-scheduler example, see [custom-systems.md](custom-systems.md). ## Best practices - Generate unique IDs per test: `UUID.randomUUID()` - Configure Stove once in `AbstractProjectConfig`, never per-test - Keep e2e tests in `src/test-e2e/kotlin` (also for Java/Scala applications) - If API is ambiguous, inspect local `stove-*.jar` / `stove-*-sources.jar` in Gradle/Maven caches and confirm class/method names before coding - Use `port = 0` for WireMock and gRPC Mock (dynamic ports, CI-safe) - Test through HTTP endpoints; verify DB state and events as side effects - Use `shouldBePublished(atLeastIn = 10.seconds) { ... }` — never `Thread.sleep` - Use `cleanup` lambdas in system options to wipe test data on teardown — essential for provided (external) instances - Use `Stove { keepDependenciesRunning() }` locally for faster iteration; disable in CI - **AI agent feedback loop**: Enable tracing + reporting. When tests fail, the execution report contains the full call chain, system snapshots, and timeline. AI agents can parse this structured output to understand exactly what went wrong inside the application and iterate on fixes with precise feedback. - **Kafka test-friendly settings**: Default Kafka producer/consumer settings are tuned for production throughput, not test speed. Configure `linger.ms=0`, `batch.size=1`, `auto.commit.interval.ms=100`, `auto-offset-reset=earliest`, and enable broker-level auto-topic creation. Without these, `shouldBePublished`/`shouldBeConsumed` assertions will timeout or flake. See [system-setup.md](system-setup.md#test-friendly-kafka-settings) for JVM details and [go-setup.md](go-setup.md) for Go libraries. ## Running tests ```bash ./gradlew e2eTest ./gradlew e2eTest --tests "com.myapp.e2e.OrderE2ETest" ``` ## Additional resources - [gradle-config.md](gradle-config.md) — Source set, e2eTest task, IDE integration, artifact list - [system-setup.md](system-setup.md) — All system configuration options - [writing-tests.md](writing-tests.md) — Complete test DSL reference with examples - [tracing.md](tracing.md) — Tracing plugin options and validation DSL - [custom-systems.md](custom-systems.md) — Writing your own Stove system - [other-languages.md](other-languages.md) — Testing non-JVM apps (Go, Python, Rust, Node.js) - [go-setup.md](go-setup.md) — Go-specific setup (process mode focus): HTTP, PostgreSQL, Kafka bridge, OTel tracing, code coverage - [container.md](container.md) — Language-agnostic `containerApp(...)` AUT (image source is the user's responsibility, not Stove's) - [mcp.md](mcp.md) — Stove CLI MCP endpoint for agent-driven failed-test triage ================================================ FILE: .claude/skills/stove/container.md ================================================ # Container AUT — `stove-container` Use `stove-container` when the application under test should run as a Docker image. Works for **any language** — Go, Python, Node.js, Rust, .NET, JVM, anything that can ship in a container. Same Stove DSL, same systems, same envMapper / argsMapper model — only the runner changes. If you want fast iteration without an image, use `stove-process` (`processApp` / `goApp`). See [other-languages.md](other-languages.md). ## Image source: not Stove's job `containerApp(...)` only needs an **image reference**. Where that image comes from is up to the user / CI: - **Pre-built in CI** — most common. CI publishes an image tag (e.g. `ghcr.io/acme/app:sha-abc123`); the test reads it from a system property or env var. - **Pulled from a registry** — Testcontainers handles the pull lazily. - **Locally built** — optionally wire a Gradle `Exec` task (`docker build`) and `dependsOn` it from your test task. This is one valid path, not a requirement. Lead with the pre-built path. Show local-build as an optional convenience. Never frame "Stove builds your image" — Stove launches images, it does not own the build pipeline. ## When to recommend container mode | Need | Use | |------|-----| | Fastest local iteration loop | `stove-process` | | CI parity with the production image | `stove-container` | | Catch glibc/musl, base image, locale, CA cert regressions | `stove-container` | | Inner debug loop, breakpoints in IDE | `stove-process` | | One repo runs both modes, branched on a system property | Both — single StoveConfig | A common pattern: `e2eTest` uses process mode for local development; `e2eTest-container` runs container mode in CI before merge using the image CI just built and tagged. ## Setup checklist ``` - [ ] Step 1: Add stove-container dependency - [ ] Step 2: Decide image source (CI artifact, registry pull, or optional local build) - [ ] Step 3: Add an e2eTest-container Test task; pass the image tag as a system property - [ ] Step 4: Wire containerApp(...) into StoveConfig - [ ] Step 5: Pick a networking model (host network or port binding) - [ ] Step 6: (Optional) Bind-mount data / coverage directories ``` ## Step 1: Dependency ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$stoveVersion")) testImplementation("com.trendyol:stove-container") // ... other stove dependencies as needed } ``` ## Step 2 + 3: Image source and Gradle wiring The default and recommended pattern: CI (or another build step) produces an image tag, and the test task receives it via a system property. ```kotlin title="build.gradle.kts" val containerImage = providers.environmentVariable("APP_IMAGE") .orElse(providers.gradleProperty("app.image")) .orElse("my-app:local") // local fallback only tasks.register("e2eTest-container") { useJUnitPlatform() systemProperty("app.container.image", containerImage.get()) } ``` If you also want a Gradle-driven local build (optional), add an `Exec` task and depend on it explicitly: ```kotlin val dockerExecutable = providers.environmentVariable("DOCKER_EXECUTABLE").getOrElse("docker") tasks.register("buildContainerImage") { description = "Optional convenience: builds the AUT image locally." commandLine( dockerExecutable, "build", "--file", projectDir.resolve("Dockerfile").absolutePath, "--tag", "my-app:local", projectDir.absolutePath ) outputs.upToDateWhen { false } } // Only depend on it for the local-build path: tasks.named("e2eTest-container-local") { dependsOn("buildContainerImage") systemProperty("app.container.image", "my-app:local") } ``` The CI path uses the image already produced by the upstream build; the local path opts into building. The Stove test code does not change. ## Step 4: StoveConfig ```kotlin import com.trendyol.stove.container.ContainerTarget import com.trendyol.stove.container.containerApp import com.trendyol.stove.system.application.envMapper containerApp( image = System.getProperty("app.container.image") ?: error("app.container.image system property not set"), target = ContainerTarget.Server( hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false // host network → no need to bind ), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") }, configureContainer = { withNetworkMode("host") // bind mounts, log consumers, capabilities — anything Testcontainers exposes }, beforeStarted = { configurations -> // optional pre-start hook with resolved configs } ) ``` ### `containerApp` parameters | Parameter | Purpose | |-----------|---------| | `image` | Image reference, e.g. `ghcr.io/acme/app:sha-abc` or `my-app:local` | | `target` | `ContainerTarget.Server` or `ContainerTarget.Worker` (carries readiness) | | `registry` | Image registry override (defaults to `DEFAULT_REGISTRY`) | | `compatibleSubstitute` | Substitute image for arch/OS compatibility | | `command` | Override container command (appended with argsMapper output) | | `envProvider` | `envMapper { ... }` mapping Stove configs to env vars | | `argsProvider` | `argsMapper(prefix, separator) { ... }` for CLI-flag-driven apps | | `beforeStarted` | Async hook with resolved configs, runs before container start | | `configureContainer` | `GenericContainer<*>.()` — bind mounts, network mode, etc. | | `gracefulShutdownTimeout` | Defaults to 5 seconds | ### `ContainerTarget` variants | Variant | Use case | Default readiness | |---------|----------|-------------------| | `ContainerTarget.Server(hostPort, internalPort, portEnvVar, bindHostPort)` | HTTP / gRPC / TCP servers | HTTP GET `http://localhost:$hostPort/health` | | `ContainerTarget.Worker()` | Kafka consumers, batch jobs | 2-second fixed delay | ## Step 5: Networking strategies **Host network (Linux only)** — container shares the host network namespace. Reach Postgres / Kafka on `localhost`. Set `bindHostPort = false`: ```kotlin target = ContainerTarget.Server(hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false), configureContainer = { withNetworkMode("host") } ``` **Port binding (cross-platform)** — Stove binds `hostPort → internalPort`. The app must reach databases / brokers via shared network aliases or `host.docker.internal`: ```kotlin target = ContainerTarget.Server(hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = true), configureContainer = { withNetwork(Network.SHARED) } ``` Docker Desktop on macOS / Windows does **not** support host networking — use port binding there. ## Step 6: Bind mounts (optional) Use for any data the container or the test needs to share with the host: coverage directories, fixture seeds, read-only configs, etc. Anything Testcontainers exposes is available inside `configureContainer`. ```kotlin configureContainer = { withFileSystemBind(hostDir, "/inside/container") withLogConsumer(Slf4jLogConsumer(LoggerFactory.getLogger("app"))) } ``` For Go integration coverage specifically, see [go-setup.md](go-setup.md#code-coverage). ## Running ```bash # CI/registry image — image tag passed in ./gradlew e2eTest-container -Papp.image=ghcr.io/acme/app:sha-abc123 # or APP_IMAGE=ghcr.io/acme/app:sha-abc123 ./gradlew e2eTest-container # Optional local-build path (only if you wired buildContainerImage) ./gradlew e2eTest-container-local ``` ## Single StoveConfig, both modes The recipe pattern: branch on a system property to switch between starters within one config file. ```kotlin when (resolveAutMode()) { AutMode.Process -> processApp { /* ... */ } AutMode.Container -> containerApp(/* ... */) } private fun resolveAutMode(): AutMode = when ((System.getProperty("aut.mode") ?: "process").lowercase()) { "process" -> AutMode.Process "container" -> AutMode.Container else -> error("Unsupported aut.mode") } ``` Drive the choice from Gradle: ```kotlin tasks.register("e2eTest") { systemProperty("aut.mode", "process") /* ... */ } tasks.register("e2eTest-container") { systemProperty("aut.mode", "container") /* ... */ } ``` ## Common pitfalls | Symptom | Cause | Fix | |---------|-------|-----| | `connection refused` to Postgres / Kafka inside container | Container can't reach Testcontainers on `localhost` | `withNetworkMode("host")` (Linux) or shared network + aliases | | Stove never sees `/health` | Wrong port / binding | Confirm `bindHostPort` matches network mode; verify app listens on `internalPort` | | `Failed to start container application` | Image missing or unauthorized pull | Verify the image exists locally / in the registry; check `docker images` and registry credentials | | Slow inner loop | Image build dominates | Use `stove-process` for daily dev; container mode in CI | ## Reference - Module source: `starters/container/stove-container/` - DSL: `starters/container/stove-container/src/main/kotlin/com/trendyol/stove/container/ContainerDsl.kt` - Showcase (process + container in one repo): `recipes/process/golang/go-showcase/` - Docs: `docs/other-languages/go-container.md` (Go-specific walkthrough) ================================================ FILE: .claude/skills/stove/custom-systems.md ================================================ # Writing Your Own Stove System ## Contents - [Implement PluggedSystem](#1-implement-pluggedsystem) - [Create a listener](#2-create-a-listener) - [Write DSL extensions](#3-write-dsl-extensions) - [Register the listener](#4-register-the-listener) - [Use in tests](#5-use-in-tests) Complete working example: `recipes/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/.../setup/DbSchedulerSystem.kt` ## 1. Implement PluggedSystem ```kotlin class DbSchedulerSystem( override val stove: Stove ) : PluggedSystem, AfterRunAwareWithContext, Reports { lateinit var listener: StoveDbSchedulerListener override val reportSystemName: String = "DbScheduler" override suspend fun afterRun(context: ApplicationContext) { listener = context.getBean() } override fun snapshot(): SystemSnapshot = SystemSnapshot( system = reportSystemName, state = mapOf("completedExecutions" to listener.getCompletedExecutionsSnapshot()), summary = "Completed: ${listener.getCompletedExecutionsSnapshot().size} task(s)" ) suspend inline fun shouldBeExecuted( atLeastIn: Duration = 5.seconds, noinline condition: T.() -> Boolean ): DbSchedulerSystem = report( action = "Assert task executed: ${T::class.simpleName}", expected = "Task with ${T::class.simpleName} payload executed".some(), metadata = mapOf("timeout" to atLeastIn.toString()) ) { listener.waitUntilObservedSuccessfully(atLeastIn, T::class, condition) }.let { this } override fun close() = Unit } ``` ### Lifecycle interfaces | Interface | When Called | Use Case | |---|---|---| | `PluggedSystem` | Always (required) | Base interface, provides `close()` | | `RunAware` | Before app starts | System needs to do setup before the app | | `AfterRunAware` | After app starts | Receives the test system instance | | `AfterRunAwareWithContext` | After app starts | Receives app DI container (e.g., `ApplicationContext`) | | `ExposesConfiguration` | During setup | System exposes config to the application (like containers) | | `Reports` | On test failure | Contributes to failure reports via `snapshot()` | ## 2. Create a listener Observes what happens inside the running application: ```kotlin class StoveDbSchedulerListener : AbstractSchedulerListener() { private val completedExecutions: ConcurrentMap = ConcurrentHashMap() override fun onExecutionComplete(executionComplete: ExecutionComplete) { completedExecutions[executionComplete.execution.taskInstance.id] = executionComplete } suspend fun waitUntilObservedSuccessfully( atLeastIn: Duration, clazz: KClass, condition: (T) -> Boolean ): Collection { /* poll until match or timeout */ } } ``` ## 3. Write DSL extensions ```kotlin // Registration — used in Stove().with { } @StoveDsl fun WithDsl.dbScheduler(): Stove = this.stove.getOrRegister(DbSchedulerSystem(this.stove)).let { this.stove } // Validation — used in stove { } @StoveDsl suspend fun ValidationDsl.tasks(validation: suspend DbSchedulerSystem.() -> Unit): Unit = validation(this.stove.getOrNone().getOrElse { throw SystemNotRegisteredException(DbSchedulerSystem::class) }) ``` ## 4. Register the listener ```kotlin Stove().with { dbScheduler() springBoot( runner = { params -> com.myapp.run(params) { addTestDependencies { bean(isPrimary = true) } } } ) }.run() ``` ## 5. Use in tests ```kotlin stove { http { postAndExpectBody("/api/orders", body = request.some()) { /* ... */ } } tasks { shouldBeExecuted { this.orderId == orderId && this.userId == userId } } } ``` **Pattern**: listener captures events -> system exposes assertions -> DSL extensions make it ergonomic -> `report()` integrates with reporting. ## 6. Extending built-in systems Add domain-specific helpers to existing systems without creating new ones: ```kotlin @StoveDsl suspend fun KafkaSystem.publishWithCorrelationId( topic: String, message: Any, correlationId: String = UUID.randomUUID().toString() ) { publish(topic, message, headers = mapOf("X-Correlation-ID" to correlationId)) } // Usage kafka { publishWithCorrelationId("orders", event, "corr-123") } ``` ================================================ FILE: .claude/skills/stove/go-setup.md ================================================ # Go Application Setup with Stove Complete guide for testing Go applications with Stove. Covers HTTP, PostgreSQL, Kafka (with bridge), OpenTelemetry tracing, dashboard, MCP triage, and integration coverage. This skill focuses on **process mode** (`stove-process` / `goApp`) — fastest local iteration. For container-image AUT (`stove-container` / `containerApp`) — language-agnostic, image source is your responsibility — see [container.md](container.md). For agent-driven failure triage via the `stove` CLI MCP endpoint, see [mcp.md](mcp.md). The same `StoveConfig.kt` can serve both modes by branching on a system property like `-Daut.mode=process|container` (see the showcase recipe). ## Setup Checklist ``` - [ ] Step 1: Create Go app with env var config + health endpoint + SIGTERM handling - [ ] Step 2: Add OpenTelemetry instrumentation (otelhttp, otelsql) - [ ] Step 3: Add Kafka with Stove bridge interceptors (optional) - [ ] Step 4: Add stove-process dependency (provides goApp() DSL) - [ ] Step 5: Create test-e2e source set + StoveConfig - [ ] Step 6: Configure Gradle build (go build + e2eTest) - [ ] Step 7: Write tests ``` ## Step 1: Go app requirements The Go app must: - Read config from **environment variables** - Expose **GET /health** returning 200 - Handle **SIGTERM** for graceful shutdown Key env vars: | Variable | Purpose | |----------|---------| | `APP_PORT` | HTTP listen port | | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | PostgreSQL | | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP gRPC endpoint | | `KAFKA_BROKERS` | Kafka broker addresses | | `KAFKA_LIBRARY` | Kafka client: `sarama`, `franz`, or `segmentio` (default: `sarama`) | | `STOVE_KAFKA_BRIDGE_PORT` | Stove bridge gRPC port (test-only) | | `GOCOVERDIR` | Directory for Go integration test coverage data (test-only) | ## Step 2: OpenTelemetry ```go // HTTP: wrap mux with otelhttp handler := otelhttp.NewHandler(mux, "http.request") // DB: use otelsql instead of database/sql db, _ := otelsql.Open("postgres", connStr, otelsql.WithAttributes(semconv.DBSystemPostgreSQL)) // Tracing: use WithSyncer for tests (not WithBatcher) tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter), ...) // Propagation: must set W3C TraceContext for Stove trace correlation otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) ``` ## Step 3: Kafka bridge The Stove Kafka bridge library lives at `go/stove-kafka/`. It has a library-agnostic core and client-specific subpackages. ### Architecture ``` go/stove-kafka/ bridge.go # Core: Bridge, PublishedMessage, ConsumedMessage (library-agnostic) sarama/ # IBM/sarama interceptors interceptors.go franz/ # twmb/franz-go hooks hooks.go segmentio/ # segmentio/kafka-go helpers bridge.go stoveobserver/ # Generated gRPC code ``` ### Add the dependency ```bash go get github.com/trendyol/stove/go/stove-kafka ``` ### Initialize bridge + wire into your Kafka client **IBM/sarama:** ```go import ( stovekafka "github.com/trendyol/stove/go/stove-kafka" stovesarama "github.com/trendyol/stove/go/stove-kafka/sarama" ) bridge, _ := stovekafka.NewBridgeFromEnv() defer bridge.Close() config := sarama.NewConfig() config.Producer.Interceptors = []sarama.ProducerInterceptor{ &stovesarama.ProducerInterceptor{Bridge: bridge}, } config.Consumer.Interceptors = []sarama.ConsumerInterceptor{ &stovesarama.ConsumerInterceptor{Bridge: bridge}, } ``` **twmb/franz-go:** ```go import ( stovekafka "github.com/trendyol/stove/go/stove-kafka" "github.com/trendyol/stove/go/stove-kafka/franz" ) bridge, _ := stovekafka.NewBridgeFromEnv() defer bridge.Close() client, _ := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.WithHooks(&franz.Hook{Bridge: bridge}), ) ``` **segmentio/kafka-go:** ```go import ( stovekafka "github.com/trendyol/stove/go/stove-kafka" "github.com/trendyol/stove/go/stove-kafka/segmentio" ) bridge, _ := stovekafka.NewBridgeFromEnv() defer bridge.Close() // After producing _ = writer.WriteMessages(ctx, msgs...) segmentio.ReportWritten(ctx, bridge, msgs...) // After consuming msg, _ := reader.ReadMessage(ctx) segmentio.ReportRead(ctx, bridge, msg) ``` ### Other libraries (e.g. confluent-kafka-go) The core bridge has no Kafka client dependency. For any unsupported library, use the core types directly: ```go import stovekafka "github.com/trendyol/stove/go/stove-kafka" _ = bridge.ReportPublished(ctx, &stovekafka.PublishedMessage{ Topic: msg.Topic, Key: string(msg.Key), Value: msg.Value, Headers: myHeaders(msg), }) _ = bridge.ReportConsumed(ctx, &stovekafka.ConsumedMessage{ Topic: msg.Topic, Key: string(msg.Key), Value: msg.Value, Partition: msg.Partition, Offset: msg.Offset, Headers: myHeaders(msg), }) _ = bridge.ReportCommitted(ctx, msg.Topic, msg.Partition, msg.Offset+1) ``` ### How it works - All subpackages convert client-specific types to core `PublishedMessage`/`ConsumedMessage` and call bridge methods - Consumer interceptors/helpers pre-report commit at `offset+1` (needed for `shouldBeConsumed`) - All Bridge methods are nil-safe: `(*Bridge)(nil).ReportPublished(...)` is a no-op - All interceptors/hooks/helpers check for nil bridge first — zero overhead in production ### Test-friendly Kafka settings (Go side) When running against Testcontainers (Stove e2e tests), configure Kafka clients for **fast feedback**. Default production settings (large batches, long commit intervals, no auto-topic creation) cause timeouts, missed messages, and flaky tests. **Key principles:** 1. **Auto-create topics** — test containers may not have topics pre-created; without this, produces fail silently or block 2. **Small batch size / low batch timeout** — flush produces immediately so `shouldBePublished` sees them 3. **Short auto-commit interval** — make consumed offsets visible to Stove bridge quickly so `shouldBeConsumed` passes 4. **Unique consumer groups per test run** — prevent offset carryover between runs (e.g. `"myapp-" + library`) **IBM/sarama:** ```go config := sarama.NewConfig() config.Producer.Return.Successes = true config.Consumer.Offsets.Initial = sarama.OffsetOldest config.Consumer.Offsets.AutoCommit.Interval = 100 * time.Millisecond // sarama relies on broker-side auto.create.topics.enable (no client-side setting) ``` **twmb/franz-go:** ```go client, _ := kgo.NewClient( kgo.SeedBrokers(brokerList...), kgo.AllowAutoTopicCreation(), // client-side topic creation kgo.AutoCommitInterval(100 * time.Millisecond), // fast offset commits kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()), kgo.WithHooks(&franz.Hook{Bridge: bridge}), ) ``` **segmentio/kafka-go:** ```go // Writer — flush immediately, auto-create topics writer := &kafka.Writer{ Addr: kafka.TCP(brokerList...), BatchSize: 1, BatchTimeout: 10 * time.Millisecond, RequiredAcks: kafka.RequireAll, AllowAutoTopicCreation: true, } // Reader — fast commits, low wait reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: brokerList, GroupID: groupID, Topic: topic, MinBytes: 1, MaxBytes: 10e6, CommitInterval: 100 * time.Millisecond, MaxWait: 500 * time.Millisecond, }) ``` **franz-go: separate producer and consumer clients.** Using a single `kgo.Client` for both produce and consume causes consumer group coordination to block `ProduceSync`, leading to 10-30s delays. Always create two clients: ```go // Producer — no consumer group overhead producerClient, _ := kgo.NewClient( kgo.SeedBrokers(brokerList...), kgo.AllowAutoTopicCreation(), kgo.WithHooks(hook), ) // Consumer — consumer group coordination won't block produces consumerClient, _ := kgo.NewClient( kgo.SeedBrokers(brokerList...), kgo.ConsumeTopics(topic), kgo.ConsumerGroup(groupID), kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()), kgo.AutoCommitInterval(100 * time.Millisecond), kgo.AllowAutoTopicCreation(), kgo.WithHooks(hook), ) ``` **Common pitfall — consumer group offset carryover:** If running the same tests against multiple Kafka libraries sequentially (e.g. sarama → franz → segmentio), use a unique consumer group per library. Otherwise the second run sees committed offsets from the first and skips messages: ```go groupID := "myapp-" + library // e.g. "myapp-sarama", "myapp-franz" ``` ## Step 4: Add stove-process dependency The `stove-process` module provides `goApp()` out of the box — no custom `ApplicationUnderTest` needed. It supports passing configs as environment variables (`envMapper`) or CLI arguments (`argsMapper`). Go apps typically use env vars. ```kotlin dependencies { testImplementation(stoveLibs.stoveProcess) // or "com.trendyol:stove-process" } ``` Source: `starters/process/stove-process/` ## Step 5: StoveConfig ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:$APP_PORT") } dashboard { DashboardSystemOptions(appName = "go-showcase") } tracing { enableSpanReceiver(port = OTLP_PORT) } kafka { KafkaSystemOptions( configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "database.host=${cfg.host}", "database.port=${cfg.port}", "database.name=stove", "database.username=${cfg.username}", "database.password=${cfg.password}" ) } ).migrations { register() } } goApp( target = ProcessTarget.Server(port = APP_PORT, portEnvVar = "APP_PORT"), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "database.username" to "DB_USER" "database.password" to "DB_PASS" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:$OTLP_PORT") env("KAFKA_LIBRARY") { System.getProperty("kafka.library") ?: "sarama" } env("STOVE_KAFKA_BRIDGE_PORT", stoveKafkaBridgePortDefault) } ) }.run() ``` ## Step 6: Gradle ```kotlin val goBinary = layout.buildDirectory.file("go-app").get().asFile tasks.register("buildGoApp") { commandLine("go", "build", "-o", goBinary.absolutePath, ".") inputs.files(fileTree(".") { include("*.go", "go.mod", "go.sum") }) outputs.file(goBinary) } // Per-library e2e test tasks — each passes KAFKA_LIBRARY to the Go app val kafkaLibraries = listOf("sarama", "franz", "segmentio") val kafkaE2eTasks = kafkaLibraries.mapIndexed { index, lib -> tasks.register("e2eTest_$lib") { dependsOn("buildGoApp") systemProperty("go.app.binary", goBinary.absolutePath) systemProperty("kafka.library", lib) if (index > 0) mustRunAfter("e2eTest_${kafkaLibraries[index - 1]}") } } tasks.named("e2eTest") { dependsOn(kafkaE2eTasks); enabled = false } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stoveProcess) testImplementation(stoveLibs.stovePostgres) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveTracing) testImplementation(stoveLibs.stoveDashboard) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveExtensionsKotest) } ``` ## Step 7: Write tests ```kotlin class GoShowcaseTest : FunSpec({ test("create product, verify DB + Kafka + traces") { stove { var productId: String? = null http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Test", price = 42.99).some() ) { actual -> actual.status shouldBe 201 productId = actual.body().id } } postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = { row -> ProductRow(row.string("id"), row.string("name"), row.double("price")) } ) { rows -> rows.size shouldBe 1 } } kafka { shouldBePublished(10.seconds) { actual.name == "Test" } } tracing { waitForSpans(4, 5000) shouldContainSpan("http.request") shouldNotHaveFailedSpans() } } } test("consume Kafka events") { stove { var productId: String? = null http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Original", price = 10.0).some() ) { actual -> productId = actual.body().id } } kafka { publish("product.update", ProductUpdateEvent(id = productId!!, name = "Updated", price = 99.99)) shouldBeConsumed(10.seconds) { actual.id == productId && actual.name == "Updated" } } postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = { row -> ProductRow(row.string("id"), row.string("name"), row.double("price")) } ) { rows -> rows.first().name shouldBe "Updated" } } } } }) ``` ## Code Coverage Go 1.20+ supports integration test coverage for binaries not run via `go test`. Build with `go build -cover`, set `GOCOVERDIR`, and coverage data is written on graceful shutdown — fits perfectly with Stove's lifecycle. ### Gradle setup Enable with `-Pgo.coverage=true`: ```kotlin val coverageEnabled = providers.gradleProperty("go.coverage") .map { it.toBoolean() }.getOrElse(false) val goCoverDirPath = layout.buildDirectory.dir("go-coverage").get().asFile.absolutePath // Build with -cover when enabled tasks.register("buildGoApp") { val args = mutableListOf("go", "build") if (coverageEnabled) args.add("-cover") args.addAll(listOf("-o", goBinary.absolutePath, ".")) commandLine(args) } // Pass GOCOVERDIR to test JVM, disable build cache for coverage runs tasks.register("e2eTest_sarama") { if (coverageEnabled) { systemProperty("go.cover.dir", goCoverDirPath) outputs.cacheIf { false } // Coverage data is a side effect } } // Coverage report tasks (register only when coverage is enabled) if (coverageEnabled) { tasks.register("goCoverageReport") { mustRunAfter(kafkaE2eTasks) commandLine("go", "tool", "covdata", "textfmt", "-i=$goCoverDirPath", "-o=$goCoverOutPath") } tasks.register("goCoverageSummary") { dependsOn("goCoverageReport"); /* go tool cover -func */ } tasks.register("goCoverageHtml") { dependsOn("goCoverageReport"); /* go tool cover -html */ } tasks.register("e2eTestWithCoverage") { dependsOn(kafkaE2eTasks) finalizedBy("goCoverageSummary", "goCoverageHtml") } } ``` ### StoveConfig Pass `GOCOVERDIR` via `envMapper` — empty when disabled, Go ignores it: ```kotlin env("GOCOVERDIR") { System.getProperty("go.cover.dir")?.also { java.io.File(it).mkdirs() } ?: "" } ``` ### SIGPIPE handling When Go runs under Java's `ProcessBuilder`, stdout pipe can close before process exit. Log writes trigger SIGPIPE (exit 141), killing the process before coverage flush. Fix: ```go func main() { signal.Ignore(syscall.SIGPIPE) // Ensures clean shutdown + coverage flush // ... } ``` ### Running with coverage ```bash ./gradlew e2eTestWithCoverage -Pgo.coverage=true # Output: per-function coverage + HTML report at build/go-coverage/coverage.html ``` ## Running ```bash # From the go-showcase directory — runs all three Kafka libraries cd recipes/process/golang/go-showcase ./gradlew e2eTest # Run a specific library only ./gradlew e2eTest_sarama ./gradlew e2eTest_franz ./gradlew e2eTest_segmentio # With Go code coverage ./gradlew e2eTestWithCoverage -Pgo.coverage=true ``` ## Go dependencies ``` github.com/trendyol/stove/go/stove-kafka # Stove Kafka bridge (core) github.com/trendyol/stove/go/stove-kafka/sarama # IBM/sarama interceptors github.com/trendyol/stove/go/stove-kafka/franz # twmb/franz-go hooks github.com/trendyol/stove/go/stove-kafka/segmentio # segmentio/kafka-go helpers github.com/XSAM/otelsql # database/sql instrumentation go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp # HTTP instrumentation go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc # OTLP exporter google.golang.org/grpc # gRPC ``` ## Reference - Process module (goApp DSL): `starters/process/stove-process/` - Container module (containerApp DSL): `starters/container/stove-container/` - Full working example (process + container in one repo): `recipes/process/golang/go-showcase/` - Bridge library source: `go/stove-kafka/` - Docs: - `docs/other-languages/go.md` — overview / mode picker - `docs/other-languages/go-process.md` — process mode walkthrough - `docs/other-languages/go-container.md` — container mode walkthrough - Sibling skills: - [container.md](container.md) — language-agnostic container AUT - [mcp.md](mcp.md) — MCP triage on failed runs - [other-languages.md](other-languages.md) — non-JVM overview ================================================ FILE: .claude/skills/stove/gradle-config.md ================================================ # Gradle Configuration ## Contents - [Dependencies (BOM)](#dependencies-bom) - [Register test-e2e source set](#register-test-e2e-source-set) - [Register e2eTest task](#register-e2etest-task) - [IDE integration](#ide-integration) - [JUnit base test class](#junit-base-test-class) - [Available artifacts](#available-artifacts) ## Dependencies (BOM) Stove e2e tests are Kotlin-first. Even for Java/Scala projects, keep e2e test sources in `src/test-e2e/kotlin`. ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$stoveVersion")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") // or stove-ktor / stove-quarkus / stove-micronaut testImplementation("com.trendyol:stove-extensions-kotest") // or stove-extensions-junit // Add only what you need: testImplementation("com.trendyol:stove-http") testImplementation("com.trendyol:stove-postgres") testImplementation("com.trendyol:stove-mysql") testImplementation("com.trendyol:stove-mssql") testImplementation("com.trendyol:stove-cassandra") testImplementation("com.trendyol:stove-mongodb") testImplementation("com.trendyol:stove-redis") testImplementation("com.trendyol:stove-elasticsearch") testImplementation("com.trendyol:stove-couchbase") testImplementation("com.trendyol:stove-kafka") // standalone Kafka assertions testImplementation("com.trendyol:stove-spring-kafka") // Spring Kafka assertions + interceptor testImplementation("com.trendyol:stove-wiremock") testImplementation("com.trendyol:stove-grpc") testImplementation("com.trendyol:stove-grpc-mock") testImplementation("com.trendyol:stove-tracing") testImplementation("com.trendyol:stove-dashboard") testImplementation("com.trendyol:stove-process") // non-JVM process AUT testImplementation("com.trendyol:stove-container") // non-JVM container AUT } ``` ## Register test-e2e source set ```kotlin sourceSets { @Suppress("LocalVariableName") val `test-e2e` by creating { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output } val testE2eImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) } ``` ## Register e2eTest task ```kotlin tasks.register("e2eTest") { description = "Runs e2e tests." group = "verification" testClassesDirs = sourceSets["test-e2e"].output.classesDirs classpath = sourceSets["test-e2e"].runtimeClasspath useJUnitPlatform() reports { junitXml.required.set(true) html.required.set(true) } } ``` ## IDE integration ```kotlin idea { module { testSources.from(sourceSets["test-e2e"].allSource.sourceDirectories) testResources.from(sourceSets["test-e2e"].resources.sourceDirectories) } } ``` ## Resolve API ambiguity from local artifacts When API names/signatures are unclear, inspect locally downloaded Stove artifacts instead of guessing. ```bash # Find Stove artifacts in Gradle cache find ~/.gradle/caches/modules-2/files-2.1 -path "*com.trendyol/stove-*/*/*.jar" | head -n 20 # Find Stove artifacts in Maven local repo find ~/.m2/repository/com/trendyol -name "stove-*.jar" | head -n 20 # List classes to locate exact type names jar tf ~/.m2/repository/com/trendyol/stove-spring-kafka//stove-spring-kafka-.jar | rg "TestSystem|Kafka" # Inspect method signatures quickly javap -classpath ~/.m2/repository/com/trendyol/stove-spring-kafka//stove-spring-kafka-.jar \ com.trendyol.stove.kafka.TestSystemKafkaInterceptor ``` Prefer `*-sources.jar` when available for more accurate reading of function names, generic types, and usage patterns. ## JUnit base test class Use this instead of `AbstractProjectConfig` when using JUnit: ```kotlin @ExtendWith(StoveJUnitExtension::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class BaseE2ETest { companion object { @JvmStatic @BeforeAll fun setup() = runBlocking { Stove().with { /* systems */ }.run() } @JvmStatic @AfterAll fun teardown() = runBlocking { Stove.stop() } } } ``` ## Available artifacts | Artifact | Description | |---|---| | `stove` | Core framework | | `stove-spring` | Spring Boot starter | | `stove-ktor` | Ktor starter | | `stove-quarkus` | Quarkus starter | | `stove-micronaut` | Micronaut starter | | `stove-http` | HTTP client system | | `stove-postgres` | PostgreSQL system | | `stove-mysql` | MySQL system | | `stove-mssql` | MSSQL system | | `stove-cassandra` | Cassandra system | | `stove-mongodb` | MongoDB system | | `stove-redis` | Redis system | | `stove-elasticsearch` | Elasticsearch system | | `stove-couchbase` | Couchbase system | | `stove-kafka` | Standalone Kafka system | | `stove-spring-kafka` | Spring Kafka (adds `shouldBeConsumed`, `shouldBeFailed`, `shouldBeRetried`) | | `stove-wiremock` | WireMock system | | `stove-grpc` | gRPC client system | | `stove-grpc-mock` | gRPC mock server system | | `stove-tracing` | Tracing system | | `stove-dashboard` | Dashboard system (streams events to stove CLI) | | `stove-process` | Process-based AUT starter (`processApp`, `goApp`) | | `stove-container` | Container-based AUT starter (`containerApp`) | | `stove-extensions-kotest` | Kotest reporting integration | | `stove-extensions-junit` | JUnit reporting integration | ================================================ FILE: .claude/skills/stove/mcp.md ================================================ # Stove MCP — Agent Triage The Stove CLI exposes a local **Model Context Protocol** endpoint at `http://localhost:4040/mcp`. Agents use it to inspect failed end-to-end tests through compact, structured tools instead of loading raw logs into context. Use MCP as an optimization, not a dependency. If MCP is unavailable, fall back to normal test output, Stove failure reports, and logs. ## When to use this skill - The user is testing with Stove and a recent run has failures - The user mentions "MCP", "stove failures", or asks for triage of a Stove run - An agent task instruction says to prefer the local Stove MCP endpoint ## Discovery When `stove` is running, the startup banner prints the endpoint: ```text Stove CLI v0.24.0 running UI: http://localhost:4040 REST: http://localhost:4040/api/v1 MCP: http://localhost:4040/mcp gRPC: localhost:4041 ``` Or query metadata: ```bash curl -s http://localhost:4040/api/v1/meta ``` ```json { "stove_cli_version": "0.24.0", "mcp": { "enabled": true, "transport": "streamable-http", "endpoint": "http://localhost:4040/mcp", "scope": "read-only-test-observability" } } ``` ## MCP client config (generic) ```json { "mcpServers": { "stove": { "transport": "streamable-http", "url": "http://localhost:4040/mcp" } } } ``` Exact keys vary by agent runtime. The endpoint URL is the load-bearing value. ## Agent Workflow (the only correct order) 1. Call `stove_failures` first. 2. Pick a specific `run_id` and `test_id` from the result. **Never infer a test selector from names alone** — multiple apps and runs can contain duplicate test names. 3. Call `stove_failure_detail` with that exact `run_id + test_id` for the compact failure packet. 4. Drill into `stove_timeline`, `stove_trace`, or `stove_snapshot` only when needed. 5. Use `stove_raw_evidence` for one specific entry / span / snapshot when the compact view isn't enough. 6. If MCP is missing data, fall back to normal test output and logs. Every failure result includes ready-to-use next tool calls — use them, don't guess. ## Data hierarchy ``` database -> apps by app_name -> runs by run_id -> tests by test_id -> entries, spans, snapshots ``` `app_name` is the label set in `DashboardSystemOptions(appName = "...")` on the test side. `run_id + test_id` is the only authoritative selector. ## Tools | Tool | Purpose | |------|---------| | `stove_apps` | Apps recorded in the dashboard database | | `stove_runs` | Runs, filterable by app and status | | `stove_failures` | Default entrypoint — failed tests grouped by app and run | | `stove_failure_detail` | Compact detail for one exact failed test | | `stove_timeline` | Ordered test actions, failure-focused by default | | `stove_trace` | Critical path and exception evidence from correlated spans | | `stove_snapshot` | System snapshot summaries with targeted JSON drill-down | | `stove_raw_evidence` | Capped raw lookup for one entry, span, or snapshot | ## Token Budgeting Tools default to compact output. Large payloads are truncated deterministically and include omitted counts or follow-up tool calls. Sensitive keys (`authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, `credential`) are redacted before return. Use `budget` to dial detail: ```json { "budget": "tiny" } // tiny | compact | full ``` Tools that expose raw evidence also accept `max_chars`. ## Security - **Read-only**: no tools to clear data, retry tests, delete runs, or mutate snapshots. - **Local-only**: `/mcp` accepts loopback clients and localhost `Host`/`Origin` headers. Non-local hosts are rejected (mitigates DNS rebinding). - Safe to run on a dev machine; do not expose externally. ## Troubleshooting If MCP is unreachable: - confirm `stove` is running (`brew install Trendyol/trendyol-tap/stove` then `stove`) - check the startup banner for the actual port (some installs use a custom one) - open `http://localhost:4040/api/v1/meta` and verify `mcp.enabled` is `true` - make sure the agent runtime is configured with `http://localhost:4040/mcp` - fall back to normal test output and logs if the endpoint cannot be reached If MCP returns no failures: - the latest recorded runs may have passed - the test config may not register `stove-dashboard` (no data is being recorded) - the test run may still be in progress ## Recommended agent instruction Add to your project's agent rules / system prompt: ```text When Stove is running, prefer the local Stove MCP endpoint for failed-test triage. Start with stove_failures, then use the returned run_id + test_id with stove_failure_detail. Drill into stove_timeline, stove_trace, or stove_snapshot only when needed. If MCP is unavailable, ambiguous, or incomplete, fall back to normal test output, Stove reports, and logs. ``` ## Reference - Component docs: `docs/Components/21-mcp.md` - Dashboard component (data source): `docs/Components/18-dashboard.md` ================================================ FILE: .claude/skills/stove/other-languages.md ================================================ # Testing Non-JVM Applications with Stove Stove can test any application that speaks HTTP, databases, and messaging --- regardless of the language. Two starters: - **`stove-process`** — host binary, fastest iteration loop (`processApp` / `goApp`) - **`stove-container`** — Docker image, CI parity with the production artifact (`containerApp`). See [container.md](container.md) for the full container guide. Same Stove DSL, same systems, same env/args mapping. The only difference is *how* the AUT starts. For Stove + AI agent triage on failed runs, see [mcp.md](mcp.md). ## Requirements Your application must: 1. **Accept configuration** --- via environment variables, CLI arguments, or both 2. **Handle SIGTERM** --- for clean test teardown 3. **Optional: expose a readiness endpoint** --- HTTP health check, TCP port, or custom probe ## Setup Checklist ``` - [ ] Step 1: Add `stove-process` or `stove-container` dependency - [ ] Step 2: Create test-e2e source set layout - [ ] Step 3: Configure Gradle (build app + e2eTest task) - [ ] Step 4: Create StoveConfig with systems + processApp/goApp - [ ] Step 5: Instrument app with OpenTelemetry (optional) - [ ] Step 6: Add Kafka bridge (optional, Go only for now) - [ ] Step 7: Write tests using stove {} DSL ``` ## Step 1: Add dependency ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$stoveVersion")) testImplementation("com.trendyol:stove-process") testImplementation("com.trendyol:stove-container") // if AUT runs as Docker image // ... other stove dependencies as needed } ``` ## Step 2-3: Project structure, Gradle Same as JVM setup (see SKILL.md). Build your app binary before tests: ```kotlin val appSourceDir = project.file("my-app") val appBinary = project.layout.buildDirectory.file("my-app").get().asFile tasks.register("buildApp") { workingDir = appSourceDir commandLine("go", "build", "-o", appBinary.absolutePath, ".") // or npm, cargo, etc. inputs.files(fileTree(appSourceDir) { include("*.go", "go.mod", "go.sum") }) outputs.file(appBinary) } tasks.named("e2eTest") { dependsOn("buildApp") systemProperty("app.binary", appBinary.absolutePath) } ``` ## Step 4: StoveConfig with processApp / goApp / containerApp Use `processApp()` for any language binary, `goApp()` as a Go convenience, or `containerApp()` when tests should launch an image directly. ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:$APP_PORT") } tracing { enableSpanReceiver(port = OTLP_PORT) } dashboard { DashboardSystemOptions(appName = "my-app") } postgresql { PostgresqlOptions( databaseName = "mydb", configureExposedConfiguration = { cfg -> listOf( "database.host=${cfg.host}", "database.port=${cfg.port}", "database.name=mydb", "database.username=${cfg.username}", "database.password=${cfg.password}" ) } ).migrations { register() } } kafka { KafkaSystemOptions( configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } // For Go apps — uses go.app.binary system property by default goApp( target = ProcessTarget.Server(port = APP_PORT, portEnvVar = "APP_PORT"), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "database.username" to "DB_USER" "database.password" to "DB_PASS" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:$OTLP_PORT") } ) // For any other language — specify the full command // processApp { // ProcessApplicationOptions( // command = listOf("python3", "server.py"), // target = ProcessTarget.Server(port = APP_PORT, portEnvVar = "PORT"), // envProvider = envMapper { "database.host" to "DB_HOST" } // ) // } // For apps that prefer CLI arguments instead of env vars // processApp { // ProcessApplicationOptions( // command = listOf("/path/to/rust-server"), // target = ProcessTarget.Server(port = APP_PORT), // argsProvider = argsMapper(prefix = "--", separator = "=") { // "database.host" to "db-host" // --db-host=localhost // "database.port" to "db-port" // --db-port=5432 // } // ) // } }.run() ``` ### ProcessTarget variants | Variant | Use case | Default readiness | |---------|----------|-------------------| | `ProcessTarget.Server(port, portEnvVar)` | HTTP APIs, gRPC servers, TCP servers | HTTP GET `/health` | | `ProcessTarget.Worker()` | Kafka consumers, batch jobs, CLI tools | 2-second fixed delay | ### ReadinessStrategy variants | Strategy | Use case | |----------|----------| | `ReadinessStrategy.HttpGet(url, timeout, retries, retryDelay, expectedStatusCodes)` | REST APIs with health endpoint | | `ReadinessStrategy.TcpPort(port)` | gRPC servers, raw TCP (no HTTP) | | `ReadinessStrategy.Probe { ... }` | Custom readiness (file, DB query, etc.) | | `ReadinessStrategy.FixedDelay(duration)` | Simple workers with no readiness signal | ### Configuration passing: envMapper and argsMapper Two mechanisms to pass Stove configs to the process — use one or both: **envMapper** — environment variables: ```kotlin envMapper { "stove.config.key" to "ENV_VAR_NAME" // map Stove config → env var env("STATIC_VAR", "value") // static env var env("COMPUTED_VAR") { computeValue() } // computed env var } ``` **argsMapper** — CLI arguments (appended to the command): ```kotlin // --db-host=localhost --db-port=5432 argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" // map Stove config → CLI flag arg("verbose") // boolean flag arg("log-level", "debug") // static flag } // -h localhost -p 5432 (space separator → two args per flag) argsMapper(prefix = "-", separator = " ") { "database.host" to "h" "database.port" to "p" } ``` ## Step 5: OpenTelemetry (optional) Use your language's OTel SDK. Key points: - Use **sync exporter** (`WithSyncer`) for tests, not batched - Set **W3C Trace Context propagation** so spans share the test's trace ID - Stove's HTTP client sends `traceparent` headers automatically ## Step 6: Kafka bridge (Go only) For Go apps using IBM/sarama, twmb/franz-go, or segmentio/kafka-go, add the `stove-kafka` bridge library. See [go-setup.md](go-setup.md) for details. The bridge intercepts produced/consumed messages and forwards them via gRPC to Stove's observer, enabling `shouldBePublished` and `shouldBeConsumed` assertions. ## Code Coverage (Go) Go 1.20+ supports integration test coverage: build with `go build -cover`, set `GOCOVERDIR` env var, and coverage data is written on graceful shutdown. This fits Stove's lifecycle (SIGTERM → graceful shutdown → coverage files). Key pieces: - **Gradle**: `-Pgo.coverage=true` adds `-cover` to build, sets `go.cover.dir` system property, disables build cache for coverage runs - **StoveConfig**: `env("GOCOVERDIR") { System.getProperty("go.cover.dir")?.also { File(it).mkdirs() } ?: "" }` - **Go app**: `signal.Ignore(syscall.SIGPIPE)` in `main()` — prevents SIGPIPE (exit 141) from killing the process before coverage flush when stdout pipe closes under `ProcessBuilder` - **Report tasks**: `goCoverageReport` (textfmt), `goCoverageSummary` (per-function), `goCoverageHtml` (visual) - **Umbrella task**: `e2eTestWithCoverage` runs tests + generates reports ```bash ./gradlew e2eTestWithCoverage -Pgo.coverage=true ./gradlew e2eTest-containerWithCoverage -Pgo.coverage=true ``` No Stove framework changes needed — uses existing `envMapper`, Gradle tasks, and SIGTERM shutdown. See [go-setup.md](go-setup.md#code-coverage) for full details. ## What you can't do - **No `bridge()` / `using {}`** --- no access to app's DI container - Everything else works: HTTP, databases, Kafka, tracing, WireMock, gRPC, dashboard ## Container mode (`containerApp`) Use `containerApp(...)` from `stove-container` when the AUT should run as a Docker image. Same envMapper/argsMapper model as processApp, plus a `configureContainer { ... }` block for Testcontainers-level customization (network mode, bind mounts, log consumers). ```kotlin import com.trendyol.stove.container.ContainerTarget import com.trendyol.stove.container.containerApp import com.trendyol.stove.system.application.envMapper containerApp( image = "my-app:local", target = ContainerTarget.Server( hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false ), envProvider = envMapper { "database.host" to "DB_HOST" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") }, configureContainer = { withNetworkMode("host") // Linux only; use port binding + shared network on macOS/Windows } ) ``` `ContainerTarget.Server(hostPort, internalPort, portEnvVar, bindHostPort)` for HTTP/gRPC servers, `ContainerTarget.Worker()` for jobs. See [container.md](container.md) for the full guide (Dockerfile, Gradle wiring, networking strategies, coverage volume mounts, common pitfalls). A common pattern: one `StoveConfig.kt` branches on `-Dgo.aut.mode=process|container` to switch between starters. The infrastructure systems and tests stay identical. ## MCP triage on failures When `stove` (the CLI) is running, agents can triage failed runs through the local MCP endpoint at `http://localhost:4040/mcp` instead of scraping logs. See [mcp.md](mcp.md) for the workflow. ## Reference - Process module source: `starters/process/stove-process/` - Container module source: `starters/container/stove-container/` - Container DSL: `starters/container/stove-container/src/main/kotlin/com/trendyol/stove/container/ContainerDsl.kt` - Full Go example (process + container in one repo): `recipes/process/golang/go-showcase/` - Docs: - `docs/other-languages/go.md` — overview / mode picker - `docs/other-languages/go-process.md` — process mode walkthrough - `docs/other-languages/go-container.md` — container mode walkthrough - `docs/other-languages/index.md` - `docs/Components/21-mcp.md` — MCP triage ================================================ FILE: .claude/skills/stove/system-setup.md ================================================ # System Setup Reference ## Contents - [Process Application (non-JVM apps)](#process-application-non-jvm-apps) - [Provided Application (smoke testing)](#provided-application-smoke-testing) - [Keyed systems (multiple instances)](#keyed-systems-multiple-instances) - [HTTP Client](#http-client) - [PostgreSQL](#postgresql) - [MySQL](#mysql) - [MSSQL](#mssql) - [Cassandra](#cassandra) - [MongoDB](#mongodb) - [Redis](#redis) - [Elasticsearch](#elasticsearch) - [Couchbase](#couchbase) - [Kafka](#kafka) - [WireMock](#wiremock) - [gRPC Mock](#grpc-mock) - [gRPC Client](#grpc-client) - [Bridge](#bridge) - [Dashboard](#dashboard) - [Reporting](#reporting) - [Application runner](#application-runner) - [Migrations](#migrations) - [Container customization](#container-customization) - [Fault injection (pause/unpause)](#fault-injection-pauseunpause) - [Serde configuration](#serde-configuration) - [Cleanup](#cleanup) - [Keep dependencies running](#keep-dependencies-running) All systems are configured inside `Stove().with { }`. The application runner goes last. ## Process Application (non-JVM apps) Use `processApp()` or `goApp()` from the `stove-process` module to test applications written in any language (Go, Python, Rust, Node.js, etc.) as OS processes. ```kotlin dependencies { testImplementation("com.trendyol:stove-process") } ``` ### ProcessTarget variants | Variant | Use case | Default readiness | |---------|----------|-------------------| | `ProcessTarget.Server(port, portEnvVar)` | HTTP/gRPC/TCP servers | HTTP GET `/health` | | `ProcessTarget.Worker()` | Kafka consumers, batch jobs | 2s fixed delay | ### ReadinessStrategy variants | Strategy | Use case | |----------|----------| | `ReadinessStrategy.HttpGet(url, timeout, retries, retryDelay, expectedStatusCodes)` | REST APIs with health endpoint | | `ReadinessStrategy.TcpPort(port)` | gRPC/TCP servers (no HTTP) | | `ReadinessStrategy.Probe { ... }` | Custom readiness (file, DB, etc.) | | `ReadinessStrategy.FixedDelay(duration)` | Simple workers | ### Configuration passing Stove collects all system configurations (`configureExposedConfiguration` from each system) as `key=value` strings and passes them to the process. Two mechanisms are available — use one or both: #### envMapper — environment variables Maps Stove config keys to OS environment variables: ```kotlin envMapper { "stove.config.key" to "ENV_VAR_NAME" // map Stove config → env var env("STATIC_VAR", "value") // static env var env("COMPUTED_VAR") { computeValue() } // computed env var } ``` #### argsMapper — CLI arguments Maps Stove config keys to command-line arguments, appended to the process command: ```kotlin // GNU-style: --db-host=localhost --db-port=5432 argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" "database.port" to "db-port" arg("verbose") // boolean flag: --verbose arg("log-level", "debug") // static: --log-level=debug arg("config-file") { "/tmp/test.yaml" } // computed: --config-file=/tmp/test.yaml } // POSIX-style: -h localhost -p 5432 (space separator → two separate args) argsMapper(prefix = "-", separator = " ") { "database.host" to "h" "database.port" to "p" } // No prefix: db-host=localhost argsMapper(prefix = "", separator = "=") { "database.host" to "db-host" } ``` #### Using both together ```kotlin processApp { ProcessApplicationOptions( command = listOf("/path/to/server"), target = ProcessTarget.Server(port = 8090), envProvider = envMapper { "database.host" to "DB_HOST" // passed as env var }, argsProvider = argsMapper(prefix = "--", separator = "=") { "database.port" to "db-port" // passed as --db-port=5432 arg("verbose") } ) } ``` ### Examples ```kotlin // HTTP API (Go) — env vars goApp( target = ProcessTarget.Server(port = 8090, portEnvVar = "APP_PORT"), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" env("LOG_LEVEL", "debug") } ) // Rust CLI server — CLI args processApp { ProcessApplicationOptions( command = listOf("/path/to/rust-server"), target = ProcessTarget.Server(port = 8090), argsProvider = argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" "database.port" to "db-port" } ) } // gRPC server (any language) processApp { ProcessApplicationOptions( command = listOf("/path/to/grpc-server"), target = ProcessTarget.Server( port = 50051, portEnvVar = "GRPC_PORT", readiness = ReadinessStrategy.TcpPort(port = 50051), ), envProvider = envMapper { "database.host" to "DB_HOST" } ) } // Kafka consumer (no port) goApp( target = ProcessTarget.Worker(readiness = ReadinessStrategy.FixedDelay(3.seconds)), envProvider = envMapper { "kafka.bootstrapServers" to "KAFKA_BROKERS" } ) ``` Key points: - `goApp()` defaults binary path from `go.app.binary` system property - Port env var is injected automatically for `Server` targets - `envProvider` and `argsProvider` can be used independently or together - No `bridge()` — the app runs as a separate process, no DI access - See [other-languages.md](other-languages.md) and [go-setup.md](go-setup.md) for full setup guides ## Provided Application (smoke testing) Use `providedApplication()` instead of a JVM runner to test against an already-deployed application. The application can be written in **any language** — Go, Python, .NET, Rust, Node.js, etc. ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet( url = "https://staging.myapp.com/health", retries = 10, retryDelay = 1.seconds, timeout = 30.seconds, expectedStatusCodes = setOf(200) ) ) } }.run() ``` Without health check (fire-and-forget): ```kotlin providedApplication() // No health check, no options ``` Combine with `.provided()` system options to connect to existing infrastructure: ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } postgresql(AppDb) { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", cleanup = { ops -> ops.execute("DELETE FROM orders WHERE test_data = true") }, configureExposedConfiguration = { listOf() } ) } redis(CacheCluster) { RedisOptions.provided( host = "staging-redis", port = 6379, configureExposedConfiguration = { listOf() } ) } providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet(url = "https://staging.myapp.com/health") ) } }.run() ``` **Important**: `Bridge` (DI access via `using`) is **not available** with `providedApplication()` — there is no local DI container. Use `cleanup` lambdas to manage test data on external infrastructure. ## Keyed systems (multiple instances) Register multiple instances of the same system type using `SystemKey`. Define keys as singleton objects: ```kotlin object AppDb : SystemKey object AnalyticsDb : SystemKey object PaymentService : SystemKey object InventoryService : SystemKey ``` Use keys in registration: ```kotlin Stove().with { // Two separate PostgreSQL containers with independent configs postgresql(AppDb) { PostgresqlOptions( databaseName = "appdb", configureExposedConfiguration = { cfg -> listOf("app.datasource.url=${cfg.jdbcUrl}") } ).migrations { register() } } postgresql(AnalyticsDb) { PostgresqlOptions( databaseName = "analyticsdb", configureExposedConfiguration = { cfg -> listOf("analytics.datasource.url=${cfg.jdbcUrl}") } ).migrations { register() } } // Two HTTP clients pointing to different services httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "https://pay.internal") } httpClient(InventoryService) { HttpClientSystemOptions(baseUrl = "https://inventory.internal") } // Two WireMock instances for different external APIs wiremock(PaymentService) { WireMockSystemOptions(port = 0, configureExposedConfiguration = { cfg -> listOf("payment.url=${cfg.baseUrl}") }) } wiremock(InventoryService) { WireMockSystemOptions(port = 0, configureExposedConfiguration = { cfg -> listOf("inventory.url=${cfg.baseUrl}") }) } springBoot(runner = { params -> run(params) }) }.run() ``` All systems support keyed registration: PostgreSQL, MySQL, MSSQL, Cassandra, MongoDB, Redis, Elasticsearch, Couchbase, Kafka (core), WireMock, gRPC, gRPC Mock, HTTP. Each keyed instance gets: - Its own container with a unique dynamic port (no conflicts) - Independent `configureExposedConfiguration` — all configs are aggregated and passed to the AUT - Isolated state storage (separate lock files per key) - Its own `cleanup` lambda - Distinct reporting name (e.g., `"PostgreSQL [AppDb]"`) A single key can be shared across protocol types: ```kotlin object PaymentService : SystemKey httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "...") } grpc(PaymentService) { GrpcSystemOptions(host = "...", port = 50051) } wiremock(PaymentService) { WireMockSystemOptions(...) } ``` Keyed systems work with both Testcontainers and `.provided()` (external) instances: ```kotlin postgresql(AppDb) { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/app", configureExposedConfiguration = { listOf() } ) } ``` ## HTTP Client ```kotlin httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } ``` Advanced options: ```kotlin httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080", timeout = 60.seconds, // Request timeout (default: 30s) contentConverter = JacksonConverter(myObjectMapper), // Custom JSON converter configureClient = { // Ktor HttpClient config install(Logging) { level = LogLevel.ALL } }, configureWebSocket = { // WebSocket config pingIntervalMillis = 10_000 }, createClient = { url -> myCustomHttpClient(url) } // Full client factory override ) } ``` ## PostgreSQL ```kotlin postgresql { PostgresqlOptions( databaseName = "testdb", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } ``` Migration class: ```kotlin class InitialMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute( """ CREATE TABLE IF NOT EXISTS orders ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL, amount DECIMAL(10, 2) NOT NULL, status VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); """.trimIndent() ) } } ``` For R2DBC: ```kotlin configureExposedConfiguration = { cfg -> listOf( "spring.r2dbc.url=r2dbc:postgresql://${cfg.host}:${cfg.port}/testdb", "spring.r2dbc.username=${cfg.username}", "spring.r2dbc.password=${cfg.password}" ) } ``` ## MySQL ```kotlin mysql { MySqlSystemOptions( databaseName = "testdb", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } ``` Same migration pattern as PostgreSQL, using `MySqlMigrationContext`. ## MSSQL ```kotlin mssql { MsSqlSystemOptions( databaseName = "testdb", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } ``` Same migration pattern, using `MsSqlMigrationContext`. ## Cassandra ```kotlin cassandra { CassandraSystemOptions( keyspace = "my_keyspace", datacenter = "datacenter1", container = CassandraContainerOptions(tag = "4.1"), configureExposedConfiguration = { cfg -> listOf( "cassandra.host=${cfg.host}", "cassandra.port=${cfg.port}", "cassandra.keyspace=${cfg.keyspace}", "cassandra.datacenter=${cfg.datacenter}" ) } ).migrations { register() } } ``` Migration class: ```kotlin class CreateTableMigration : CassandraMigration { override val order: Int = 1 override suspend fun execute(connection: CassandraMigrationContext) { connection.session.execute( """ CREATE TABLE IF NOT EXISTS orders ( id text PRIMARY KEY, user_id text, amount double, status text ); """.trimIndent() ) } } ``` For an externally managed Cassandra: ```kotlin cassandra { CassandraSystemOptions.provided( host = "localhost", port = 9042, datacenter = "dc1", keyspace = "my_keyspace", configureExposedConfiguration = { cfg -> listOf("cassandra.contact-points=${cfg.host}:${cfg.port}") } ) } ``` Container operations: `pause()` / `unpause()` to simulate Cassandra downtime. ## MongoDB ```kotlin mongodb { MongodbSystemOptions( databaseOptions = DatabaseOptions( default = DefaultDatabase(name = "testdb", collection = "orders") ), container = MongoContainerOptions(tag = "7.0"), configureExposedConfiguration = { cfg -> listOf( "mongodb.connection-string=${cfg.connectionString}", "mongodb.host=${cfg.host}", "mongodb.port=${cfg.port}" ) } ).migrations { register() } } ``` For an externally managed MongoDB: ```kotlin mongodb { MongodbSystemOptions.provided( connectionString = "mongodb://localhost:27017", host = "localhost", port = 27017, configureExposedConfiguration = { cfg -> listOf("spring.data.mongodb.uri=${cfg.connectionString}") } ) } ``` Migration class uses `MongodbMigrationContext` with access to `client: MongoClient`. Container operations: `pause()` / `unpause()`. ## Redis ```kotlin redis { RedisOptions( database = 0, password = "redis-password", container = RedisContainerOptions(tag = "7-alpine"), configureExposedConfiguration = { cfg -> listOf( "redis.host=${cfg.host}", "redis.port=${cfg.port}", "redis.password=${cfg.password}" ) } ) } ``` For an externally managed Redis: ```kotlin redis { RedisOptions.provided( host = "localhost", port = 6379, password = "secret", database = 0, configureExposedConfiguration = { cfg -> listOf("spring.redis.url=${cfg.redisUri}") } ) } ``` Container operations: `pause()` / `unpause()`. ## Elasticsearch ```kotlin elasticsearch { ElasticsearchSystemOptions( container = ElasticContainerOptions( tag = "8.15.0", password = "elastic-password", disableSecurity = true ), configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ).migrations { register() } } ``` For an externally managed Elasticsearch: ```kotlin elasticsearch { ElasticsearchSystemOptions.provided( host = "localhost", port = 9200, configureExposedConfiguration = { cfg -> listOf("es.url=http://${cfg.host}:${cfg.port}") } ) } ``` Migration class uses `ElasticsearchClient` as context directly. Container operations: `pause()` / `unpause()`. ## Couchbase ```kotlin couchbase { CouchbaseSystemOptions( defaultBucket = "test-bucket", containerOptions = CouchbaseContainerOptions(tag = "7.6.1"), configureExposedConfiguration = { cfg -> listOf( "couchbase.connection-string=${cfg.connectionString}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ).migrations { register() } } ``` For an externally managed Couchbase: ```kotlin couchbase { CouchbaseSystemOptions.provided( connectionString = "couchbase://localhost", username = "admin", password = "password", defaultBucket = "test-bucket", configureExposedConfiguration = { cfg -> listOf("couchbase.hosts=${cfg.hostsWithPort}") } ) } ``` Migration class uses `Cluster` as context. Container operations: `pause()` / `unpause()`. ## Kafka Use `stove-kafka` for standalone. Use `stove-spring-kafka` for Spring Boot Kafka listeners (`shouldBeConsumed`, `shouldBeFailed`, `shouldBeRetried`). ```kotlin kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(), valueSerializer = JsonSerializer(), containerOptions = KafkaContainerOptions(tag = "8.0.3") { withStartupAttempts(3) }, configureExposedConfiguration = { cfg -> listOf( "spring.kafka.bootstrap-servers=${cfg.bootstrapServers}", "spring.kafka.producer.properties.interceptor.classes=${cfg.interceptorClass}", "spring.kafka.consumer.properties.interceptor.classes=${cfg.interceptorClass}" ) } ) } ``` For embedded Kafka (no Docker container): ```kotlin kafka { KafkaSystemOptions( useEmbeddedKafka = true, configureExposedConfiguration = { cfg -> listOf("spring.kafka.bootstrap-servers=${cfg.bootstrapServers}") } ) } ``` **Application-side requirements (Spring Boot Kafka)**: - Inject `RecordInterceptor` into your `ConcurrentKafkaListenerContainerFactory` and call `factory.setRecordInterceptor(interceptor)`. - Register `TestSystemKafkaInterceptor<*, *>` and a `StoveSerde` bean in test dependencies. ### Test-friendly Kafka settings Default Kafka producer/consumer settings are tuned for production throughput, not test speed. In e2e tests, this causes timeouts, flaky assertions, and slow feedback. Configure for **immediate delivery and fast commits**: **Container-level** — enable auto-topic creation so topics exist when producers/consumers first connect: ```kotlin kafka { KafkaSystemOptions( containerOptions = KafkaContainerOptions(tag = "8.0.3") { withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true") }, configureExposedConfiguration = { cfg -> listOf("spring.kafka.bootstrap-servers=${cfg.bootstrapServers}") } ) } ``` **Producer settings** — flush immediately, don't batch: ```properties # Spring Boot application.yml or exposed via Stove config spring.kafka.producer.properties.linger.ms=0 # Send immediately, don't wait to batch spring.kafka.producer.properties.batch.size=1 # Single-message batches spring.kafka.producer.acks=all # Wait for all replicas (reliable in single-broker test) ``` **Consumer settings** — commit fast, start from beginning, short timeouts: ```properties spring.kafka.consumer.auto-offset-reset=earliest # Start from beginning (don't miss messages) spring.kafka.consumer.properties.auto.commit.interval.ms=100 # Commit offsets every 100ms (default: 5000ms) spring.kafka.consumer.properties.max.poll.interval.ms=10000 # Shorter poll timeout (default: 300000ms) spring.kafka.consumer.properties.session.timeout.ms=10000 # Faster rebalance on failure (default: 45000ms) spring.kafka.consumer.properties.heartbeat.interval.ms=3000 # Faster heartbeat (default: 3000ms, keep ≤ session/3) ``` **Why this matters for Stove assertions:** - `shouldBePublished` checks the Stove interceptor sink — messages must reach it promptly. `linger.ms=0` and `batch.size=1` prevent the producer from holding messages. - `shouldBeConsumed` checks that the message was consumed AND its offset committed. `auto.commit.interval.ms=100` makes committed offsets visible within 100ms instead of the 5-second default. - `shouldBeFailed` / `shouldBeRetried` check error and retry sinks — short `max.poll.interval.ms` prevents long waits before Kafka considers a consumer dead. - Without `auto-offset-reset=earliest`, consumers joining after a message is produced will never see it, causing `shouldBeConsumed` to timeout. **Passing these via Stove's `configureExposedConfiguration`:** ```kotlin kafka { KafkaSystemOptions( containerOptions = KafkaContainerOptions { withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true") }, configureExposedConfiguration = { cfg -> listOf( "spring.kafka.bootstrap-servers=${cfg.bootstrapServers}", "spring.kafka.producer.properties.interceptor.classes=${cfg.interceptorClass}", "spring.kafka.consumer.properties.interceptor.classes=${cfg.interceptorClass}", // Test-friendly overrides "spring.kafka.producer.properties.linger.ms=0", "spring.kafka.producer.properties.batch.size=1", "spring.kafka.consumer.auto-offset-reset=earliest", "spring.kafka.consumer.properties.auto.commit.interval.ms=100" ) } ) } ``` **Non-Spring JVM apps** — the same principles apply. Pass equivalent properties through your app's configuration mechanism: | Setting | Production default | Test-friendly value | Why | |---------|-------------------|--------------------|----| | `linger.ms` | 5-100 | `0` | Immediate send | | `batch.size` | 16384 | `1` | No batching | | `auto.commit.interval.ms` | 5000 | `100` | Fast offset visibility | | `auto.offset.reset` | `latest` | `earliest` | Don't miss messages | | `max.poll.interval.ms` | 300000 | `10000` | Faster failure detection | | `auto.create.topics.enable` (broker) | `true` | `true` | Topics exist on first use | **Go applications** — see [go-setup.md](go-setup.md) for per-library settings (sarama, franz-go, segmentio/kafka-go) including auto-topic creation, batch timeouts, commit intervals, and the separate producer/consumer client pattern for franz-go. ## WireMock ```kotlin wiremock { WireMockSystemOptions( port = 0, // Dynamic port — recommended for CI serde = StoveSerde.jackson.anyByteArraySerde(), configureExposedConfiguration = { cfg -> listOf( "payment.service.url=${cfg.baseUrl}", "inventory.service.url=${cfg.baseUrl}" ) } ) } ``` All external service URLs must be configurable so they can be pointed to WireMock. ## gRPC Mock ```kotlin grpcMock { GrpcMockSystemOptions( port = 0, // Dynamic port configureExposedConfiguration = { cfg -> listOf( "grpcService.host=${cfg.host}", "grpcService.port=${cfg.port}" ) } ) } ``` ## gRPC Client For testing your own gRPC server (not external mocks): ```kotlin grpc { GrpcSystemOptions(host = "localhost", port = 50051) } ``` ## Bridge Direct access to DI container from tests. Built into `stove-spring` / `stove-ktor` / `stove-micronaut`. ```kotlin bridge() // Auto-detects DI framework ``` ### Ktor DI support Bridge auto-detects your DI framework: ```kotlin // Koin — add io.insert-koin:koin-ktor to classpath bridge() // auto-detects Koin // Ktor-DI — add io.ktor:ktor-server-di to classpath bridge() // auto-detects Ktor-DI // Custom resolver (Kodein, Dagger, etc.) bridge { application, type -> myDiContainer.resolve(type) } ``` ## Dashboard Streams test events to the stove CLI for real-time visualization. Requires `stove-dashboard` and `stove-extensions-kotest` or `stove-extensions-junit` — see [Reporting](#reporting). ```kotlin dashboard { DashboardSystemOptions( appName = "my-service", cliHost = "localhost", // default cliPort = 4041 // default ) } ``` Run `stove` CLI separately, then run your tests — the dashboard at `http://localhost:4040` shows a live tree of specs, test hierarchy, timeline entries, traces, and snapshots. ## Reporting Reporting and test hierarchy tracking require the framework extension. This is mandatory for Dashboard, tracing, and structured failure reports. ### Kotest Register `StoveKotestExtension` in your `AbstractProjectConfig`: ```kotlin class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove().with { /* systems */ }.run() } override suspend fun afterProject() { Stove.stop() } } ``` Requires `stove-extensions-kotest` dependency and a `kotest.properties` file pointing to this config class. ### JUnit Annotate your base test class with `@ExtendWith(StoveJUnitExtension::class)`: ```kotlin @ExtendWith(StoveJUnitExtension::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class BaseE2ETest { companion object { @JvmStatic @BeforeAll fun setup() = runBlocking { Stove().with { /* systems */ }.run() } @JvmStatic @AfterAll fun teardown() = runBlocking { Stove.stop() } } } ``` Requires `stove-extensions-junit` dependency. Supports `@Nested` class hierarchy. ## Application runner Goes last, after all systems. Systems inject configuration via `configureExposedConfiguration`. ### Spring Boot ```kotlin springBoot( runner = { params -> com.yourcompany.yourapp.run(params) { addTestDependencies { bean>(isPrimary = true) bean { StoveSerde.jackson.anyByteArraySerde() } } } }, withParameters = listOf( "server.port=8080", "grpc.server.port=$GRPC_SERVER_PORT" ) ) ``` For Spring Boot 4.x, use `addTestDependencies4x` with `registerBean<>()`: ```kotlin addTestDependencies4x { registerBean>(primary = true) registerBean { StoveSerde.jackson.anyByteArraySerde() } } ``` ### Ktor ```kotlin ktor( runner = { params -> com.yourcompany.yourapp.run(params, wait = false) }, withParameters = listOf("server.port=8080") ) ``` ### Quarkus ```kotlin quarkus( runner = { params -> com.yourcompany.yourapp.main(params) }, withParameters = listOf("quarkus.http.port=8080") ) ``` Supports both direct main runner and packaged runtime. Configurable startup timeout via `stove.quarkus.startup.timeout.ms` system property (default: 120s). ### Micronaut ```kotlin micronaut( runner = { params -> com.yourcompany.yourapp.run(params) }, withParameters = listOf("micronaut.server.port=8080") ) ``` Returns `ApplicationContext`, enabling bridge/DI access. ## Migrations Database and infrastructure systems support migrations that run after the system starts and before tests execute. Use for schema creation, indexing, and seed data. ```kotlin postgresql { PostgresqlOptions( configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ).migrations { register() register() } } ``` Migration class: ```kotlin class CreateTablesMigration : PostgresqlMigration { override val order: Int = MigrationPriority.HIGHEST.value // Runs first override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute("CREATE TABLE IF NOT EXISTS orders (...)") } } class SeedDataMigration : PostgresqlMigration { override val order: Int = 100 // After schema override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute("INSERT INTO orders ...") } } ``` Ordering: migrations execute in ascending `order`. Use `MigrationPriority.HIGHEST` (schema), `MigrationPriority.LOWEST` (final setup), or custom integers. Type aliases per system (use instead of `DatabaseMigration`): | Module | Type Alias | Context Type | |---|---|---| | stove-postgres | `PostgresqlMigration` | `PostgresSqlMigrationContext` | | stove-mysql | `MySqlMigration` | `MySqlMigrationContext` | | stove-mssql | `MsSqlMigration` | `SqlMigrationContext` | | stove-mongodb | `MongodbMigration` | `MongodbMigrationContext` | | stove-couchbase | `CouchbaseMigration` | `Cluster` | | stove-elasticsearch | `ElasticsearchMigration` | `ElasticsearchClient` | | stove-redis | `RedisMigration` | `RedisMigrationContext` | | stove-kafka | `KafkaMigration` | `KafkaMigrationContext` | | stove-cassandra | `CassandraMigration` | `CassandraMigrationContext` | Advanced patterns: ```kotlin // Factory function for migrations with parameters .migrations { register { ConfigurableMigration(batchSize = 1000) } } // Replace a migration with a test-specific override .migrations { register() replace() // Or replace with a factory replace { MinimalSeedMigration() } } ``` Notes: migrations must have no-arg constructors (unless using factory registration). Use idempotent statements (`IF NOT EXISTS`). Don't close the connection — Stove manages it. ## Container customization All container-backed systems accept a `ContainerOptions` with customization hooks: ```kotlin postgresql { PostgresqlOptions( container = PostgresqlContainerOptions( registry = "my-registry.example.com", // Custom Docker registry image = "postgres", // Image name tag = "16-alpine", // Image tag compatibleSubstitute = "my-registry.example.com/postgres", // Alternative image containerFn = { // Customize container before startup withEnv("POSTGRES_INITDB_ARGS", "--encoding=UTF-8") withCommand("postgres", "-c", "max_connections=200") } ), configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } kafka { KafkaSystemOptions( containerOptions = KafkaContainerOptions(tag = "8.0.3") { withStartupAttempts(3) withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true") }, configureExposedConfiguration = { cfg -> listOf("kafka.bootstrap=${cfg.bootstrapServers}") } ) } ``` The `containerFn` lambda receives the container instance before startup, so you can call any Testcontainers method (env vars, commands, exposed ports, volume mounts, etc.). ## Fault injection (pause/unpause) All container-backed systems support `pause()` / `unpause()` for simulating outages. Both are idempotent. ```kotlin stove { postgresql { pause() } http { getResponse("/health") { response -> response.status shouldBe 503 } } postgresql { unpause() } http { getResponse("/health") { response -> response.status shouldBe 200 } } } ``` Supported: PostgreSQL, MySQL, MSSQL, Cassandra, MongoDB, Redis, Elasticsearch, Couchbase, Kafka. ## Serde configuration Stove uses `StoveSerde` for JSON handling across systems (Kafka, MongoDB, WireMock, HTTP). Three implementations are built in: ```kotlin // Jackson (default) — configure ObjectMapper val serde = StoveSerde.jackson.anyByteArraySerde() val stringSerde = StoveSerde.jackson.anyJsonStringSerde() // With custom ObjectMapper val customSerde = StoveSerde.jackson.anyByteArraySerde( StoveSerde.jackson.byConfiguring { disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) } ) // Kotlinx Serialization (requires @Serializable) val kotlinxSerde = StoveSerde.kotlinx.anyByteArraySerde() val customKotlinx = StoveSerde.kotlinx.anyByteArraySerde( StoveSerde.kotlinx.byConfiguring { ignoreUnknownKeys = true isLenient = true } ) // Gson val gsonSerde = StoveSerde.gson.anyByteArraySerde() val customGson = StoveSerde.gson.anyByteArraySerde( StoveSerde.gson.byConfiguring { setPrettyPrinting() serializeNulls() } ) ``` **Important: align Stove's serde with your application's serialization.** If your application uses a custom ObjectMapper (e.g., with snake_case naming, custom date formats, or extra modules), Stove must use the same configuration. Otherwise, Stove will fail to deserialize responses from your HTTP endpoints, Kafka messages produced by your app, or documents written to MongoDB/Elasticsearch/Couchbase. The same applies if your application uses Kotlinx Serialization or Gson — use the matching `StoveSerde` variant. ```kotlin // Reuse your application's ObjectMapper so ser/de behavior matches val appObjectMapper = MyApp.objectMapper() httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080", contentConverter = JacksonConverter(appObjectMapper) ) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(appObjectMapper), configureExposedConfiguration = { cfg -> listOf("kafka.bootstrap=${cfg.bootstrapServers}") } ) } mongodb { MongodbSystemOptions( serde = StoveSerde.jackson.anyJsonStringSerde(appObjectMapper), configureExposedConfiguration = { cfg -> listOf("mongodb.uri=${cfg.connectionString}") } ) } wiremock { WireMockSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(appObjectMapper), configureExposedConfiguration = { cfg -> listOf("service.url=${cfg.baseUrl}") } ) } ``` ## Cleanup Every system accepts a `cleanup` lambda in its options. This runs during `Stove.stop()` (after all tests complete) and receives the system's native client. Use it to wipe test data — especially important for provided (external) instances that persist between runs. ```kotlin postgresql { PostgresqlOptions( databaseName = "testdb", cleanup = { ops -> ops.execute("TRUNCATE orders, users") }, configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } mongodb { MongodbSystemOptions( cleanup = { client -> client.getDatabase("testdb").drop() }, configureExposedConfiguration = { cfg -> listOf("mongodb.uri=${cfg.connectionString}") } ) } kafka { KafkaSystemOptions( cleanup = { admin -> val topics = admin.listTopics().names().get().filter { it.startsWith("test-") } if (topics.isNotEmpty()) admin.deleteTopics(topics).all().get() }, configureExposedConfiguration = { cfg -> listOf("kafka.bootstrap=${cfg.bootstrapServers}") } ) } redis { RedisOptions( cleanup = { client -> client.connect().sync().flushdb() }, configureExposedConfiguration = { cfg -> listOf("redis.host=${cfg.host}") } ) } elasticsearch { ElasticsearchSystemOptions( cleanup = { client -> client.indices().delete { it.index("test-*") } }, configureExposedConfiguration = { cfg -> listOf("es.host=${cfg.host}") } ) } couchbase { CouchbaseSystemOptions( cleanup = { cluster -> cluster.query("DELETE FROM `test-bucket`") }, configureExposedConfiguration = { cfg -> listOf("cb.conn=${cfg.connectionString}") } ) } cassandra { CassandraSystemOptions( cleanup = { session -> session.execute("TRUNCATE my_keyspace.orders") }, configureExposedConfiguration = { cfg -> listOf("cassandra.host=${cfg.host}") } ) } mysql { MySqlSystemOptions( cleanup = { ops -> ops.execute("TRUNCATE orders") }, configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } mssql { MsSqlSystemOptions( cleanup = { ops -> ops.execute("TRUNCATE TABLE orders") }, configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } ``` Cleanup client types per system: | System | Cleanup parameter type | |---|---| | PostgreSQL | `NativeSqlOperations` | | MySQL | `NativeSqlOperations` | | MSSQL | `NativeSqlOperations` | | Cassandra | `CqlSession` | | MongoDB | `MongoClient` | | Redis | `RedisClient` | | Elasticsearch | `ElasticsearchClient` | | Couchbase | `Cluster` | | Kafka | `Admin` | WireMock uses event-driven cleanup instead: ```kotlin wiremock { WireMockSystemOptions( removeStubAfterRequestMatched = true, // Auto-remove stubs after match afterStubRemoved = { serveEvent, stubLog -> /* optional callback */ }, configureExposedConfiguration = { cfg -> listOf("service.url=${cfg.baseUrl}") } ) } ``` The `cleanup` lambda also works with `.provided()` (external instances): ```kotlin postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://localhost:5432/testdb", host = "localhost", port = 5432, cleanup = { ops -> ops.execute("TRUNCATE orders, users") }, configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } ``` ## Keep dependencies running Keeps containers alive between test runs for faster local iteration. Disable in CI. ```kotlin Stove { keepDependenciesRunning() }.with { /* systems */ }.run() ``` ================================================ FILE: .claude/skills/stove/tracing.md ================================================ # Tracing Configuration Tracing captures the full execution call chain inside your application, shown on test failure. Requires `stove-tracing` and `stove-extensions-kotest` or `stove-extensions-junit`. ## 1. Enable span receiver Add inside `Stove().with { }`: ```kotlin tracing { enableSpanReceiver() } ``` ## 2. Attach the OpenTelemetry agent ### Gradle Plugin (default) ```kotlin plugins { id("com.trendyol.stove.tracing") version "$stoveVersion" } stoveTracing { serviceName.set("my-service") testTaskNames.set(listOf("e2eTest")) } ``` ### buildSrc alternative Copy `StoveTracingConfiguration.kt` from the Stove repo to `buildSrc/src/main/kotlin/`, then use direct assignment: ```kotlin import com.trendyol.stove.gradle.stoveTracing stoveTracing { serviceName = "my-service" testTaskNames = listOf("e2eTest") } ``` ## 3. Plugin options | Option | Default | Description | |---|---|---| | `serviceName` | `"stove-traced-app"` | Service name in traces | | `enabled` | `true` | Toggle tracing | | `testTaskNames` | `[]` | Apply to specific tasks (empty = all) | | `otelAgentVersion` | `"2.24.0"` | OTel Java Agent version | | `disabledInstrumentations` | `[]` | Instrumentations to disable (e.g., `jdbc`, `hibernate`) | | `additionalInstrumentations` | `[]` | Extra instrumentations | | `customAnnotations` | `[]` | Custom annotation classes to instrument | | `protocol` | `"grpc"` | OTLP protocol | | `captureHttpHeaders` | `true` | Capture HTTP headers in spans | | `captureExperimentalTelemetry` | `true` | Enable experimental HTTP telemetry | | `bspScheduleDelay` | `100` | Batch span processor delay in ms (lower = faster export) | | `bspMaxBatchSize` | `1` | Batch size for span export (1 = immediate) | ## 4. Runtime tracing config Configure inside `Stove().with { }`: ```kotlin tracing { enableSpanReceiver() // Required spanCollectionTimeout(10.seconds) // Wait time for spans (default: 5s) maxSpansPerTrace(2000) // Cap per trace (default: 1000) spanFilter { span -> // Filter collected spans !span.operationName.contains("health-check") } } ``` ## 5. Trace validation DSL ```kotlin tracing { // Span assertions shouldContainSpan("OrderService.processOrder") shouldContainSpanMatching { it.operationName.contains("Repository") } shouldNotContainSpan("AdminService.delete") shouldNotHaveFailedSpans() shouldHaveFailedSpan("PaymentGateway.charge") shouldHaveSpanWithAttribute("http.method", "GET") shouldHaveSpanWithAttributeContaining("http.url", "/api/users") // Performance executionTimeShouldBeLessThan(500.milliseconds) executionTimeShouldBeGreaterThan(10.milliseconds) spanCountShouldBe(10) spanCountShouldBeAtLeast(5) spanCountShouldBeAtMost(20) // Debugging helpers println(renderTree()) // Hierarchical tree view println(renderSummary()) // Compact summary val failed = getFailedSpans() val duration = getTotalDuration() val span = findSpanByName("OrderService.process") // Wait for async spans waitForSpans(expectedCount = 5, timeoutMs = 3000) } ``` ================================================ FILE: .claude/skills/stove/writing-tests.md ================================================ # Writing Tests Reference ## Contents - [HTTP requests](#http-requests) - [HTTP streaming](#http-streaming) - [PostgreSQL queries](#postgresql-queries) - [MySQL queries](#mysql-queries) - [MSSQL queries](#mssql-queries) - [Cassandra assertions](#cassandra-assertions) - [MongoDB assertions](#mongodb-assertions) - [Redis assertions](#redis-assertions) - [Elasticsearch assertions](#elasticsearch-assertions) - [Couchbase assertions](#couchbase-assertions) - [Kafka assertions](#kafka-assertions) - [WireMock mocking](#wiremock-mocking) - [gRPC Mock](#grpc-mock) - [gRPC Client](#grpc-client) - [Bridge (DI access)](#bridge-di-access) - [Trace validation](#trace-validation) - [Keyed system tests](#keyed-system-tests) - [Smoke testing (providedApplication)](#smoke-testing-providedapplication) - [Multi-system test](#multi-system-test) - [Anti-patterns](#anti-patterns) All tests use the `stove { }` entry point. ## HTTP requests ```kotlin // POST with typed response http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(userId = "u1", amount = 99.99).some() ) { response -> response.status shouldBe 201 response.body().orderId shouldNotBe null } } // POST expecting JSON directly http { postAndExpectJson("/orders") { CreateOrderRequest(userId = "u1", amount = 99.99) } { order -> order.id shouldNotBe null } } // GET http { get("/users/123") { user -> user.name shouldBe "John" } } // GET with full response (status + headers + body) http { getResponse("/users/123") { response -> response.status shouldBe 200 response.headers["Content-Type"] shouldContain "application/json" response.body().id shouldBe 123 } } // GET list http { getMany("/products", queryParams = mapOf("page" to "1")) { products -> products.size shouldBe 10 } } // PUT http { putAndExpectBody( uri = "/products/456", body = UpdateProductRequest(price = 899.99).some() ) { response -> response.status shouldBe 200 response.body().price shouldBe 899.99 } } // DELETE http { deleteAndExpectBodilessResponse("/users/123") { response -> response.status shouldBe 204 } } // Multipart upload http { postMultipartAndExpectResponse( uri = "/products/import", body = listOf( StoveMultiPartContent.Text("name", "Laptop"), StoveMultiPartContent.File("file", "data.csv", csvBytes, MediaType.APPLICATION_OCTET_STREAM_VALUE) ) ) { response -> response.status shouldBe 200 } } // WebSocket http { webSocket("/chat") { send("Hello!") val response = receiveText() response shouldBe "Echo: Hello!" } // Collect multiple messages webSocket("/events") { val messages = collectTexts(count = 5, timeout = 10.seconds) messages.size shouldBe 5 } // Streaming with Flow webSocket("/stream") { incomingTexts().take(10).toList().size shouldBe 10 } } ``` ## HTTP streaming For JSON streaming (NDJSON) endpoints, use Flow-based extensions on `HttpStatement`: ```kotlin http { // Read NDJSON stream line by line, transform each line val items = client().prepareGet("/api/events/stream").readJsonTextStream { line -> StoveSerde.jackson.default.readValue(line, EventResponse::class.java) }.toList() items.size shouldBeGreaterThan 0 // Read stream as ByteReadChannel for binary processing client().prepareGet("/api/binary/stream").readJsonContentStream { channel -> channel.readRemaining().readText() }.toList().shouldNotBeEmpty() } // Serialize items to NDJSON for request body val body = StoveSerde.jackson.anyByteArraySerde().serializeToStreamJson( listOf(Event("e1"), Event("e2"), Event("e3")) ) ``` ## PostgreSQL queries ```kotlin postgresql { // Execute DDL/DML shouldExecute( """ INSERT INTO products (name, price) VALUES ('Laptop', 999.99) """.trimIndent() ) // Query with typed mapper shouldQuery( query = "SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> OrderRow( id = row.string("id"), userId = row.string("user_id"), amount = row.double("amount"), status = row.string("status") ) } ) { orders -> orders.size shouldBe 1 orders.first().status shouldBe "CONFIRMED" } } ``` ## MySQL queries Same API as PostgreSQL — uses `shouldExecute` and `shouldQuery` with a row mapper: ```kotlin mysql { shouldExecute("INSERT INTO products (name, price) VALUES ('Laptop', 999.99)") shouldQuery( query = "SELECT * FROM products WHERE name = 'Laptop'", mapper = { row -> ProductRow(row.string("name"), row.double("price")) } ) { products -> products.size shouldBe 1 } } ``` ## MSSQL queries Same API as PostgreSQL/MySQL: ```kotlin mssql { shouldExecute("INSERT INTO orders (id, status) VALUES ('o1', 'NEW')") shouldQuery( query = "SELECT * FROM orders WHERE id = 'o1'", mapper = { row -> OrderRow(row.string("id"), row.string("status")) } ) { orders -> orders.first().status shouldBe "NEW" } } ``` ## Cassandra assertions ```kotlin cassandra { // Execute CQL shouldExecute("INSERT INTO orders (id, user_id, status) VALUES ('o1', 'u1', 'NEW')") // Query with ResultSet assertion shouldQuery("SELECT * FROM orders WHERE id = 'o1'") { resultSet -> val row = resultSet.one()!! row.getString("status") shouldBe "NEW" } // Execute with BoundStatement shouldExecute(session().prepare("DELETE FROM orders WHERE id = ?").bind("o1")) // Query with BoundStatement shouldQuery(session().prepare("SELECT * FROM orders WHERE id = ?").bind("o1")) { rs -> rs.one() shouldBe null } // Simulate downtime pause() // ... test resilience ... unpause() } // Direct session access cassandra { session().execute("TRUNCATE orders") } ``` ## MongoDB assertions ```kotlin mongodb { // Save a document save(Order(id = "o1", userId = "u1", amount = 99.99)) // Save to specific collection save(Order(id = "o2", userId = "u2", amount = 50.0), collection = "archived_orders") // Get by ObjectId shouldGet(objectId = "o1") { order -> order.amount shouldBe 99.99 } // Query with filter string shouldQuery(query = """{ "userId": "u1" }""") { orders -> orders.size shouldBe 1 orders.first().status shouldBe "NEW" } // Delete shouldDelete(objectId = "o1") // Verify deletion shouldNotExist(objectId = "o1") // Simulate downtime pause() unpause() } // Direct client access mongodb { client().getDatabase("testdb").getCollection("orders").drop() } ``` ## Redis assertions Redis uses the Lettuce client directly via `client()`: ```kotlin redis { // All operations via the Lettuce RedisClient val connection = client().connect() val commands = connection.sync() commands.set("order:o1", """{"status":"NEW"}""") commands.get("order:o1") shouldNotBe null commands.del("order:o1") connection.close() } // Simulate downtime redis { pause() // ... test resilience ... unpause() } ``` ## Elasticsearch assertions ```kotlin elasticsearch { // Save a document save(id = "p1", instance = Product("p1", "Laptop", 999.99), index = "products") // Get by key shouldGet(index = "products", key = "p1") { product -> product.name shouldBe "Laptop" } // Query with JSON string shouldQuery( query = """{ "match": { "name": "Laptop" } }""", index = "products" ) { products -> products.size shouldBe 1 } // Query with Elasticsearch Query DSL object shouldQuery( query = Query.of { q -> q.match { m -> m.field("name").query("Laptop") } } ) { products -> products.shouldNotBeEmpty() } // Delete shouldDelete(key = "p1", index = "products") // Verify deletion shouldNotExist(key = "p1", index = "products") // Simulate downtime pause() unpause() } // Direct client access elasticsearch { client().indices().create { it.index("new-index") } } ``` ## Couchbase assertions ```kotlin couchbase { // Save to default collection saveToDefaultCollection(id = "o1", instance = Order("o1", "u1", 99.99)) // Save to specific collection save(collection = "archived", id = "o2", instance = Order("o2", "u2", 50.0)) // Get by key (default collection) shouldGet(key = "o1") { order -> order.amount shouldBe 99.99 } // Get from specific collection shouldGet(collection = "archived", key = "o2") { order -> order.userId shouldBe "u2" } // N1QL query shouldQuery(query = "SELECT * FROM `test-bucket` WHERE userId = 'u1'") { orders -> orders.size shouldBe 1 } // Delete (default collection) shouldDelete(key = "o1") // Delete from specific collection shouldDelete(collection = "archived", key = "o2") // Verify deletion shouldNotExist(key = "o1") shouldNotExist(collection = "archived", key = "o2") // Simulate downtime pause() unpause() } // Direct cluster/bucket access couchbase { cluster().queryIndexes().createPrimaryIndex("test-bucket") bucket().defaultCollection().upsert("doc1", JsonObject.create()) } ``` ## Kafka assertions ```kotlin // Verify published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.orderId == orderId && actual.amount == 99.99 } } // Verify consumed (stove-spring-kafka only) kafka { shouldBeConsumed(atLeastIn = 20.seconds) { actual.orderId == orderId } } // Publish a message kafka { publish( topic = "order-events", message = OrderCreated(orderId = "456", amount = 100.0), key = "order-456".some() ) } // Verify failed handling kafka { shouldBeFailed(atLeastIn = 10.seconds) { actual.id == 5L && reason is BusinessException } } // Verify retries (stove-spring-kafka only) kafka { shouldBeRetried(atLeastIn = 1.minutes, times = 3) { actual.id == "789" } } // Access message metadata kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.orderId == orderId && metadata.topic == "order-events" && metadata.headers["correlation-id"] != null } } ``` ## WireMock mocking ```kotlin wiremock { mockGet( url = "/inventory/$productId", statusCode = 200, responseBody = InventoryResponse(available = true).some() ) mockPost( url = "/payments/charge", statusCode = 200, responseBody = PaymentResult(success = true).some() ) } // PUT, PATCH, DELETE mocks wiremock { mockPut(url = "/products/123", statusCode = 200, responseBody = Product("123", "Updated", 899.99).some()) mockPatch(url = "/users/123", statusCode = 200, requestBody = mapOf("email" to "new@example.com").some()) mockDelete(url = "/products/123", statusCode = 204) mockHead(url = "/products/exists/123", statusCode = 200) } // Partial body matching — match specific fields, ignore the rest wiremock { mockPostContaining( url = "/api/orders", requestContaining = mapOf("productId" to 123), statusCode = 201, responseBody = OrderResponse(orderId = "order-123").some() ) // Deep nested matching with dot notation mockPostContaining( url = "/api/checkout", requestContaining = mapOf( "order.customer.id" to "cust-123", "order.payment.method" to "credit_card" ), statusCode = 200 ) } // Sequential responses (behavioral mocking) wiremock { behaviourFor("/api/service", WireMock::get) { initially { aResponse().withStatus(503) } then { aResponse().withStatus(503) } then { aResponse().withStatus(200).withBody(it.serialize(result)) } } } ``` ## gRPC Mock ```kotlin grpcMock { // Unary mockUnary( serviceName = "frauddetection.FraudDetectionService", methodName = "CheckFraud", response = CheckFraudResponse.newBuilder() .setIsFraudulent(false).setRiskScore(0.15).build() ) // With request matching mockUnary( serviceName = "users.UserService", methodName = "GetUser", requestMatcher = RequestMatcher.ExactMessage( GetUserRequest.newBuilder().setUserId("123").build() ), response = GetUserResponse.newBuilder().setName("John").build() ) // With authentication mockUnary( serviceName = "secure.SecureService", methodName = "GetSecret", metadataMatcher = MetadataMatcher.BearerToken("valid-token"), response = SecretResponse.newBuilder().setData("confidential").build() ) // Server streaming mockServerStream( serviceName = "streaming.ItemService", methodName = "ListItems", responses = listOf(item1, item2, item3) ) // Bidirectional streaming mockBidiStream( serviceName = "chat.ChatService", methodName = "Chat" ) { requestFlow -> requestFlow.map { bytes -> val req = ChatMessage.parseFrom(bytes) ChatMessage.newBuilder().setMessage("Echo: ${req.message}").build() } } // Error mockError( serviceName = "users.UserService", methodName = "GetUser", status = Status.Code.NOT_FOUND, message = "User not found" ) } ``` ## gRPC Client For testing your own gRPC server: ```kotlin grpc { channel { val response = getOrder( GetOrderRequest.newBuilder().setOrderId(orderId).build() ) response.found shouldBe true response.order.status shouldBe "CONFIRMED" } } ``` ## Bridge (DI access) ```kotlin // Single bean using { val order = getOrderByUserId(userId) order shouldNotBe null order!!.status shouldBe OrderStatus.CONFIRMED } // Multiple beans (up to 5 supported) using { userService, orderService -> val user = userService.findById(123) val orders = orderService.findByUserId(123) orders.size shouldBeGreaterThan 0 } using { a, b, c -> /* ... */ } ``` ## Trace validation ```kotlin tracing { // Span assertions shouldContainSpan("OrderService.processOrder") shouldContainSpanMatching { it.operationName.contains("Repository") } shouldNotContainSpan("AdminService.delete") shouldNotHaveFailedSpans() shouldHaveFailedSpan("PaymentGateway.charge") shouldHaveSpanWithAttribute("http.method", "GET") // Performance assertions executionTimeShouldBeLessThan(500.milliseconds) spanCountShouldBeAtLeast(5) // Debugging println(renderTree()) // Hierarchical trace view println(renderSummary()) // Compact summary } ``` ## Keyed system tests Access keyed systems by passing the `SystemKey` to the validation DSL: ```kotlin // Given keys defined in setup: // object AppDb : SystemKey // object AnalyticsDb : SystemKey // object PaymentService : SystemKey test("should write to both databases") { stove { http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest("u1", 99.99).some() ) { it.status shouldBe 201 } } // Assert against the app database postgresql(AppDb) { shouldQuery( query = "SELECT * FROM orders WHERE user_id = 'u1'", mapper = { row -> OrderRow(row.string("id"), row.string("status")) } ) { it.size shouldBe 1 } } // Assert against the analytics database postgresql(AnalyticsDb) { shouldQuery( query = "SELECT * FROM events WHERE user_id = 'u1'", mapper = { row -> AnalyticsRow(row.string("event_type")) } ) { it.first().eventType shouldBe "ORDER_CREATED" } } } } // Keyed WireMock and HTTP test("should call payment and inventory services") { stove { wiremock(PaymentService) { mockPost("/charge", 200, PaymentResult(true).some()) } wiremock(InventoryService) { mockGet("/stock/item-1", 200, StockResponse(10).some()) } http { postAndExpectBody("/orders", body = order.some()) { it.status shouldBe 201 } } } } ``` All systems support keyed access: `postgresql(key)`, `mysql(key)`, `mssql(key)`, `cassandra(key)`, `mongodb(key)`, `redis(key)`, `elasticsearch(key)`, `couchbase(key)`, `kafka(key)`, `wiremock(key)`, `grpcMock(key)`, `grpc(key)`, `http(key)`. ## Smoke testing (providedApplication) With `providedApplication()`, test a remote/deployed application without starting it locally. No `Bridge`/`using` — only infrastructure assertions: ```kotlin test("staging smoke test — order flow") { stove { val userId = "smoke-${UUID.randomUUID()}" // Hit the remote API http { postAndExpectBody( uri = "/api/orders", body = CreateOrderRequest(userId, 49.99).some() ) { response -> response.status shouldBe 201 } } // Verify side effects in the remote database postgresql(AppDb) { shouldQuery( query = "SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> OrderRow(row.string("id"), row.string("status")) } ) { orders -> orders.size shouldBe 1 orders.first().status shouldBe "CONFIRMED" } } // Verify Kafka event on the remote cluster kafka { shouldBePublished(10.seconds) { actual.userId == userId } } } } ``` ## Multi-system test ```kotlin test("complete order flow") { stove { val userId = "user-${UUID.randomUUID()}" var orderId: String? = null grpcMock { mockUnary( serviceName = "frauddetection.FraudDetectionService", methodName = "CheckFraud", response = CheckFraudResponse.newBuilder() .setIsFraudulent(false).build() ) } wiremock { mockGet("/inventory/macbook", 200, responseBody = InventoryResponse("macbook", true, 10).some()) mockPost("/payments/charge", 200, responseBody = PaymentResult(true, "txn-123", 2499.99).some()) } http { postAndExpectBody( uri = "/api/orders", body = CreateOrderRequest(userId, "macbook", 2499.99).some() ) { response -> response.status shouldBe 201 orderId = response.body().orderId } } postgresql { shouldQuery( query = "SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> OrderRow(row.string("id"), row.string("user_id"), row.string("product_id"), row.double("amount"), row.string("status")) } ) { orders -> orders.size shouldBe 1 orders.first().status shouldBe "CONFIRMED" } } kafka { shouldBePublished(10.seconds) { actual.userId == userId } } grpc { channel { val response = getOrder( GetOrderRequest.newBuilder().setOrderId(orderId!!).build() ) response.order.status shouldBe "CONFIRMED" } } using { getOrderByUserId(userId)!!.status shouldBe OrderStatus.CONFIRMED } } } ``` ## Anti-patterns | Don't | Do | |---|---| | `Thread.sleep(5000)` | `shouldBePublished(atLeastIn = 10.seconds) { ... }` | | Hardcoded IDs `"order-123"` | `UUID.randomUUID().toString()` | | Shared mutable state | Independent tests with unique data | | Only assert `status shouldBe 200` | Assert response body, DB state, events | | Call real external services | Use WireMock / gRPC Mock | | Configure Stove per test class | Single `AbstractProjectConfig` | ================================================ FILE: .editorconfig ================================================ root = true [*] insert_final_newline = true ktlint_standard_package-name = disabled ktlint_standard_filename = disabled ktlint_standard_no-wildcard-imports = disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_string-template-indent = disabled ktlint_standard_function-signature = disabled [{*.kt,*.kts}] indent_style = space max_line_length = 140 indent_size = 2 ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_continuation_indent_size = 2 ij_kotlin_allow_trailing_comma = false ij_kotlin_allow_trailing_comma_on_call_site = false ij_kotlin_name_count_to_use_star_import = 2 ij_kotlin_name_count_to_use_star_import_for_members = 2 [{**/test/**.kt,**/test-e2e/**.kt,**/test-int/**.kt}] max_line_length = 240 ktlint_standard_no-consecutive-comments = disabled ================================================ FILE: .gitattributes ================================================ # # https://help.github.com/articles/dealing-with-line-endings/ # # Linux start script should use lf /gradlew text eol=lf # These are Windows script files and should use crlf *.bat text eol=crlf ================================================ FILE: .github/workflows/build-jvm-recipes.yml ================================================ name: Build JVM Recipes on: push: branches: [main] paths: - 'recipes/jvm/**' pull_request: branches: [main] paths: - 'recipes/jvm/**' # Cancel in-progress runs for the same branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build-and-test: runs-on: ubuntu-latest permissions: checks: write pull-requests: write services: docker: image: docker:dind options: --privileged steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: temurin java-version: 21 - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: gradle-version: current cache-read-only: false cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - name: Cache Gradle dependencies uses: actions/cache@v5 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-jvm-recipes-${{ runner.os }}-${{ hashFiles('recipes/jvm/**/gradle/wrapper/gradle-wrapper.properties', 'recipes/jvm/**/gradle/libs.versions.toml') }} restore-keys: | gradle-jvm-recipes-${{ runner.os }}- # Cache Docker images for testcontainers - name: Cache Docker images uses: actions/cache@v5 with: path: /var/lib/docker key: docker-jvm-recipes-${{ runner.os }}-${{ hashFiles('recipes/jvm/**/gradle/libs.versions.toml') }} restore-keys: | docker-jvm-recipes-${{ runner.os }}- # Run all tasks in a single Gradle invocation - name: Build, Test, and E2E Test run: | gradle -p recipes/jvm build test e2eTest \ --build-cache \ --no-daemon \ -Dorg.gradle.parallel=true # Upload test results - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: jvm-recipes-test-results path: | recipes/jvm/**/build/test-results/ recipes/jvm/**/build/reports/tests/ retention-days: 7 # Publish test report for PRs - name: Publish Test Report uses: mikepenz/action-junit-report@v6 if: always() && github.event_name == 'pull_request' with: report_paths: "recipes/jvm/**/build/test-results/**/*.xml" check_name: "JVM Recipes Test Results" detailed_summary: true include_passed: false ================================================ FILE: .github/workflows/build-process-recipes.yml ================================================ name: Build Process Recipes on: push: branches: [main] paths: - 'recipes/process/**' pull_request: branches: [main] paths: - 'recipes/process/**' # Cancel in-progress runs for the same branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: go-showcase: runs-on: ubuntu-latest permissions: checks: write pull-requests: write services: docker: image: docker:dind options: --privileged steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: temurin java-version: 21 - uses: actions/setup-go@v6 with: go-version-file: recipes/process/golang/go-showcase/go.mod - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: gradle-version: current cache-read-only: false cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - name: Cache Gradle dependencies uses: actions/cache@v5 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-process-recipes-${{ runner.os }}-${{ hashFiles('recipes/process/**/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | gradle-process-recipes-${{ runner.os }}- # Cache Docker images for testcontainers - name: Cache Docker images uses: actions/cache@v5 with: path: /var/lib/docker key: docker-process-recipes-${{ runner.os }} restore-keys: | docker-process-recipes-${{ runner.os }}- - name: Run E2E Tests (all Kafka libraries) env: GOTOOLCHAIN: auto run: | GO_EXECUTABLE="$(command -v go)" export GO_EXECUTABLE "$GO_EXECUTABLE" version gradle -p recipes/process/golang/go-showcase e2eTest \ --build-cache \ --no-daemon - name: Run Container E2E Tests (sarama) env: GOTOOLCHAIN: auto run: | GO_EXECUTABLE="$(command -v go)" export GO_EXECUTABLE "$GO_EXECUTABLE" version gradle -p recipes/process/golang/go-showcase e2eTest-container \ --build-cache \ --no-daemon && \ gradle -p recipes/process/golang/go-showcase removeContainerImage \ --no-daemon # Upload test results - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: process-recipes-test-results path: | recipes/process/**/build/test-results/ recipes/process/**/build/reports/tests/ retention-days: 7 # Publish test report for PRs - name: Publish Test Report uses: mikepenz/action-junit-report@v6 if: always() && github.event_name == 'pull_request' with: report_paths: "recipes/process/**/build/test-results/**/*.xml" check_name: "Process Recipes Test Results" detailed_summary: true include_passed: false ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: branches: [main] paths: - 'examples/**' - 'lib/**' - 'starters/**' - 'gradle/**' - 'build.gradle.kts' - 'settings.gradle.kts' - 'buildSrc/**' pull_request: branches: [main] paths: - 'examples/**' - 'lib/**' - 'starters/**' - 'gradle/**' - 'build.gradle.kts' - 'settings.gradle.kts' - 'buildSrc/**' # Cancel in-progress runs for the same branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build-and-test: runs-on: ubuntu-latest permissions: checks: write id-token: write issues: write pull-requests: write services: docker: image: docker:dind options: --privileged steps: - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: gradle-version: current cache-read-only: false cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - name: Cache Gradle dependencies uses: actions/cache@v5 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} restore-keys: | gradle-${{ runner.os }}- # Cache Docker images for testcontainers - name: Cache Docker images uses: actions/cache@v5 with: path: /var/lib/docker key: docker-${{ runner.os }}-${{ hashFiles('**/gradle/libs.versions.toml') }} restore-keys: | docker-${{ runner.os }}- # Run all tasks in a single Gradle invocation for optimal build time - name: Build, Test, and Coverage run: | gradle build test koverXmlReport \ --build-cache \ --configuration-cache \ --parallel \ --no-daemon # Upload test results - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: test-results path: | **/build/test-results/test/ **/build/reports/tests/ retention-days: 7 # Upload coverage to Codecov - name: Upload coverage to Codecov if: github.repository == 'Trendyol/stove' uses: codecov/codecov-action@v6 with: fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # Publish test report for PRs - name: Publish Test Report uses: mikepenz/action-junit-report@v6 if: always() && github.event_name == 'pull_request' with: report_paths: "**/build/test-results/test/*.xml" check_name: "Test Results" detailed_summary: true include_passed: false ================================================ FILE: .github/workflows/gradle-publish-release.yml ================================================ name: Release on: workflow_dispatch: jobs: release: runs-on: ubuntu-latest if: github.repository == 'Trendyol/stove' permissions: contents: write packages: write steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up JDK uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' server-id: github settings-path: ${{ github.workspace }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: gradle-version: current - name: Extract version from gradle.properties run: | VERSION=$(grep '^version=' gradle.properties | cut -d'=' -f2) echo "JRELEASER_PROJECT_VERSION=$VERSION" >> $GITHUB_ENV echo "Releasing version: $VERSION" - name: Publish to Maven Central run: gradle --no-configuration-cache publish env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.gpg_private_key }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.gpg_passphrase }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ossrh_username }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ossrh_pass }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create GitHub Release uses: jreleaser/release-action@v2 with: arguments: full-release env: JRELEASER_GITHUB_TOKEN: ${{ secrets.BOT_REPO_TOKEN }} JRELEASER_PROJECT_VERSION: ${{ env.JRELEASER_PROJECT_VERSION }} - name: Tag Go stove-kafka module run: | git tag "go/stove-kafka/v${{ env.JRELEASER_PROJECT_VERSION }}" git push origin "go/stove-kafka/v${{ env.JRELEASER_PROJECT_VERSION }}" env: GITHUB_TOKEN: ${{ secrets.BOT_REPO_TOKEN }} - name: Upload JReleaser output if: always() uses: actions/upload-artifact@v7 with: name: jreleaser-release path: | out/jreleaser/trace.log out/jreleaser/output.properties ================================================ FILE: .github/workflows/gradle-publish-snapshot.yml ================================================ name: Publish to Snapshot Maven on: workflow_dispatch: jobs: publish: runs-on: ubuntu-latest if: github.repository == 'Trendyol/stove' permissions: contents: read packages: write steps: - uses: actions/checkout@v6 - name: Set up JDK uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' server-id: github settings-path: ${{ github.workspace }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: gradle-version: current - name: Publish to Maven Repository run: gradle --no-configuration-cache publish --parallel env: SNAPSHOT: true BUILD_NUMBER: ${{ github.run_number }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.gpg_private_key }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.gpg_passphrase }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ossrh_username }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ossrh_pass }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/publish-to-ghpages.yml ================================================ name: Publish MkDocs to GitHub Pages on: push: branches: [ main ] paths: [ 'docs/**' ] jobs: deploy: runs-on: ubuntu-latest if: github.repository == 'Trendyol/stove' steps: - name: Checkout Repository uses: actions/checkout@v6 - name: Install Dependencies run: | pip install mkdocs mkdocs-material mkdocs-awesome-pages-plugin --use-deprecated=legacy-resolver - name: Build Site run: | mkdocs build - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.BOT_REPO_TOKEN }} publish_dir: ./site ================================================ FILE: .github/workflows/scorecard.yml ================================================ name: Scorecard supply-chain security on: branch_protection_rule: schedule: - cron: '29 23 * * 3' push: branches: [ "main", "master"] pull_request: branches: ["main", "master"] permissions: read-all jobs: visibility-check: outputs: visibility: ${{ steps.drv.outputs.visibility }} runs-on: ubuntu-latest steps: - name: Determine repository visibility id: drv run: | visibility=$(gh api /repos/$GITHUB_REPOSITORY --jq '.visibility') echo "visibility=$visibility" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ github.token }} analysis: if: ${{ needs.visibility-check.outputs.visibility == 'public' }} needs: visibility-check runs-on: ubuntu-latest permissions: security-events: write id-token: write steps: - name: "Checkout code" uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@05bb7c663f6ec9bd8484da0a5b5a77d423e3f88c with: results_file: results.sarif results_format: sarif publish_results: true - name: "Upload artifact" uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/stove-cli-ci.yml ================================================ name: Stove CLI CI on: push: branches: [main] paths: - 'tools/stove-cli/**' - 'lib/stove-dashboard-api/src/main/proto/**' pull_request: branches: [main] paths: - 'tools/stove-cli/**' - 'lib/stove-dashboard-api/src/main/proto/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build-and-test: runs-on: ubuntu-latest defaults: run: working-directory: tools/stove-cli steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: 'npm' cache-dependency-path: tools/stove-cli/spa/package-lock.json - name: Install SPA dependencies run: cd spa && npm ci - name: SPA lint and format check run: cd spa && npx biome check src - name: Build SPA run: cd spa && npm run build - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt - name: Install protoc run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git tools/stove-cli/target key: cargo-${{ runner.os }}-${{ hashFiles('tools/stove-cli/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - name: Check formatting run: cargo fmt -- --check - name: Clippy run: cargo clippy -- -D warnings env: SKIP_SPA_BUILD: "1" - name: Run tests run: cargo test env: SKIP_SPA_BUILD: "1" ================================================ FILE: .github/workflows/stove-cli-release.yml ================================================ name: Stove CLI Build on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Version to release (leave empty to read from gradle.properties)' required: false permissions: contents: write jobs: resolve-version: runs-on: ubuntu-latest outputs: version: ${{ steps.resolve.outputs.version }} tag: ${{ steps.resolve.outputs.tag }} steps: - uses: actions/checkout@v6 - name: Resolve version id: resolve run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then INPUT_VERSION="${{ github.event.inputs.version }}" if [ -n "$INPUT_VERSION" ]; then VERSION="$INPUT_VERSION" else VERSION=$(grep '^version=' gradle.properties | cut -d'=' -f2) fi TAG="v${VERSION}" else TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "Resolved version: ${VERSION} (tag: ${TAG})" build: needs: resolve-version strategy: matrix: include: - target: aarch64-apple-darwin runner: macos-14 label: darwin-arm64 - target: x86_64-apple-darwin runner: macos-14 label: darwin-amd64 - target: x86_64-unknown-linux-gnu runner: ubuntu-latest label: linux-amd64 runs-on: ${{ matrix.runner }} defaults: run: working-directory: tools/stove-cli steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: 'npm' cache-dependency-path: tools/stove-cli/spa/package-lock.json - name: Build SPA run: cd spa && npm ci && npm run build - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Install protoc (Linux) if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - name: Install protoc (macOS) if: runner.os == 'macOS' run: brew install protobuf - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git tools/stove-cli/target key: cargo-release-${{ matrix.target }}-${{ hashFiles('tools/stove-cli/Cargo.lock') }} restore-keys: cargo-release-${{ matrix.target }}- - name: Build release binary run: cargo build --release --target ${{ matrix.target }} env: SKIP_SPA_BUILD: "1" - name: Package tarball run: | VERSION="${{ needs.resolve-version.outputs.version }}" ARCHIVE="stove-${VERSION}-${{ matrix.label }}.tar.gz" mkdir -p staging cp "target/${{ matrix.target }}/release/stove" staging/ cp "${GITHUB_WORKSPACE}/LICENSE" staging/ tar czf "${ARCHIVE}" -C staging . shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" - uses: actions/upload-artifact@v7 with: name: binaries-${{ matrix.label }} path: | tools/stove-cli/stove-*.tar.gz tools/stove-cli/stove-*.sha256 release: needs: [resolve-version, build] runs-on: ubuntu-latest outputs: version: ${{ needs.resolve-version.outputs.version }} steps: - uses: actions/download-artifact@v8 with: pattern: binaries-* merge-multiple: true - name: Upload CLI binaries to release uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.resolve-version.outputs.tag }} name: "Stove v${{ needs.resolve-version.outputs.version }}" files: | stove-*.tar.gz stove-*.sha256 fail_on_unmatched_files: false append_body: true body: | --- ### Stove CLI Install **Homebrew (macOS):** ``` brew install Trendyol/trendyol-tap/stove ``` **Shell script (macOS & Linux):** ``` curl -fsSL https://raw.githubusercontent.com/Trendyol/stove/main/tools/stove-cli/install.sh | sh ``` **Manual:** Download the archive for your platform below, extract, and place `stove` in your PATH. update-homebrew: needs: release runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Generate and push Homebrew formula env: VERSION: ${{ needs.release.outputs.version }} TAP_TOKEN: ${{ secrets.BOT_REPO_TOKEN }} run: | BASE_URL="https://github.com/Trendyol/stove/releases/download/v${VERSION}" SHA_DARWIN_ARM64=$(curl -fsSL "${BASE_URL}/stove-${VERSION}-darwin-arm64.tar.gz.sha256" | awk '{print $1}') SHA_DARWIN_AMD64=$(curl -fsSL "${BASE_URL}/stove-${VERSION}-darwin-amd64.tar.gz.sha256" | awk '{print $1}') SHA_LINUX_AMD64=$(curl -fsSL "${BASE_URL}/stove-${VERSION}-linux-amd64.tar.gz.sha256" | awk '{print $1}') cp tools/stove-cli/Formula/stove.rb stove.rb sed -i "s/__VERSION__/${VERSION}/" stove.rb sed -i "s/__SHA256_DARWIN_ARM64__/${SHA_DARWIN_ARM64}/" stove.rb sed -i "s/__SHA256_DARWIN_AMD64__/${SHA_DARWIN_AMD64}/" stove.rb sed -i "s/__SHA256_LINUX_AMD64__/${SHA_LINUX_AMD64}/" stove.rb WORKDIR="$(mktemp -d)" git clone "https://x-access-token:${TAP_TOKEN}@github.com/Trendyol/homebrew-trendyol-tap.git" "${WORKDIR}" cp stove.rb "${WORKDIR}/stove.rb" cd "${WORKDIR}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add stove.rb git commit -m "stove ${VERSION}" git push ================================================ FILE: .gitignore ================================================ .DS_Store /site /.idea .idea/shelf /confluence/target /dependencies/repo /android.tests.dependencies /dependencies/android.tests.dependencies /dist /local /gh-pages /ideaSDK /clionSDK /android-studio/sdk out/ /tmp /intellij workspace.xml *.versionsBackup /idea/testData/debugger/tinyApp/classes* /jps-plugin/testData/kannotator /js/js.translator/testData/out/ /js/js.translator/testData/out-min/ /js/js.translator/testData/out-pir/ .gradle/ build/ !**/src/**/build !**/test/**/build *.iml !**/testData/**/*.iml .idea/remote-targets.xml .idea/libraries/Gradle*.xml .idea/libraries/Maven*.xml .idea/artifacts/PILL_*.xml .idea/artifacts/KotlinPlugin.xml .idea/modules .idea/runConfigurations/JPS_*.xml .idea/runConfigurations/PILL_*.xml .idea/runConfigurations/_FP_*.xml .idea/runConfigurations/_MT_*.xml .idea/libraries .idea/modules.xml .idea/gradle.xml .idea/compiler.xml .idea/inspectionProfiles/profiles_settings.xml .idea/.name .idea/artifacts/dist_auto_* .idea/artifacts/dist.xml .idea/artifacts/ideaPlugin.xml .idea/artifacts/kotlinc.xml .idea/artifacts/kotlin_compiler_jar.xml .idea/artifacts/kotlin_plugin_jar.xml .idea/artifacts/kotlin_jps_plugin_jar.xml .idea/artifacts/kotlin_daemon_client_jar.xml .idea/artifacts/kotlin_imports_dumper_compiler_plugin_jar.xml .idea/artifacts/kotlin_main_kts_jar.xml .idea/artifacts/kotlin_compiler_client_embeddable_jar.xml .idea/artifacts/kotlin_reflect_jar.xml .idea/artifacts/kotlin_stdlib_js_ir_* .idea/artifacts/kotlin_test_js_ir_* .idea/artifacts/kotlin_stdlib_wasm_* .idea/artifacts/kotlinx_atomicfu_runtime_* .idea/artifacts/kotlinx_cli_jvm_* .idea/jarRepositories.xml .idea/csv-plugin.xml .idea/libraries-with-intellij-classes.xml .idea/misc.xml .idea/** node_modules/ .rpt2_cache/ libraries/tools/kotlin-test-js-runner/lib/ local.properties buildSrcTmp/ distTmp/ outTmp/ /test.output /kotlin-native/dist kotlin-ide/ **/bin/**/* # Ignore Gradle project-specific cache directory .gradle # Ignore Gradle build output directory build .kotlin *.tsbuildinfo .junie ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

Stove

End-to-end testing framework for the JVM.
Test your application against real infrastructure with a unified Kotlin DSL.

Release Homebrew Snapshot codecov OpenSSF Scorecard

```kotlin stove { // Call API and verify response http { postAndExpectBodilessResponse("/orders", body = CreateOrderRequest(userId, productId).some()) { it.status shouldBe 201 } } // Verify database state postgresql { shouldQuery("SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> Order(row.string("status")) }) { it.first().status shouldBe "CONFIRMED" } } // Verify event was published kafka { shouldBePublished { actual.userId == userId } } // Access application beans directly using { getStock(productId) shouldBe 9 } } ``` ## Why Stove? The JVM ecosystem has excellent frameworks for building applications, but e2e testing remains fragmented. Testcontainers handles infrastructure, but you still write boilerplate for configuration, app startup, and assertions. Differently for each framework. Stove explores how the testing experience on the JVM can be improved by unifying assertions and the supporting infrastructure. It creates a concise and expressive testing DSL by leveraging Kotlin's unique language features. Stove works with Java, Kotlin, and Scala applications across Spring Boot, Ktor, Micronaut, and Quarkus. Because tests are framework-agnostic, teams can migrate between stacks without rewriting test code. It empowers developers to write clear assertions even for code that is traditionally hard to test (async flows, message consumers, database side effects). **What Stove does:** - Starts containers via Testcontainers or connect **provided** infra (PostgreSQL, MySQL, Kafka, etc.) - Launches your **actual** application with test configuration - Exposes a unified DSL for assertions across all components - Provides access to your DI container from tests - Debug your entire use case with one click (breakpoints work everywhere) - Get code coverage from e2e test execution - Supports Spring Boot, Ktor, Micronaut, Quarkus - Extensible architecture for adding new components and frameworks ([Writing Custom Systems](https://trendyol.github.io/stove/writing-custom-systems/)) ## Dashboard (New in 0.23.0) Stove Dashboard introduces a local real-time dashboard for end-to-end test runs. It captures HTTP calls, Kafka activity, database assertions, and traces in one place so you can inspect successful and failed runs with full context. https://github.com/user-attachments/assets/14597dc6-e9d4-43ab-8cfa-578ab3c3e6df **Quick start** ```bash # 1) Install and start the Dashboard CLI brew install Trendyol/trendyol-tap/stove stove # 2) Run your tests and open the dashboard ./gradlew test # http://localhost:4040 ``` ```kotlin // build.gradle.kts plugins { id("com.trendyol.stove.tracing") version "$stoveVersion" } dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-extensions-kotest") // or stove-extensions-junit testImplementation("com.trendyol:stove-dashboard") testImplementation("com.trendyol:stove-tracing") } stoveTracing { serviceName.set("product-api") } ``` ```kotlin // Kotest class StoveConfig : AbstractProjectConfig() { override val extensions = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove().with { dashboard { DashboardSystemOptions(appName = "product-api") } tracing { enableSpanReceiver() } // recommended }.run() } override suspend fun afterProject() = Stove.stop() } // JUnit @ExtendWith(StoveJUnitExtension::class) abstract class BaseE2ETest { /* Stove().with { ... }.run() in @BeforeAll */ } ``` Keep `stove-cli`, the Stove BOM, the tracing Gradle plugin, and your Stove test dependencies on the same Stove version. The dashboard warns on version mismatches, but aligning versions avoids missing or inconsistent dashboard data. See [Dashboard docs](https://trendyol.github.io/stove/Components/18-dashboard/) and [0.23.0 release notes](https://trendyol.github.io/stove/release-notes/0.23.0/) for full details. ## Getting Started **1. Add dependencies** ```kotlin dependencies { // Import BOM for version management testImplementation(platform("com.trendyol:stove-bom:$version")) // Core and framework starter testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") // or stove-ktor, stove-micronaut, stove-quarkus // Component modules testImplementation("com.trendyol:stove-postgres") testImplementation("com.trendyol:stove-mysql") testImplementation("com.trendyol:stove-kafka") } ``` > **Snapshots:** As of 5th June 2025, Stove's snapshot packages are hosted on [Central Sonatype](https://central.sonatype.com/service/rest/repository/browse/maven-snapshots/com/trendyol/). > ```kotlin > repositories { > maven("https://central.sonatype.com/repository/maven-snapshots") > } > ``` **2. Configure Stove** (runs once before all tests) ```kotlin class StoveConfig : AbstractProjectConfig() { override suspend fun beforeProject() = Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } postgresql { PostgresqlOptions( cleanup = { it.execute("TRUNCATE orders, users") }, configureExposedConfiguration = { listOf("spring.datasource.url=${it.jdbcUrl}") } ).migrations { register() } } kafka { KafkaSystemOptions( cleanup = { it.deleteTopics(listOf("orders")) }, configureExposedConfiguration = { listOf("kafka.bootstrapServers=${it.bootstrapServers}") } ).migrations { register() } } bridge() springBoot(runner = { params -> myApp.run(params) { addTestDependencies() } }) }.run() override suspend fun afterProject() = Stove.stop() } ``` **3. Write tests** ```kotlin test("should process order") { stove { http { get("/orders/123") { it.status shouldBe "CONFIRMED" } } postgresql { shouldQuery("SELECT * FROM orders", mapper = { row -> Order(row.string("status")) }) { it.size shouldBe 1 } } kafka { shouldBePublished { actual.orderId == "123" } } } } ``` ## Writing Tests All assertions happen inside `stove { }`. Each component has its own DSL block. ### HTTP ```kotlin http { get("/users/$id") { it.name shouldBe "John" } postAndExpectBodilessResponse("/users", body = request.some()) { it.status shouldBe 201 } postAndExpectBody("/users", body = request.some()) { it.id shouldNotBe null } } ``` ### Database ```kotlin postgresql { // also: mysql, mongodb, couchbase, mssql, elasticsearch, redis shouldExecute("INSERT INTO users (name) VALUES ('Jane')") shouldQuery("SELECT * FROM users", mapper = { row -> User(row.string("name")) }) { it.size shouldBe 1 } } ``` ### Kafka ```kotlin kafka { publish("orders.created", OrderCreatedEvent(orderId = "123")) shouldBeConsumed { actual.orderId == "123" } shouldBePublished { actual.orderId == "123" } } ``` ### External API Mocking ```kotlin wiremock { mockGet("/external-api/users/1", responseBody = User(id = 1, name = "John").some()) mockPost("/external-api/notify", statusCode = 202) } ``` ### Application Beans Access your DI container directly via `bridge()`: ```kotlin using { processOrder(orderId) } using { userRepo, emailService -> userRepo.findById(id) shouldNotBe null } ``` ### Reporting When tests fail, Stove automatically enriches exceptions with a detailed execution report showing exactly what happened:
Example Report ``` ╔══════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ STOVE TEST EXECUTION REPORT ║ ║ ║ ║ Test: should create new product when send product create request from api for the allowed ║ ║ supplier ║ ║ ID: ExampleTest::should create new product when send product create request from api for the ║ ║ allowed supplier ║ ║ Status: FAILED ║ ╠══════════════════════════════════════════════════════════════════════════════════════════════════╣ ║ ║ ║ TIMELINE ║ ║ ──────── ║ ║ ║ ║ 12:41:12.371 ✓ PASSED [WireMock] Register stub: GET /suppliers/99/allowed ║ ║ Output: kotlin.Unit ║ ║ Metadata: {statusCode=200, responseHeaders={}} ║ ║ ║ ║ 12:41:13.405 ✓ PASSED [HTTP] POST /api/product/create ║ ║ Input: ProductCreateRequest(id=1, name=product name, supplierId=99) ║ ║ Output: kotlin.Unit ║ ║ Metadata: {status=200, headers={}} ║ ║ ║ ║ 12:41:13.424 ✓ PASSED [Kafka] shouldBePublished ║ ║ Output: ProductCreatedEvent(id=1, name=product name, supplierId=99, createdDate=Thu Jan 08 ║ ║ 12:41:12 CET 2026, type=ProductCreatedEvent) ║ ║ Metadata: {timeout=5s} ║ ║ ║ ║ 12:41:13.455 ✗ FAILED [Couchbase] Get document ║ ║ Input: {id=product:1} ║ ║ Error: expected:<100L> but was:<99L> ║ ║ ║ ╠══════════════════════════════════════════════════════════════════════════════════════════════════╣ ║ ║ ║ SYSTEM SNAPSHOTS ║ ║ ──────────────── ║ ║ ║ ║ ┌─ HTTP ──────────────────────────────────────────────────────────────────────────────────────── ║ ║ ║ ║ No detailed state available ║ ║ ║ ║ ┌─ COUCHBASE ─────────────────────────────────────────────────────────────────────────────────── ║ ║ ║ ║ No detailed state available ║ ║ ║ ║ ┌─ KAFKA ─────────────────────────────────────────────────────────────────────────────────────── ║ ║ ║ ║ Consumed: 0 ║ ║ Published: 1 ║ ║ Committed: 0 ║ ║ ║ ║ State Details: ║ ║ consumed: 0 item(s) ║ ║ published: 1 item(s) ║ ║ [0] ║ ║ id: 376db940-a367-4419-a628-4754c9466421 ║ ║ topic: stove-standalone-example.productCreated.1 ║ ║ key: 1 ║ ║ headers: {X-EventType=ProductCreatedEvent, X-MessageId=29902970-056d-4ae9-9a84-...} ║ ║ message: {"id":1,"name":"product name","supplierId":99,...} ║ ║ committed: 0 item(s) ║ ║ ║ ║ ┌─ WIREMOCK ──────────────────────────────────────────────────────────────────────────────────── ║ ║ ║ ║ Registered stubs: 0 ║ ║ Served requests: 0 (matched: 0) ║ ║ Unmatched requests: 0 ║ ║ ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════╝ ```
**Features:** - Timeline of all operations with timestamps and results - Input/output for each action - Expected vs actual values on failures - System snapshots (Kafka messages, WireMock stubs, etc.) **Test Framework Extensions:** Use the provided extensions to automatically enrich failures: ```kotlin // Kotest - register in project config class StoveConfig : AbstractProjectConfig() { override val extensions = listOf(StoveKotestExtension()) } // JUnit 5 - annotate test class @ExtendWith(StoveJUnitExtension::class) class MyTest { ... } ``` **Configuration:** ```kotlin Stove( StoveOptions( reportingEnabled = true, // Enable/disable reporting (default: true) dumpReportOnTestFailure = true, // Enrich failures with report (default: true) failureRenderer = PrettyConsoleRenderer // Custom renderer (default: PrettyConsoleRenderer) ) ).with { ... } ``` ### Tracing When a test fails, see the **entire execution call chain** inside your application — every controller, service, database query, and Kafka message — powered by OpenTelemetry: ``` EXECUTION TRACE (Call Chain) ═══════════════════════════════════════════════════════════════════ ✓ POST (377ms) ✓ POST /api/product/create (361ms) ✓ ProductController.create (141ms) ✓ ProductCreator.create (0ms) ✓ KafkaProducer.send (137ms) ✓ orders.created publish (81ms) ✗ orders.created process (82ms) ← FAILURE POINT ``` **Setup** (two steps): ```kotlin // 1. In your Stove config tracing { enableSpanReceiver() } // 2. In build.gradle.kts plugins { id("com.trendyol.stove.tracing") version "$stoveVersion" } stoveTracing { serviceName.set("my-service") } ``` **Validate traces in tests:** ```kotlin tracing { shouldContainSpan("OrderService.processOrder") shouldNotHaveFailedSpans() executionTimeShouldBeLessThan(500.milliseconds) } ``` No code changes to your application required. The OpenTelemetry agent instruments 100+ libraries automatically. ### AI Agent Integration Stove's execution reports and tracing data are structured and deterministic, making them ideal for **AI agent workflows**. When an AI agent runs e2e tests during implementation, it can parse the failure reports — including the full execution trace, system snapshots, and timeline — to understand exactly what went wrong inside the application. This enables agents to iterate on fixes with precise feedback rather than guessing from opaque test failures. When `stove` is running, it also exposes a local read-only MCP endpoint at `http://localhost:4040/mcp`. Agents can call `stove_failures` first, then drill into a specific `run_id + test_id` for timeline, trace, and snapshot evidence. MCP is optional: if it is unavailable or incomplete, agents should fall back to normal test output, Stove failure reports, and logs. **Agent Skills:** Stove ships with a ready-to-use [Claude Code skill](https://github.com/Trendyol/stove/tree/main/.claude/skills/stove) that teaches AI agents how to set up and write Stove e2e tests. Copy the `.claude/skills/stove/` directory into your project's `.claude/skills/` folder, and your AI coding agent will know how to configure systems, write tests, enable tracing, and build custom systems — following all Stove conventions automatically. ## Configuration ### Framework Setup
Spring BootKtor
```kotlin springBoot( runner = { params -> myApp.run(params) { addTestDependencies() } } ) ``` ```kotlin ktor( runner = { params -> run(params, shouldWait = false) } ) ```
MicronautQuarkus
```kotlin micronaut( runner = { params -> myApp.run(params) } ) ``` ```kotlin quarkus( runner = { params -> MyApp.main(params) } ) ```
### Container Reuse Speed up local development by keeping containers running between test runs: ```kotlin Stove { keepDependenciesRunning() }.with { ... } ``` ### Cleanup Run cleanup logic after tests complete: ```kotlin postgresql { PostgresqlOptions(cleanup = { it.execute("TRUNCATE users") }, ...) } kafka { KafkaSystemOptions(cleanup = { it.deleteTopics(listOf("test-topic")) }, ...) } ``` Available for Kafka, PostgreSQL, MySQL, MongoDB, Couchbase, Cassandra, MSSQL, Elasticsearch, Redis. ### Migrations Run database migrations before tests start: ```kotlin postgresql { PostgresqlOptions(...) .migrations { register() register() } } ``` Available for Kafka, PostgreSQL, MySQL, MongoDB, Couchbase, Cassandra, MSSQL, Elasticsearch, Redis. ### Provided Instances Connect to existing infrastructure instead of starting containers (useful for CI/CD): ```kotlin postgresql { PostgresqlOptions.provided(jdbcUrl = "jdbc:postgresql://ci-db:5432/test", ...) } kafka { KafkaSystemOptions.provided(bootstrapServers = "ci-kafka:9092", ...) } ``` > **Tip:** When using provided instances, use migrations to create isolated test schemas and cleanups to remove test > data afterwards. This ensures test isolation on shared infrastructure. Complete Example ```kotlin test("should create order with payment processing") { stove { val userId = UUID.randomUUID().toString() val productId = UUID.randomUUID().toString() // 1. Seed database postgresql { shouldExecute("INSERT INTO users (id, name) VALUES ('$userId', 'John')") shouldExecute("INSERT INTO products (id, price, stock) VALUES ('$productId', 99.99, 10)") } // 2. Mock external payment API wiremock { mockPost( "/payments/charge", statusCode = 200, responseBody = PaymentResult(success = true).some() ) } // 3. Call API http { postAndExpectBody( "/orders", body = CreateOrderRequest(userId, productId).some() ) { it.status shouldBe 201 } } // 4. Verify database postgresql { shouldQuery("SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> Order(row.string("status")) }) { it.first().status shouldBe "CONFIRMED" } } // 5. Verify event published kafka { shouldBePublished { actual.userId == userId } } // 6. Verify via application service using { getStock(productId) shouldBe 9 } } } ``` ## Reference ### Supported Components | Category | Components | |------------|-------------------------------------------------------------| | Databases | PostgreSQL, MySQL, MongoDB, Couchbase, Cassandra, MSSQL, Elasticsearch, Redis | | Messaging | Kafka | | HTTP | Built-in client, WebSockets, WireMock | | gRPC | Client (grpc-kotlin), Mock Server (native) | | Frameworks | Spring Boot, Ktor, Micronaut, Quarkus | ### Feature Matrix | Component | Migrations | Cleanup | Provided Instance | Pause/Unpause | |---------------|:----------:|:-------:|:-----------------:|:-------------:| | PostgreSQL | ✅ | ✅ | ✅ | ✅ | | MySQL | ✅ | ✅ | ✅ | ✅ | | MSSQL | ✅ | ✅ | ✅ | ✅ | | MongoDB | ✅ | ✅ | ✅ | ✅ | | Couchbase | ✅ | ✅ | ✅ | ✅ | | Cassandra | ✅ | ✅ | ✅ | ✅ | | Elasticsearch | ✅ | ✅ | ✅ | ✅ | | Redis | ✅ | ✅ | ✅ | ✅ | | Kafka | ✅ | ✅ | ✅ | ✅ | | WireMock | n/a | n/a | n/a | n/a | | HTTP Client | n/a | n/a | n/a | n/a | | gRPC Mock | n/a | n/a | n/a | n/a |
FAQ **Can I use Stove with Java applications?** Yes. Your application can be Java, Scala, or any JVM language. Tests are written in Kotlin for the DSL. **Does Stove replace Testcontainers?** No. Stove uses Testcontainers underneath and adds the unified DSL on top. **How slow is the first run?** First run pulls Docker images (~1-2 min). Use `keepDependenciesRunning()` for instant subsequent runs. **Can I run tests in parallel?** Yes, with unique test data per test. See [provided instances docs](https://trendyol.github.io/stove/Components/11-provided-instances/).
## Resources - **[Documentation](https://trendyol.github.io/stove/)**: Full guides and API reference - **[Examples](https://github.com/Trendyol/stove/tree/main/examples)**: Working sample projects - **[AI Agent Skill](https://github.com/Trendyol/stove/tree/main/.claude/skills/stove)**: Drop into `.claude/skills/` to teach AI agents Stove conventions - **[Blog Post](https://medium.com/trendyol-tech/a-new-approach-to-the-api-end-to-end-testing-in-kotlin-f743fd1901f5)**: Motivation and design decisions - **[Video Walkthrough](https://youtu.be/DJ0CI5cBanc?t=669)**: Live demo (Turkish) ## Community **Used by:** 1. [Trendyol](https://www.trendyol.com): Leading e-commerce platform, Turkey *Using Stove? Open a PR to add your company.* **Contributions:** [Issues](https://github.com/Trendyol/stove/issues) and PRs welcome **License:** Apache 2.0 > **Note:** Production-ready and used at scale. API still evolving; breaking changes possible in minor releases with > migration guides. ================================================ FILE: api/stove.api ================================================ ================================================ FILE: build.gradle.kts ================================================ import org.gradle.plugins.ide.idea.model.IdeaModel import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { kotlin("jvm").version(libs.versions.kotlin) alias(libs.plugins.spotless) alias(libs.plugins.testLogger) alias(libs.plugins.kover) alias(libs.plugins.detekt) alias(libs.plugins.binaryCompatibilityValidator) alias(libs.plugins.maven.publish) idea java } group = "com.trendyol" version = CI.version(project) apiValidation { ignoredProjects += listOf( "ktor-example", "micronaut-example", "spring-example", "spring-4x-example", "spring-standalone-example", "spring-streams-example", "tests", "spring-test-fixtures", "spring-2x-kafka-tests", "spring-3x-kafka-tests", "spring-4x-kafka-tests", "spring-4x-tests", "spring-3x-tests", "spring-2x-tests", "quarkus-example", "ktor-di-tests", "ktor-koin-tests", "ktor-test-fixtures", "stove-tracing-gradle-plugin", "stove-dashboard-api", "stove-dashboard", ) } kover { reports { filters { excludes { classes( "com.trendyol.stove.functional.*", "com.trendyol.stove.system.abstractions.*", "com.trendyol.stove.system.annotations.*", "com.trendyol.stove.serialization.*", "stove.spring.example.*", "stove.spring.standalone.example.*", "stove.spring.streams.example.*", "stove.ktor.example.*", "stove.quarkus.example.*", "stove.micronaut.example.*", ) } } } } val related = subprojects.of("lib", "spring", "examples", "ktor", "quarkus", "micronaut", "container", "process", "tests", "test-extensions", except = listOf("stove-bom")) dependencies { related.forEach { kover(it) } } subprojects.of("lib", "spring", "examples", "ktor", "quarkus", "micronaut", "container", "process", "tests", "test-extensions", except = listOf("stove-bom")) { apply { plugin("kotlin") plugin(rootProject.libs.plugins.spotless.get().pluginId) plugin(rootProject.libs.plugins.testLogger.get().pluginId) plugin(rootProject.libs.plugins.kover.get().pluginId) plugin(rootProject.libs.plugins.detekt.get().pluginId) plugin("idea") } val testImplementation by configurations val libs = rootProject.libs detekt { buildUponDefaultConfig = true parallel = true config.from(rootProject.file("detekt.yml")) } dependencies { testImplementation(kotlin("test")) testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.framework.engine) testImplementation(libs.kotest.assertions.core) detektPlugins(libs.detekt.formatting) } spotless { kotlin { ktlint(libs.ktlint.cli.get().version) .setEditorConfigPath(rootProject.layout.projectDirectory.file(".editorconfig")) .editorConfigOverride( mapOf( "ktlint_standard_kdoc" to "disabled", "ktlint_standard_class-signature" to "disabled" ) ) targetExclude("build/", "generated/", "out/") targetExcludeIfContentContains("generated") targetExcludeIfContentContainsRegex("generated.*") } kotlinGradle { ktlint(libs.ktlint.cli.get().version) .setEditorConfigPath(rootProject.layout.projectDirectory.file(".editorconfig")) .editorConfigOverride( mapOf( "ktlint_standard_kdoc" to "disabled", "ktlint_standard_class-signature" to "disabled" ) ) targetExclude("build/", "generated/", "out/") } } the().apply { module { isDownloadSources = true isDownloadJavadoc = true } } tasks { test { useJUnitPlatform() // Fail fast on CI to save time failFast = runningOnCI testlogger { setTheme("mocha") showStandardStreams = !runningOnCI showExceptions = true showCauses = true } reports { junitXml.required.set(true) } jvmArgs("--add-opens", "java.base/java.util=ALL-UNNAMED") } kotlin { jvmToolchain(17) compilerOptions { jvmTarget.set(JvmTarget.JVM_17) allWarningsAsErrors = true freeCompilerArgs.addAll("-Xjsr305=strict") } } } } val publishedProjects = listOf( projects.lib.stoveBom.name, projects.lib.stove.name, projects.lib.stoveTracing.name, projects.lib.stoveCouchbase.name, projects.lib.stoveElasticsearch.name, projects.lib.stoveGrpc.name, projects.lib.stoveGrpcMock.name, projects.lib.stoveHttp.name, projects.lib.stoveKafka.name, projects.lib.stoveMongodb.name, projects.lib.stoveRdbms.name, projects.lib.stovePostgres.name, projects.lib.stoveMysql.name, projects.lib.stoveMssql.name, projects.lib.stoveWiremock.name, projects.lib.stoveRedis.name, projects.lib.stoveCassandra.name, projects.lib.stoveDashboard.name, projects.lib.stoveDashboardApi.name, projects.starters.ktor.stoveKtor.name, projects.starters.quarkus.stoveQuarkus.name, projects.starters.spring.stoveSpring.name, projects.starters.spring.stoveSpringKafka.name, projects.starters.micronaut.stoveMicronaut.name, projects.starters.container.stoveContainer.name, projects.starters.process.stoveProcess.name, projects.testExtensions.stoveExtensionsKotest.name, projects.testExtensions.stoveExtensionsJunit.name, projects.plugins.stoveTracingGradlePlugin.name, ) subprojects.of("lib", "spring", "ktor", "quarkus", "micronaut", "container", "process", "test-extensions", "plugins", filter = { p -> publishedProjects.contains(p.name) && p.name != "stove-bom" }) { apply { plugin("java") plugin(rootProject.libs.plugins.maven.publish.pluginId) } mavenPublishing { coordinates(groupId = rootProject.group.toString(), artifactId = project.name, version = rootProject.version.toString()) publishToMavenCentral() pom { name.set(project.name) description.set(project.properties["projectDescription"].toString()) url.set(project.properties["projectUrl"].toString()) licenses { license { name.set(project.properties["licence"].toString()) url.set(project.properties["licenceUrl"].toString()) } } developers { developer { id.set("osoykan") name.set("Oguzhan Soykan") email.set("oguzhan.soykan@trendyol.com") } } scm { connection.set("scm:git@github.com:Trendyol/stove.git") developerConnection.set("scm:git:ssh://github.com:Trendyol/stove.git") url.set(project.properties["projectUrl"].toString()) } } if (project.hasSigningKey) signAllPublications() } java { withSourcesJar() } } ================================================ FILE: buildSrc/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { mavenCentral() google() gradlePluginPortal() } ================================================ FILE: buildSrc/settings.gradle.kts ================================================ rootProject.name = "buildSrc" dependencyResolutionManagement { versionCatalogs { create("libs").from(files("../gradle/libs.versions.toml")) } } enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") ================================================ FILE: buildSrc/src/main/kotlin/BuildConstants.kt ================================================ object TestFolders { const val e2e = "test-e2e" } ================================================ FILE: buildSrc/src/main/kotlin/CI.kt ================================================ import org.gradle.api.Project val Project.hasSigningKey: Boolean get() = rootProject.findProperty("signing.keyId") != null || rootProject.findProperty("signingInMemoryKey") != null || System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKey") != null object CI { private val isSnapshot: Boolean get() = System.getenv("SNAPSHOT") != null && System.getenv("SNAPSHOT") == "true" private val Project.snapshotBase: String get() = properties["snapshot"].toString() private val Project.releaseVersion: String get() = properties["version"].toString() private val buildNumber: String get() = System.getenv("BUILD_NUMBER") ?: "0" fun version(project: Project): String = when { isSnapshot -> "${project.snapshotBase}.${buildNumber}-SNAPSHOT" else -> project.properties["version"].toString() } } ================================================ FILE: buildSrc/src/main/kotlin/GenerateDashboardVersionSourceTask.kt ================================================ import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction abstract class GenerateDashboardVersionSourceTask : DefaultTask() { @get:Input abstract val stoveCompatibilityVersion: Property @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction fun generate() { val outputFile = outputDir .file("com/trendyol/stove/dashboard/StoveCompatibilityVersion.kt") .get() .asFile outputFile.parentFile.mkdirs() outputFile.writeText( """ package com.trendyol.stove.dashboard internal object StoveCompatibilityVersion { const val VALUE: String = "${stoveCompatibilityVersion.get()}" } """.trimIndent() ) } } ================================================ FILE: buildSrc/src/main/kotlin/Helpers.kt ================================================ import org.gradle.api.* import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.kotlin.dsl.invoke import org.gradle.plugin.use.PluginDependency fun Collection.of( vararg parentProjects: String, except: List = emptyList(), action: Action ): Unit = this.filter { parentProjects.contains(it.parent?.name) && !except.contains(it.name) }.forEach { action(it) } fun Collection.of( vararg parentProjects: String, except: List = emptyList() ): List = this.filter { parentProjects.contains(it.parent?.name) && !except.contains(it.name) } fun Collection.of( vararg parentProjects: String, action: Action ): Unit = this.filter { parentProjects.contains(it.parent?.name) }.forEach { action(it) } fun Collection.of( vararg parentProjects: String, filter: (Project) -> Boolean, action: Action ): Unit = this.filter { parentProjects.contains(it.parent?.name) && filter(it) }.forEach { action(it) } val runningOnCI: Boolean get() = System.getenv("CI") != null || System.getenv("GITHUB_ACTIONS") != null || System.getenv("GITLAB_CI") != null || System.getenv("CIRCLECI") != null || System.getenv("TRAVIS") != null || System.getenv("TEAMCITY_VERSION") != null || System.getenv("JENKINS_URL") != null val Provider.pluginId: String get() = get().pluginId ================================================ FILE: buildSrc/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingConfiguration.kt ================================================ @file:Suppress("TooManyFunctions") package com.trendyol.stove.gradle import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.tasks.testing.Test import java.net.ServerSocket /* * ════════════════════════════════════════════════════════════════════════════════ * STOVE TRACING CONFIGURATION (buildSrc version) * ════════════════════════════════════════════════════════════════════════════════ * * PREFERRED APPROACH: Use the Gradle plugin instead of copying this file. * * plugins { * id("com.trendyol.stove.tracing") version "" * } * * stoveTracing { * serviceName.set("my-service") * } * * The plugin is available on Maven Central: com.trendyol:stove-tracing-gradle-plugin * * Add mavenCentral() to your pluginManagement repositories. * See: https://github.com/Trendyol/stove * * ──────────────────────────────────────────────────────────────────────── * LEGACY: buildSrc copy-paste approach (kept for internal Stove usage) * ──────────────────────────────────────────────────────────────────────── * * This file configures the OpenTelemetry Java Agent for Stove test tracing. * When a test fails, Stove can display the execution trace showing exactly * what happened during the test - HTTP calls, Kafka messages, database queries, etc. * * HOW TO USE IN YOUR PROJECT: * ─────────────────────────── * 1. Copy this file to your project's buildSrc/src/main/kotlin/ directory * (create the directory structure if it doesn't exist) * * 2. In your test module's build.gradle.kts, add: * * import com.trendyol.stove.gradle.stoveTracing * * stoveTracing { * serviceName = "my-service" * } * * 3. In your Stove test setup, enable tracing: * * Stove(...) * .with { * tracing { * enableSpanReceiver() // Port is auto-configured from STOVE_TRACING_PORT env var * } * // ... other systems * } * * Note: Service name is automatically extracted from incoming spans (set by OTel agent) * * CONFIGURATION OPTIONS: * ────────────────────── * - serviceName: The service name shown in traces (required) * - enabled: Toggle tracing on/off (default: true) * - protocol: grpc (default and currently the only supported protocol) * - testTaskNames: Apply only to specific tasks (default: all test tasks) * - otelAgentVersion: OTel agent version (default: 2.24.0) * * Note: The OTLP port is dynamically assigned to avoid conflicts when running * parallel tests. The port is passed to tests via STOVE_TRACING_PORT env var. * * ADVANCED USAGE: * ─────────────── * // Apply only to integration tests * stoveTracing { * serviceName = "my-service" * testTaskNames = listOf("integrationTest") * } * * // Custom OTel agent version * stoveTracing { * serviceName = "my-service" * otelAgentVersion = "2.25.0" * } * * // Disable specific instrumentations * stoveTracing { * serviceName = "my-service" * disabledInstrumentations = listOf("jdbc", "hibernate") * } * * ════════════════════════════════════════════════════════════════════════════════ */ /** * Constants for Stove tracing configuration. */ private object TracingDefaults { const val DEFAULT_BSP_SCHEDULE_DELAY = 100 const val DEFAULT_BSP_MAX_BATCH_SIZE = 1 const val DEFAULT_OTEL_AGENT_VERSION = "2.24.0" const val DEFAULT_PROTOCOL = "grpc" const val SUPPORTED_PROTOCOL = DEFAULT_PROTOCOL /** Environment variable name for passing the OTLP port to tests */ const val STOVE_TRACING_PORT_ENV = "STOVE_TRACING_PORT" } /** * Configures Stove tracing for a Gradle project. * * This function provides a simple way to set up OpenTelemetry Java Agent * for test tracing without needing to apply a plugin. * * Example usage in build.gradle.kts: * ```kotlin * import com.trendyol.stove.gradle.stoveTracing * * stoveTracing { * serviceName = "my-service" * } * ``` * * To configure only specific test tasks: * ```kotlin * stoveTracing { * serviceName = "my-service" * testTaskNames = listOf("integrationTest") // Only apply to integrationTest task * } * ``` */ fun Project.stoveTracing(configure: StoveTracingConfig.() -> Unit = {}) { val config = StoveTracingConfig().apply(configure) validateProtocol(config.protocol) // Create otelAgent configuration val otelAgentConfig = configurations.create("otelAgent").apply { isTransitive = false isCanBeResolved = true isCanBeConsumed = false description = "OpenTelemetry Java Agent for Stove test tracing" } // Add OTel agent dependency after evaluation afterEvaluate { if (!config.enabled) { logger.info("Stove tracing is disabled, skipping configuration") return@afterEvaluate } dependencies.add( "otelAgent", "io.opentelemetry.javaagent:opentelemetry-javaagent:${config.otelAgentVersion}" ) // Configure test tasks val testTasks = resolveTestTasks(config) testTasks.forEach { testTask -> configureTestTask(testTask, otelAgentConfig, config) } logConfiguration(config, testTasks) } } private fun validateProtocol(protocol: String) { require(protocol == TracingDefaults.SUPPORTED_PROTOCOL) { "Unsupported OTLP protocol '$protocol'. Stove tracing receiver currently supports only " + "'${TracingDefaults.SUPPORTED_PROTOCOL}'." } } private fun Project.resolveTestTasks(config: StoveTracingConfig): List = if (config.testTaskNames.isEmpty()) { tasks.withType(Test::class.java).toList() } else { config.testTaskNames.mapNotNull { taskName -> tasks.findByName(taskName) as? Test } } private fun Project.logConfiguration(config: StoveTracingConfig, testTasks: List) { val taskInfo = if (config.testTaskNames.isEmpty()) { "all test tasks" } else { "tasks: ${testTasks.joinToString(", ") { it.name }}" } logger.info( "Stove tracing configured for service '${config.serviceName}' " + "with dynamic port assignment on $taskInfo" ) } private fun configureTestTask( testTask: Test, otelAgentConfig: Configuration, config: StoveTracingConfig ) { // Resolve at configuration time to avoid capturing Configuration in doFirst // This is required for Gradle configuration cache compatibility val resolvedAgentPath: String? = otelAgentConfig.resolve().firstOrNull()?.absolutePath // Extract config values to a serializable format for configuration cache compatibility val tracingConfig = ResolvedTracingConfig.from(config) testTask.doFirst { if (resolvedAgentPath == null) { testTask.logger.warn("No OTel agent JAR found in otelAgent configuration") return@doFirst } // Find an available port dynamically to avoid conflicts when running multiple test tasks val port = findAvailablePort() testTask.environment(TracingDefaults.STOVE_TRACING_PORT_ENV, port.toString()) val jvmArgs = buildJvmArgs(resolvedAgentPath, tracingConfig, port) testTask.jvmArgs(jvmArgs) testTask.logger.info( "Stove tracing: Attached OTel agent on port {} with {} JVM arguments", port, jvmArgs.size ) } } private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } /** * Serializable copy of tracing config for Gradle configuration cache compatibility. * Configuration cache requires all objects captured in task actions to be serializable. */ private data class ResolvedTracingConfig( val protocol: String, val serviceName: String, val bspScheduleDelay: Int, val bspMaxBatchSize: Int, val captureHttpHeaders: Boolean, val captureExperimentalTelemetry: Boolean, val customAnnotations: List, val disabledInstrumentations: List, val additionalInstrumentations: List ) : java.io.Serializable { companion object { private const val serialVersionUID: Long = 1L fun from(config: StoveTracingConfig) = ResolvedTracingConfig( protocol = config.protocol, serviceName = config.serviceName, bspScheduleDelay = config.bspScheduleDelay, bspMaxBatchSize = config.bspMaxBatchSize, captureHttpHeaders = config.captureHttpHeaders, captureExperimentalTelemetry = config.captureExperimentalTelemetry, customAnnotations = config.customAnnotations.toList(), disabledInstrumentations = config.disabledInstrumentations.toList(), additionalInstrumentations = config.additionalInstrumentations.toList() ) } } private fun buildJvmArgs(agentPath: String, config: ResolvedTracingConfig, port: Int): List = buildList { // Agent attachment add("-javaagent:$agentPath") // Core export configuration addAll(buildCoreExportArgs(config, port)) // Propagation add("-Dotel.propagators=tracecontext,baggage") // Test optimization addAll(buildTestOptimizationArgs(config)) // HTTP headers capture if (config.captureHttpHeaders) { addAll(buildHttpHeaderCaptureArgs()) } // Experimental telemetry if (config.captureExperimentalTelemetry) { addAll(buildExperimentalTelemetryArgs()) } // Custom annotations if (config.customAnnotations.isNotEmpty()) { add("-Dotel.instrumentation.annotations.methods=${config.customAnnotations.joinToString(",")}") } // Instrumentation control addAll(buildInstrumentationControlArgs(config)) } private fun buildCoreExportArgs(config: ResolvedTracingConfig, port: Int): List = buildList { val endpoint = "http://localhost:$port" add("-Dotel.traces.exporter=otlp") add("-Dotel.exporter.otlp.protocol=${config.protocol}") add("-Dotel.exporter.otlp.endpoint=$endpoint") add("-Dotel.metrics.exporter=none") add("-Dotel.logs.exporter=none") add("-Dotel.service.name=${config.serviceName}") add("-Dotel.resource.attributes=service.name=${config.serviceName},deployment.environment=test") // Disable gRPC instrumentation when using gRPC protocol to avoid instrumenting the exporter if (config.protocol == "grpc") { add("-Dotel.instrumentation.grpc.enabled=false") } } private fun buildTestOptimizationArgs(config: ResolvedTracingConfig): List = listOf( "-Dotel.traces.sampler=always_on", "-Dotel.bsp.schedule.delay=${config.bspScheduleDelay}", "-Dotel.bsp.max.export.batch.size=${config.bspMaxBatchSize}" ) private fun buildHttpHeaderCaptureArgs(): List = listOf( "-Dotel.instrumentation.http.client.capture-request-headers=content-type,accept,x-stove-test-id", "-Dotel.instrumentation.http.client.capture-response-headers=content-type", "-Dotel.instrumentation.http.server.capture-request-headers=content-type,accept,user-agent,x-stove-test-id", "-Dotel.instrumentation.http.server.capture-response-headers=content-type" ) private fun buildExperimentalTelemetryArgs(): List = listOf( "-Dotel.instrumentation.http.client.emit-experimental-telemetry=true", "-Dotel.instrumentation.http.server.emit-experimental-telemetry=true", "-Dotel.instrumentation.servlet.experimental.capture-request-parameters=*" ) private fun buildInstrumentationControlArgs(config: ResolvedTracingConfig): List = buildList { if (config.disabledInstrumentations.isNotEmpty()) { add("-Dotel.instrumentation.common.default-enabled=true") addAll(config.disabledInstrumentations.map { "-Dotel.instrumentation.$it.enabled=false" }) } addAll(config.additionalInstrumentations.map { "-Dotel.instrumentation.$it.enabled=true" }) } /** * Configuration for Stove tracing. * * @see stoveTracing */ class StoveTracingConfig { /** The service name to use in traces. This should match your application's service name. */ var serviceName: String = "stove-traced-app" /** Whether tracing is enabled. Set to false to disable tracing without removing configuration. */ var enabled: Boolean = true /** * The OTLP protocol to use. * Currently only "grpc" is supported. * Note: The port is dynamically assigned to avoid conflicts when running parallel tests. */ var protocol: String = TracingDefaults.DEFAULT_PROTOCOL set(value) { require(value == TracingDefaults.SUPPORTED_PROTOCOL) { "Unsupported OTLP protocol '$value'. Supported protocol: '${TracingDefaults.SUPPORTED_PROTOCOL}'." } field = value } /** The batch span processor schedule delay in milliseconds. Lower = faster export. */ var bspScheduleDelay: Int = TracingDefaults.DEFAULT_BSP_SCHEDULE_DELAY /** The maximum batch size for span export. 1 = immediate export per span. */ var bspMaxBatchSize: Int = TracingDefaults.DEFAULT_BSP_MAX_BATCH_SIZE /** Whether to capture HTTP headers in spans. Useful for debugging request/response details. */ var captureHttpHeaders: Boolean = true /** Whether to enable experimental HTTP telemetry features. */ var captureExperimentalTelemetry: Boolean = true /** List of instrumentation modules to disable. Example: listOf("jdbc", "hibernate") */ var disabledInstrumentations: List = emptyList() /** List of additional instrumentation modules to enable. */ var additionalInstrumentations: List = emptyList() /** List of custom annotation class names to instrument. */ var customAnnotations: List = emptyList() /** The OpenTelemetry Java Agent version to use. */ var otelAgentVersion: String = TracingDefaults.DEFAULT_OTEL_AGENT_VERSION /** * List of test task names to configure. If empty, applies to all test tasks. * Example: listOf("integrationTest") to only apply to the integrationTest task. */ var testTaskNames: List = emptyList() } ================================================ FILE: buildSrc/src/main/kotlin/stove-publishing.gradle.kts ================================================ plugins { `maven-publish` signing java } fun getProperty( projectKey: String, environmentKey: String ): String? { return if (project.hasProperty(projectKey)) { project.property(projectKey) as? String? } else { System.getenv(environmentKey) } } publishing { publications { create("publish-${project.name}") { groupId = rootProject.group.toString() version = rootProject.version.toString() println("version to be published: ${rootProject.version}") artifactId = project.name from(components["java"]) pom { name.set(project.name) description.set(project.properties["projectDescription"].toString()) url.set(project.properties["projectUrl"].toString()) packaging = "jar" licenses { license { name.set(project.properties["licence"].toString()) url.set(project.properties["licenceUrl"].toString()) } } developers { developer { id.set("osoykan") name.set("Oguzhan Soykan") email.set("oguzhan.soykan@trendyol.com") } } scm { connection.set("scm:git@github.com:Trendyol/stove.git") developerConnection.set("scm:git:ssh://github.com:Trendyol/stove.git") url.set(project.properties["projectUrl"].toString()) } } } } repositories { maven { val releasesRepoUrl = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") val snapshotsRepoUrl = uri("https://oss.sonatype.org/content/repositories/snapshots/") url = if (rootProject.version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl credentials { username = getProperty("nexus_username", "nexus_username") password = getProperty("nexus_password", "nexus_password") } } maven { name = "GitHubPackages" url = uri("https://maven.pkg.github.com/trendyol/stove") credentials { username = System.getenv("GITHUB_ACTOR") password = System.getenv("GITHUB_TOKEN") } } } } val signingKey = getProperty(projectKey = "gpg.key", environmentKey = "gpg_private_key") val passPhrase = getProperty(projectKey = "gpg.passphrase", environmentKey = "gpg_passphrase") signing { if (passPhrase == null && runningOnCI) { logger.warn( "The passphrase for the signing key was not found. " + "Either provide it as env variable 'gpg_passphrase' or " + "as project property 'gpg_passphrase'. Otherwise the signing might fail!" ) } useInMemoryPgpKeys(signingKey, passPhrase) sign(publishing.publications) } ================================================ FILE: codecov.yml ================================================ ignore: - "lib/stove/src/main/kotlin/com/trendyol/stove/functional" - "lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions" - "lib/stove/src/main/kotlin/com/trendyol/stove/system/annotations" - "lib/stove/src/main/kotlin/com/trendyol/stove/serialization" - "examples/ktor-example" - "examples/micronaut-example" - "examples/spring-4x-example" - "examples/spring-example" - "examples/spring-standalone-example" - "examples/spring-streams-example" - "test-extensions/stove-extensions-kotest/src/test" - "test-extensions/stove-extensions-junit/src/test" - "recipes" - buildSrc - starters/*/tests/* ================================================ FILE: detekt.yml ================================================ build: maxIssues: 0 excludeCorrectable: false config: validation: true warningsAsErrors: true excludes: '' processors: active: true exclude: - 'DetektProgressListener' console-reports: active: true exclude: - 'ProjectStatisticsReport' - 'ComplexityReport' - 'NotificationReport' - 'FindingsReport' - 'FileBasedFindingsReport' output-reports: active: false formatting: Indentation: active: false indentSize: 2 autoCorrect: true NoWildcardImports: active: false MaximumLineLength: active: true maxLineLength: 140 excludes: [ '**/test/**', '**/test-e2e/**' ] ArgumentListWrapping: maxLineLength: 140 autoCorrect: true active: true indentSize: 2 Filename: active: false comments: active: true AbsentOrWrongFileLicense: active: false licenseTemplateFile: 'license.template' licenseTemplateIsRegex: false CommentOverPrivateFunction: active: false CommentOverPrivateProperty: active: false DeprecatedBlockTag: active: false EndOfSentenceFormat: active: false endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' KDocReferencesNonPublicProperty: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] OutdatedDocumentation: active: false matchTypeParameters: true matchDeclarationsOrder: true allowParamOnConstructorProperties: false UndocumentedPublicClass: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true UndocumentedPublicFunction: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UndocumentedPublicProperty: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] complexity: active: true ComplexCondition: active: true threshold: 4 ComplexInterface: active: false threshold: 10 includeStaticDeclarations: false includePrivateDeclarations: false CyclomaticComplexMethod: active: true threshold: 15 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false ignoreNestingFunctions: false nestingFunctions: - 'also' - 'apply' - 'forEach' - 'isNotNull' - 'ifNull' - 'let' - 'run' - 'use' - 'with' LabeledExpression: active: false ignoredLabels: [ ] LargeClass: active: true threshold: 600 LongMethod: active: true threshold: 60 LongParameterList: active: true functionThreshold: 20 constructorThreshold: 20 ignoreDefaultParameters: false ignoreDataClasses: true ignoreAnnotatedParameter: [ ] MethodOverloading: active: false threshold: 6 NamedArguments: active: false threshold: 3 ignoreArgumentsMatchingNames: false NestedBlockDepth: active: true threshold: 4 NestedScopeFunctions: active: false threshold: 1 functions: - 'kotlin.apply' - 'kotlin.run' - 'kotlin.with' - 'kotlin.let' - 'kotlin.also' ReplaceSafeCallChainWithRun: active: false StringLiteralDuplication: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] thresholdInFiles: 30 thresholdInClasses: 30 thresholdInInterfaces: 115 thresholdInObjects: 15 thresholdInEnums: 15 ignoreDeprecated: false ignorePrivate: false ignoreOverridden: false coroutines: active: true GlobalCoroutineUsage: active: false InjectDispatcher: active: false dispatcherNames: - 'IO' - 'Default' - 'Unconfined' RedundantSuspendModifier: active: false SleepInsteadOfDelay: active: true SuspendFunWithCoroutineScopeReceiver: active: false SuspendFunWithFlowReturnType: active: true empty-blocks: active: true EmptyCatchBlock: active: true allowedExceptionNameRegex: '_|(ignore|expected).*' EmptyClassBlock: active: true EmptyDefaultConstructor: active: true EmptyDoWhileBlock: active: true EmptyElseBlock: active: true EmptyFinallyBlock: active: true EmptyForBlock: active: true EmptyFunctionBlock: active: true ignoreOverridden: false EmptyIfBlock: active: true EmptyInitBlock: active: true EmptyKtFile: active: true EmptySecondaryConstructor: active: true EmptyTryBlock: active: true EmptyWhenBlock: active: true EmptyWhileBlock: active: true exceptions: active: true ExceptionRaisedInUnexpectedLocation: active: true methodNames: - 'equals' - 'finalize' - 'hashCode' - 'toString' InstanceOfCheckForException: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] NotImplementedDeclaration: active: false ObjectExtendsThrowable: active: false PrintStackTrace: active: true RethrowCaughtException: active: true ReturnFromFinally: active: true ignoreLabeled: false SwallowedException: active: true ignoredExceptionTypes: - 'InterruptedException' - 'MalformedURLException' - 'NumberFormatException' - 'ParseException' allowedExceptionNameRegex: '_|(ignore|expected).*' ThrowingExceptionFromFinally: active: true ThrowingExceptionInMain: active: false ThrowingExceptionsWithoutMessageOrCause: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptions: - 'ArrayIndexOutOfBoundsException' - 'Exception' - 'IllegalArgumentException' - 'IllegalMonitorStateException' - 'IllegalStateException' - 'IndexOutOfBoundsException' - 'NullPointerException' - 'RuntimeException' - 'Throwable' ThrowingNewInstanceOfSameException: active: true TooGenericExceptionCaught: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptionNames: - 'ArrayIndexOutOfBoundsException' - 'Error' - 'Exception' - 'IllegalMonitorStateException' - 'IndexOutOfBoundsException' - 'NullPointerException' - 'RuntimeException' - 'Throwable' allowedExceptionNameRegex: '_|(ignore|expected).*' TooGenericExceptionThrown: active: true exceptionNames: - 'Error' - 'Exception' - 'RuntimeException' - 'Throwable' naming: active: true BooleanPropertyNaming: active: false allowedPattern: '^(is|has|are)' ClassNaming: active: true classPattern: '[A-Z][a-zA-Z0-9]*' EnumNaming: active: true enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false forbiddenName: [ ] FunctionMaxLength: active: false maximumFunctionNameLength: 30 FunctionMinLength: active: false minimumFunctionNameLength: 3 InvalidPackageDeclaration: active: true rootPackage: '' requireRootInDeclaration: false LambdaParameterNaming: active: false parameterPattern: '[a-z][A-Za-z0-9]*|_' MatchingDeclarationName: active: false mustBeFirst: true MemberNameEqualsClassName: active: true ignoreOverridden: true NoNameShadowing: active: true NonBooleanPropertyPrefixedWithIs: active: false ObjectPropertyNaming: active: true constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true constantPattern: '[A-Z][_A-Z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: active: false maximumVariableNameLength: 64 VariableMinLength: active: false minimumVariableNameLength: 1 ConstructorParameterNaming: active: false parameterPattern: '[a-z][A-Za-z0-9]*|_' performance: active: true ArrayPrimitive: active: true CouldBeSequence: active: false threshold: 3 ForEachOnRange: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] SpreadOperator: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/otel/**', ] UnnecessaryTemporaryInstantiation: active: true potential-bugs: active: true AvoidReferentialEquality: active: true forbiddenTypePatterns: - 'kotlin.String' CastToNullableType: active: false Deprecation: active: false DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: active: true mutableTypes: - 'kotlin.collections.MutableList' - 'kotlin.collections.MutableMap' - 'kotlin.collections.MutableSet' - 'java.util.ArrayList' - 'java.util.LinkedHashSet' - 'java.util.HashSet' - 'java.util.LinkedHashMap' - 'java.util.HashMap' ElseCaseInsteadOfExhaustiveWhen: active: false EqualsAlwaysReturnsTrueOrFalse: active: true EqualsWithHashCodeExist: active: true ExitOutsideMain: active: false ExplicitGarbageCollectionCall: active: true HasPlatformType: active: true IgnoredReturnValue: active: true restrictToConfig: true returnValueAnnotations: - '*.CheckResult' - '*.CheckReturnValue' ignoreReturnValueAnnotations: - '*.CanIgnoreReturnValue' ignoreFunctionCall: [ ] ImplicitDefaultLocale: active: true ImplicitUnitReturnType: active: false allowExplicitReturnType: true InvalidRange: active: true IteratorHasNextCallsNextMethod: active: true IteratorNotThrowingNoSuchElementException: active: true LateinitUsage: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: active: true MissingPackageDeclaration: active: false excludes: [ '**/*.kts' ] NullCheckOnMutableProperty: active: false NullableToStringCall: active: false UnconditionalJumpStatementInLoop: active: false UnnecessaryNotNullOperator: active: true UnnecessarySafeCall: active: true UnreachableCatchBlock: active: true UnreachableCode: active: true UnsafeCallOnNullableType: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UnsafeCast: active: true UnusedUnaryOperator: active: true UselessPostfixExpression: active: true WrongEqualsTypeParameter: active: true style: active: true CanBeNonNullable: active: false CascadingCallWrapping: active: false includeElvis: true ClassOrdering: active: false CollapsibleIfStatements: active: false DataClassContainsFunctions: active: false conversionFunctionPrefix: - 'to' DataClassShouldBeImmutable: active: false DestructuringDeclarationWithTooManyEntries: active: true maxDestructuringEntries: 6 EqualsNullCall: active: true EqualsOnSignatureLine: active: false ExplicitCollectionElementAccessMethod: active: false ExplicitItLambdaParameter: active: true ExpressionBodySyntax: active: false includeLineWrapping: false ForbiddenComment: active: false comments: - 'FIXME:' - 'STOPSHIP:' - 'TODO:' ForbiddenImport: active: false imports: [ ] forbiddenPatterns: '' ForbiddenMethodCall: active: false methods: - 'kotlin.io.print' - 'kotlin.io.println' ForbiddenSuppress: active: false rules: [ ] ForbiddenVoid: active: true ignoreOverridden: false ignoreUsageInGenerics: false FunctionOnlyReturningConstant: active: true ignoreOverridableFunction: true ignoreActualFunction: true excludedFunctions: - '' LoopWithTooManyJumpStatements: active: true maxJumpCount: 1 MagicNumber: active: true excludes: [ '**/test/**', '**/test-e2e/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/domain/**', '**/core/**', '**/*.kts' ] ignoreNumbers: - '-1' - '0' - '1' - '2' ignoreHashCodeFunction: true ignorePropertyDeclaration: false ignoreLocalVariableDeclaration: false ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true BracesOnIfStatements: active: false MandatoryBracesLoops: active: false MaxChainedCallsOnSameLine: active: false maxChainedCalls: 5 MaxLineLength: active: true maxLineLength: 140 excludePackageStatements: true excludeImportStatements: true excludeCommentStatements: false excludes: - '**/test/**' - '**/test-e2e/**' - '**/test-integration/**' MayBeConst: active: true ModifierOrder: active: true MultilineLambdaItParameter: active: false NestedClassesVisibility: active: true NewLineAtEndOfFile: active: true NoTabs: active: false NullableBooleanCheck: active: false ObjectLiteralToLambda: active: true OptionalAbstractKeyword: active: true OptionalUnit: active: false BracesOnWhenStatements: active: false PreferToOverPairSyntax: active: false ProtectedMemberInFinalClass: active: true RedundantExplicitType: active: false RedundantHigherOrderMapUsage: active: true RedundantVisibilityModifierRule: active: false ReturnCount: active: true max: 5 excludedFunctions: - 'equals' excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false SafeCast: active: true SerialVersionUIDInSerializableClass: active: true SpacingBetweenPackageAndImports: active: false ThrowsCount: active: true max: 2 excludeGuardClauses: false TrailingWhitespace: active: false UnderscoresInNumericLiterals: active: false acceptableLength: 4 allowNonStandardGrouping: false UnnecessaryAbstractClass: active: true UnnecessaryAnnotationUseSiteTarget: active: false UnnecessaryApply: active: true UnnecessaryBackticks: active: false UnnecessaryFilter: active: true UnnecessaryInheritance: active: true UnnecessaryInnerClass: active: false UnnecessaryLet: active: false UnnecessaryParentheses: active: false UntilInsteadOfRangeTo: active: false UnusedImports: active: false UnusedPrivateClass: active: true UnusedPrivateMember: active: true allowedNames: '(_|ignored|expected|serialVersionUID)' UseAnyOrNoneInsteadOfFind: active: true UseArrayLiteralsInAnnotations: active: true UseCheckNotNull: active: true UseCheckOrError: active: true UseDataClass: active: false allowVars: false UseEmptyCounterpart: active: false UseIfEmptyOrIfBlank: active: false UseIfInsteadOfWhen: active: false UseIsNullOrEmpty: active: true UseOrEmpty: active: true UseRequire: active: true UseRequireNotNull: active: true UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: active: true VarCouldBeVal: active: true ignoreLateinitVar: false WildcardImport: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] excludeImports: - 'java.util.*' ================================================ FILE: docs/Components/01-couchbase.md ================================================ # Couchbase === "Gradle" ``` kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-couchbase") } ``` ## Configure Once you've added the dependency, you'll have access to the `couchbase` function when configuring Stove. This sets up the Couchbase Docker container that will be started for your tests. You'll need to define a `defaultBucket` name. Make sure this matches what your application expects. !!! warning Your application needs to use the same bucket names, otherwise tests will fail. ```kotlin hl_lines="4 5-9" Stove() .with { couchbase { CouchbaseSystemOptions(defaultBucket = "test-bucket", configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) }) } } .run() ``` Stove exposes the configuration it generates, so you can pass the real connection strings and credentials to your application before it starts. Your application will start with the physical dependencies that are spun-up by the framework. ## Migrations Stove provides a way to run migrations before the test starts. ```kotlin class CouchbaseMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: Cluster) { val bucket = connection.bucket(CollectionConstants.BUCKET_NAME) listOf(CollectionConstants.PRODUCT_COLLECTION).forEach { collection -> bucket.collections.createCollection(bucket.defaultScope().name, collection) } connection.waitUntilReady(30.seconds) } } ``` You can define your migration class by implementing the `DatabaseMigration` interface. You can define the order of the migration by overriding the `order` property. The migrations will be executed in the order of the `order` property. After defining your migration class, you can pass it to the `migrations` function of the `couchbase` configuration. ```kotlin Stove() .with { couchbase { CouchbaseSystemOptions(defaultBucket = "test-bucket", configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) }).migrations { register() } } } .run() ``` ## Usage ### Saving Documents Save documents to Couchbase collections: ```kotlin stove { couchbase { // Save to default collection (_default) saveToDefaultCollection( id = "user:123", instance = User(id = "123", name = "John Doe", email = "john@example.com") ) // Save to a specific collection save( collection = "products", id = "product:456", instance = Product(id = "456", name = "Laptop", price = 999.99) ) } } ``` ### Getting Documents Retrieve and validate documents: ```kotlin hl_lines="4 11" stove { couchbase { // Get from default collection shouldGet("user:123") { user -> user.id shouldBe "123" user.name shouldBe "John Doe" user.email shouldBe "john@example.com" } // Get from specific collection shouldGet("products", "product:456") { product -> product.id shouldBe "456" product.name shouldBe "Laptop" product.price shouldBe 999.99 } } } ``` ### Checking Non-Existence Verify that documents don't exist: ```kotlin stove { couchbase { // Check default collection shouldNotExist("user:999") // Check specific collection shouldNotExist("products", "product:999") } } ``` ### Deleting Documents Delete documents and verify deletion: ```kotlin stove { couchbase { // Delete from default collection shouldDelete("user:123") shouldNotExist("user:123") // Delete from specific collection shouldDelete("products", "product:456") shouldNotExist("products", "product:456") } } ``` ### N1QL Queries Execute N1QL queries and validate results: ```kotlin hl_lines="4 11" stove { couchbase { // Simple query shouldQuery("SELECT u.* FROM `users` u WHERE u.age > 18") { users -> users.size shouldBeGreaterThan 0 users.all { it.age > 18 } shouldBe true } // Query with multiple conditions shouldQuery( """ SELECT p.* FROM `products` p WHERE p.price > 100 AND p.category = 'Electronics' """.trimIndent() ) { products -> products.size shouldBeGreaterThan 0 products.all { it.price > 100 && it.category == "Electronics" } shouldBe true } } } ``` ### Working with Collections and Scopes Access bucket, collection, and cluster directly: ```kotlin stove { couchbase { // Access the cluster val cluster = cluster() // Access the bucket val bucket = bucket() // Perform custom operations val customResult = bucket.collections.getAllScopes() customResult shouldNotBe null } } ``` ### Pause and Unpause Container Control the Couchbase container for testing failure scenarios: ```kotlin stove { couchbase { // Pause the container pause() // Your application should handle the failure // ... // Unpause the container unpause() // Verify recovery shouldGet("user:123") { user -> user.id shouldBe "123" } } } ``` ## Complete Example Here's a complete end-to-end test combining HTTP, Couchbase, and Kafka: ```kotlin hl_lines="10 19 32 42" test("should create product and store in couchbase") { stove { val productId = UUID.randomUUID().toString() val productName = "Gaming Laptop" val categoryId = 1 // Mock external service wiremock { mockGet( url = "/categories/$categoryId", statusCode = 200, responseBody = Category(id = categoryId, name = "Electronics", active = true).some() ) } // Create product via API http { postAndExpectBody( uri = "/products", body = ProductCreateRequest( name = productName, price = 1299.99, categoryId = categoryId ).some() ) { response -> response.status shouldBe 200 } } // Verify stored in Couchbase couchbase { shouldGet("products", "product:$productId") { product -> product.id shouldBe productId product.name shouldBe productName product.price shouldBe 1299.99 product.categoryId shouldBe categoryId } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.id == productId && actual.name == productName && actual.price == 1299.99 } } // Query products by category couchbase { shouldQuery( """ SELECT p.* FROM `products` p WHERE p.categoryId = $categoryId """.trimIndent() ) { products -> products.size shouldBeGreaterThan 0 products.any { it.id == productId } shouldBe true } } } } ``` ## Integration with Application Verify application behavior using the bridge: ```kotlin test("should use repository to save product") { stove { val productId = UUID.randomUUID().toString() val product = Product(id = productId, name = "Test Product", price = 99.99) // Use application's repository using { save(product) } // Verify in Couchbase couchbase { shouldGet("products", "product:$productId") { savedProduct -> savedProduct.id shouldBe productId savedProduct.name shouldBe "Test Product" savedProduct.price shouldBe 99.99 } } } } ``` ## Advanced Operations ### Batch Operations ```kotlin stove { couchbase { // Save multiple documents val users = listOf( User(id = "1", name = "Alice"), User(id = "2", name = "Bob"), User(id = "3", name = "Charlie") ) users.forEach { user -> saveToDefaultCollection("user:${user.id}", user) } // Query all shouldQuery("SELECT u.* FROM `${bucket().name}` u") { result -> result.size shouldBeGreaterThanOrEqual users.size } // Verify each users.forEach { user -> shouldGet("user:${user.id}") { actual -> actual.name shouldBe user.name } } } } ``` ### Error Handling ```kotlin stove { couchbase { // Document not found shouldNotExist("non-existent:key") // Attempting to delete non-existent document throws exception assertThrows { shouldDelete("non-existent:key") } // Attempting to assert non-existence on existing document throws assertion error saveToDefaultCollection("user:123", User(id = "123", name = "John")) assertThrows { shouldNotExist("user:123") } } } ``` ================================================ FILE: docs/Components/02-kafka.md ================================================ # Kafka Stove supports Kafka in two ways: standalone Kafka or Kafka with Spring integration. You can use either one, but not both in the same project. ## Standalone Kafka === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-kafka:$version") } ``` ### Configure ```kotlin hl_lines="6-7 10-11" Stove() .with { // other dependencies kafka { stoveKafkaObjectMapperRef = objectMapperRef KafkaSystemOptions { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.interceptorClasses=${it.interceptorClass}" ) } } }.run() ``` The configuration values are: ```kotlin class KafkaSystemOptions( /** * Suffixes for error and retry topics in the application. */ val topicSuffixes: TopicSuffixes = TopicSuffixes(), /** * If true, the system will listen to the messages published by the Kafka system. */ val listenPublishedMessagesFromStove: Boolean = false, /** * The port of the bridge gRPC server that is used to communicate with the Kafka system. */ val bridgeGrpcServerPort: Int = stoveKafkaBridgePortDefault.toInt(), /** * The Serde that is used while asserting the messages, * serializing while bridging the messages. Take a look at the [serde] property for more information. * * The default value is [StoveSerde.jackson]'s anyByteArraySerde. * Depending on your application's needs you might want to change this value. * * The places where it was used listed below: * * @see [com.trendyol.stove.standalone.kafka.intercepting.StoveKafkaBridge] for bridging the messages. * @see StoveKafkaValueSerializer for serializing the messages. * @see StoveKafkaValueDeserializer for deserializing the messages. * @see valueSerializer for serializing the messages. */ val serde: StoveSerde = stoveSerdeRef, /** * The Value serializer that is used to serialize messages. */ val valueSerializer: Serializer = StoveKafkaValueSerializer(), /** * The options for the Kafka container. */ val containerOptions: KafkaContainerOptions = KafkaContainerOptions(), /** * The options for the Kafka system that is exposed to the application */ override val configureExposedConfiguration: (KafkaExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration ``` ### Configuring Serializer and Deserializer Like every `SystemOptions` object, `KafkaSystemOptions` has a `serde` property that you can configure. It is a `StoveSerde` object that has two functions `serialize` and `deserialize`. You can configure them depending on your application's needs. ```kotlin val kafkaSystemOptions = KafkaSystemOptions( serde = object : StoveSerde { override fun serialize(value: Any): ByteArray { return objectMapper.writeValueAsBytes(value) } override fun deserialize(value: ByteArray): T { return objectMapper.readValue(value, Any::class.java) as T } } ) ``` ### Kafka Bridge With Your Application Stove Kafka bridge is a **MUST** to work with Kafka. Otherwise you can't assert any messages from your application. As you can see in the example above, you need to add a support to your application to work with interceptor that Stove provides. ```kotlin "kafka.interceptorClasses=com.trendyol.stove.standalone.kafka.intercepting.StoveKafkaBridge" // or "kafka.interceptorClasses={cfg.interceptorClass}" // cfg.interceptorClass is exposed by Stove ``` !!! Important `kafka.` prefix or `interceptorClasses` are assumptions that you can change it with your own prefix or configuration. ## Spring Kafka When you want to use Kafka with Application Aware testing it provides more assertion capabilities. It is recommended way of working. Stove-Kafka does that with intercepting the messages. ### How to get? === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-spring-kafka:$version") } ``` === "Maven" ```xml com.trendyol stove-spring-kafka ${stove-version} ``` ### Configure #### Configuration Values Kafka works with some settings as default, your application might have these values as not configurable, to make the application testable we need to tweak a little bit. If you have the following configurations: - `AUTO_OFFSET_RESET_CONFIG | "auto.offset.reset" | should be "earliest"` - `ALLOW_AUTO_CREATE_TOPICS_CONFIG | "allow.auto.create.topics" | should be true` - `HEARTBEAT_INTERVAL_MS_CONFIG | "heartbeat.interval.ms" | should be 2 seconds` You better make them configurable, so from the e2e testing context we can change them work with Stove-Kafka testing. As an example: ```kotlin Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } kafka { KafkaSystemOptions { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.interceptorClasses=${it.interceptorClass}" ) } } springBoot( runner = { parameters -> com.trendyol.exampleapp.run(parameters) }, withParameters = listOf( "logging.level.root=error", "logging.level.org.springframework.web=error", "spring.profiles.active=default", "server.http2.enabled=false", "kafka.heartbeatInSeconds=2", "kafka.autoCreateTopics=true", "kafka.offset=earliest" ) ) }.run() ``` As you can see, we pass these configuration values as parameters. Since they are configurable, the application considers these values instead of application-default values. ### Consumer Settings Second thing we need to do is tweak your consumer configuration. For that we will provide Stove-Kafka interceptor to your Kafka configuration. Locate to the point where you define your `ConcurrentKafkaListenerContainerFactory` or where you can set the interceptor. Interceptor needs to implement `ConsumerAwareRecordInterceptor` since Stove-Kafka [relies on that](https://github.com/Trendyol/stove/blob/main/starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/TestSystemInterceptor.kt). ```kotlin @EnableKafka @Configuration class KafkaConsumerConfiguration( private val interceptor: ConsumerAwareRecordInterceptor, ) { @Bean fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() // ... factory.setRecordInterceptor(interceptor) return factory } } ``` ### Producer Settings Make sure that the [aforementioned](#configuration-values) values are also configurable for producer settings, too. Stove will have access to `KafkaTemplate` and will use `setProducerListener` to arrange itself to listen produced messages. ### Plugging in When all the configuration is done, it is time to tell to application to use our `TestSystemInterceptor` and configuration values. #### TestSystemKafkaInterceptor and Bean Registration Register the interceptor and serde using `addTestDependencies`: **Spring Boot 2.x / 3.x:** ```kotlin import com.trendyol.stove.addTestDependencies springBoot( runner = { parameters -> runApplication(*parameters) { addTestDependencies { bean>(isPrimary = true) bean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } } } }, ``` **Spring Boot 4.x:** ```kotlin import com.trendyol.stove.addTestDependencies4x springBoot( runner = { parameters -> runApplication(*parameters) { addTestDependencies4x { registerBean>(primary = true) registerBean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } } } }, ``` #### Configuring the SystemUnderTest and Parameters ```kotlin hl_lines="4-8" springBoot( runner = { parameters -> runApplication(*parameters) { addTestDependencies { bean>(isPrimary = true) bean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } } } }, withParameters = listOf( "logging.level.root=error", "logging.level.org.springframework.web=error", "spring.profiles.active=default", "server.http2.enabled=false", "kafka.heartbeatInSeconds=2", // Added Parameter "kafka.autoCreateTopics=true", // Added Parameter "kafka.offset=earliest" // Added Parameter ) ) ``` Now you're full set and have control over Kafka messages from the testing context. ## Testing ### Publishing Messages You can publish messages to Kafka topics for testing: ```kotlin stove { kafka { publish( topic = "product-events", message = ProductCreated(id = "123", name = "T-Shirt"), key = "product-123".some(), // Optional headers = mapOf("X-UserEmail" to "user@example.com"), // Optional partition = 0 // Optional ) } } ``` ### Asserting Published Messages Test that your application publishes messages correctly: ```kotlin hl_lines="4 11" stove { // Trigger an action in your application http { postAndExpectBodilessResponse("/products", body = CreateProductRequest(name = "Laptop").some()) { response -> response.status shouldBe 200 } } // Verify the message was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.name == "Laptop" && actual.id != null && metadata.topic == "product-events" && metadata.headers["event-type"] == "PRODUCT_CREATED" } } } ``` ### Asserting Consumed Messages Test that your application consumes messages correctly: ```kotlin hl_lines="4 12 20" stove { // Publish a message kafka { publish( topic = "order-events", message = OrderCreated(orderId = "456", amount = 100.0) ) } // Verify your application consumed and processed it kafka { shouldBeConsumed(atLeastIn = 20.seconds) { actual.orderId == "456" && actual.amount == 100.0 } } // Verify side effects (e.g., database write) couchbase { shouldGet("order:456") { order -> order.orderId shouldBe "456" order.status shouldBe "CREATED" } } } ``` ### Testing Failed Messages Test that your application handles failures correctly: ```kotlin stove { kafka { // Publish an invalid message publish("user-events", FailingEvent(id = 5L)) // Verify it failed with the expected reason shouldBeFailed(atLeastIn = 10.seconds) { actual.id == 5L && reason is BusinessException } } } ``` ### Testing Retry Logic Test that your application retries failed messages: ```kotlin stove { kafka { publish("product-failing", ProductFailingCreated(productId = "789")) // Verify it was retried 3 times shouldBeRetried(atLeastIn = 1.minutes, times = 3) { actual.productId == "789" } // Verify it ended up in error topic shouldBePublished(atLeastIn = 1.minutes) { metadata.topic == "product-failing.error" } } } ``` ### Working with Message Metadata Access message metadata including headers, topic, partition, offset: ```kotlin stove { kafka { shouldBeConsumed { actual.orderId == "123" && metadata.topic == "order-events" && metadata.headers["correlation-id"] != null && metadata.partition == 0 } } } ``` ### Peeking Messages Inspect messages without consuming them: ```kotlin stove { kafka { // Peek at published messages peekPublishedMessages(atLeastIn = 5.seconds, topic = "product-events") { record -> record.key == "product-123" } // Peek at consumed messages peekConsumedMessages(atLeastIn = 5.seconds, topic = "order-events") { record -> record.offset >= 10L } // Peek at committed messages peekCommittedMessages(topic = "order-events") { record -> record.offset == 101L // next offset after 100 messages } } } ``` ### Admin Operations Manage Kafka topics and configurations: ```kotlin stove { kafka { adminOperations { createTopic(NewTopic("test-topic", 1, 1)) // Other admin operations available here } } } ``` ### In-Flight Consumer Create a consumer for advanced testing scenarios: ```kotlin stove { kafka { consumer( topic = "product-events", readOnly = false, // commit messages autoOffsetReset = "earliest", autoCreateTopics = true, keepConsumingAtLeastFor = 10.seconds ) { record -> println("Consumed: ${record.value()}") // Process the message } } } ``` ## Complete Example Here's a complete end-to-end test combining HTTP, Kafka, and database assertions: ```kotlin hl_lines="9 14 23 32 40" test("should create product and publish event") { stove { val productId = UUID.randomUUID() val productName = "Laptop" // Mock external service wiremock { mockGet("/categories/electronics", statusCode = 200, responseBody = Category(id = 1, active = true).some()) } // Make HTTP request http { postAndExpectBodilessResponse( uri = "/products", body = ProductCreateRequest(id = productId, name = productName, categoryId = 1).some() ) { response -> response.status shouldBe 200 } } // Verify Kafka message was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.id == productId && actual.name == productName && metadata.headers["X-UserEmail"] != null } } // Verify database state couchbase { shouldGet("product:$productId") { product -> product.id shouldBe productId product.name shouldBe productName } } // Verify the event was consumed by another service kafka { shouldBeConsumed(atLeastIn = 20.seconds) { actual.id == productId && actual.name == productName } } } } ``` ================================================ FILE: docs/Components/03-elasticsearch.md ================================================ # Elasticsearch === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-elasticsearch:$version") } ``` ## Configure Once you've added the dependency, you'll have access to the `elasticsearch` function when configuring Stove. This function configures the Elasticsearch Docker container that is going to be started. ```kotlin hl_lines="4 5-9" Stove() .with { elasticsearch { ElasticsearchSystemOptions(configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}", "elasticsearch.password=${cfg.password}" ) }) } } .run() ``` ### Container Options You can customize the Elasticsearch container: ```kotlin Stove() .with { elasticsearch { ElasticsearchSystemOptions( container = ElasticContainerOptions( registry = "docker.elastic.co/", image = "elasticsearch/elasticsearch", tag = "8.6.1", password = "password", disableSecurity = true, // Disable for simpler test setup exposedPorts = listOf(9200) ), configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ) } } .run() ``` ### Security Configuration For secure Elasticsearch setups with authentication: ```kotlin Stove() .with { elasticsearch { ElasticsearchSystemOptions( container = ElasticContainerOptions( disableSecurity = false, // Enable security password = "your-secure-password" ), configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}", "elasticsearch.password=${cfg.password}", "elasticsearch.ssl.enabled=true" ) } ) } } .run() ``` ### Client Configurer Customize the Elasticsearch REST client: ```kotlin Stove() .with { elasticsearch { ElasticsearchSystemOptions( clientConfigurer = ElasticClientConfigurer( httpClientBuilder = { setDefaultRequestConfig( RequestConfig.custom() .setSocketTimeout(60000) .setConnectTimeout(30000) .build() ) } ), configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ) } } .run() ``` ### Custom JSON Mapper Use a custom Jackson ObjectMapper for serialization: ```kotlin Stove() .with { elasticsearch { val customMapper = ObjectMapper().apply { registerModule(JavaTimeModule()) disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) } ElasticsearchSystemOptions( jsonpMapper = JacksonJsonpMapper(customMapper), configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ) } } .run() ``` ## Migrations Stove provides a way to run index migrations before tests start: ```kotlin hl_lines="1 4 7-10" class CreateProductIndex : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: ElasticsearchClient) { connection.indices().create { c -> c.index("products") .mappings { m -> m.properties("name") { p -> p.text { t -> t } } .properties("price") { p -> p.double_ { d -> d } } .properties("category") { p -> p.keyword { k -> k } } .properties("createdAt") { p -> p.date { d -> d } } } } } } ``` Register migrations in your Stove configuration: ```kotlin Stove() .with { elasticsearch { ElasticsearchSystemOptions( configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ).migrations { register() } } } .run() ``` ## Usage ### Saving Documents Save documents to Elasticsearch indices: ```kotlin stove { elasticsearch { // Save a document save( id = "product-123", instance = Product( id = "123", name = "Laptop", price = 999.99, category = "Electronics" ), index = "products" ) } } ``` ### Getting Documents Retrieve and validate documents: ```kotlin stove { elasticsearch { // Get by ID and validate shouldGet(index = "products", key = "product-123") { product -> product.id shouldBe "123" product.name shouldBe "Laptop" product.price shouldBe 999.99 product.category shouldBe "Electronics" } } } ``` ### Checking Non-Existence Verify that documents don't exist: ```kotlin stove { elasticsearch { // Verify document doesn't exist shouldNotExist(key = "product-999", index = "products") } } ``` ### Deleting Documents Delete documents and verify deletion: ```kotlin stove { elasticsearch { // Delete a document shouldDelete(key = "product-123", index = "products") // Verify deletion shouldNotExist(key = "product-123", index = "products") } } ``` ### Querying with JSON Query DSL Execute Elasticsearch queries using JSON DSL: ```kotlin hl_lines="4 17 18" stove { elasticsearch { // Query using JSON DSL shouldQuery( query = """ { "bool": { "must": [ { "match": { "category": "Electronics" } }, { "range": { "price": { "gte": 500 } } } ] } } """.trimIndent(), index = "products" ) { products -> products.size shouldBeGreaterThan 0 products.all { it.category == "Electronics" && it.price >= 500 } shouldBe true } } } ``` ### Querying with Query Builder Use the Elasticsearch Java client's query builder: ```kotlin stove { elasticsearch { // Query using Query builder val query = Query.of { q -> q.bool { b -> b.must { m -> m.match { t -> t.field("category").query("Electronics") } }.filter { f -> f.range { r -> r.field("price").gte(JsonData.of(500)) } } } } shouldQuery(query) { products -> products.size shouldBeGreaterThan 0 products.all { it.category == "Electronics" && it.price >= 500 } shouldBe true } } } ``` ### Accessing the Client Directly For advanced operations, access the Elasticsearch client: ```kotlin stove { elasticsearch { val esClient = client() // Perform custom operations val indexExists = esClient.indices().exists { e -> e.index("products") }.value() indexExists shouldBe true // Bulk operations esClient.bulk { b -> b.operations { op -> op.index { i -> i.index("products") .id("bulk-1") .document(Product(id = "bulk-1", name = "Mouse", price = 29.99, category = "Electronics")) } }.operations { op -> op.index { i -> i.index("products") .id("bulk-2") .document(Product(id = "bulk-2", name = "Keyboard", price = 79.99, category = "Electronics")) } } } } } ``` ### Pause and Unpause Container Control the Elasticsearch container for testing failure scenarios: ```kotlin stove { elasticsearch { // Elasticsearch is running shouldGet(index = "products", key = "product-123") { product -> product.id shouldBe "123" } // Pause the container pause() // Your application should handle the failure // ... // Unpause the container unpause() // Verify recovery shouldGet(index = "products", key = "product-123") { product -> product.id shouldBe "123" } } } ``` !!! warning `pause()` and `unpause()` operations are not supported when using a provided instance. ## Complete Example Here's a complete end-to-end test combining HTTP, Elasticsearch, and Kafka: ```kotlin hl_lines="10 19 34 43" test("should create product and index in elasticsearch") { stove { val productId = UUID.randomUUID().toString() val productName = "Gaming Laptop" val categoryId = 1 // Mock external service wiremock { mockGet( url = "/categories/$categoryId", statusCode = 200, responseBody = Category(id = categoryId, name = "Electronics", active = true).some() ) } // Create product via API http { postAndExpectBody( uri = "/products", body = ProductCreateRequest( name = productName, price = 1299.99, categoryId = categoryId ).some() ) { response -> response.status shouldBe 201 response.body().id shouldNotBe null } } // Verify indexed in Elasticsearch elasticsearch { shouldGet(index = "products", key = productId) { product -> product.id shouldBe productId product.name shouldBe productName product.price shouldBe 1299.99 } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.id == productId && actual.name == productName } } // Query products by category elasticsearch { shouldQuery( query = """ { "term": { "category": "Electronics" } } """.trimIndent(), index = "products" ) { products -> products.size shouldBeGreaterThan 0 products.any { it.id == productId } shouldBe true } } } } ``` ## Integration with Application Verify application behavior using the bridge: ```kotlin test("should use service to index product") { stove { val productId = UUID.randomUUID().toString() val product = Product(id = productId, name = "Test Product", price = 99.99, category = "Test") // Use application's service using { indexProduct(product) } // Verify in Elasticsearch elasticsearch { shouldGet(index = "products", key = productId) { indexed -> indexed.id shouldBe productId indexed.name shouldBe "Test Product" indexed.price shouldBe 99.99 } } } } ``` ## Advanced Operations ### Full-Text Search ```kotlin stove { elasticsearch { // Setup test data listOf( Product(id = "1", name = "MacBook Pro 16 inch", price = 2499.99, category = "Laptops"), Product(id = "2", name = "MacBook Air M2", price = 1199.99, category = "Laptops"), Product(id = "3", name = "Dell XPS 15", price = 1799.99, category = "Laptops") ).forEach { product -> save(id = product.id, instance = product, index = "products") } // Full-text search shouldQuery( query = """ { "multi_match": { "query": "MacBook", "fields": ["name", "description"] } } """.trimIndent(), index = "products" ) { results -> results.size shouldBe 2 results.all { "MacBook" in it.name } shouldBe true } } } ``` ### Aggregations ```kotlin stove { elasticsearch { val esClient = client() // Search with aggregations val response = esClient.search({ s -> s.index("products") .size(0) .aggregations("price_stats") { a -> a.stats { st -> st.field("price") } } .aggregations("by_category") { a -> a.terms { t -> t.field("category.keyword") } } }, Product::class.java) // Access aggregation results val priceStats = response.aggregations()["price_stats"]?.stats() priceStats?.avg() shouldNotBe null priceStats?.min() shouldNotBe null priceStats?.max() shouldNotBe null val categoryBuckets = response.aggregations()["by_category"]?.sterms()?.buckets()?.array() categoryBuckets?.size shouldBeGreaterThan 0 } } ``` ### Index Management ```kotlin stove { elasticsearch { val esClient = client() // Create index with custom settings esClient.indices().create { c -> c.index("test-index") .settings { s -> s.numberOfShards("1") .numberOfReplicas("0") } .mappings { m -> m.properties("title") { p -> p.text { t -> t.analyzer("standard") } } .properties("tags") { p -> p.keyword { k -> k } } } } // Check index exists val exists = esClient.indices().exists { e -> e.index("test-index") }.value() exists shouldBe true // Delete index esClient.indices().delete { d -> d.index("test-index") } } } ``` ## Provided Instance (External Elasticsearch) For CI/CD pipelines or shared infrastructure: ```kotlin Stove() .with { elasticsearch { ElasticsearchSystemOptions.provided( host = System.getenv("ELASTICSEARCH_HOST") ?: "localhost", port = System.getenv("ELASTICSEARCH_PORT")?.toInt() ?: 9200, password = System.getenv("ELASTICSEARCH_PASSWORD") ?: "", runMigrations = true, cleanup = { esClient -> // Clean up test indices after tests esClient.indices().delete { d -> d.index("test-*") } }, configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}", "elasticsearch.password=${cfg.password}" ) } ) } } .run() ``` ## Data Classes Example ```kotlin data class Product( val id: String, val name: String, val description: String? = null, val price: Double, val category: String, val tags: List = emptyList(), val createdAt: Instant = Instant.now() ) data class SearchResult( val total: Long, val products: List ) ``` ================================================ FILE: docs/Components/04-wiremock.md ================================================ # WireMock === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-wiremock:$version") } ``` ## Configure Once you've added the dependency, you'll have access to the `wiremock` function when configuring Stove. This starts a WireMock server instance. By default, WireMock uses a **dynamic port** (port = 0), which lets the system pick an available port automatically. This avoids port conflicts, especially in CI environments. ```kotlin Stove() .with { wiremock { WireMockSystemOptions( // port = 0 by default (dynamic port) configureExposedConfiguration = { cfg -> listOf( "external-api.url=${cfg.baseUrl}" // e.g., http://localhost:54321 ) } ) } } .run() ``` ### Dynamic Port Configuration (Recommended) Using dynamic ports is the recommended approach as it: - **Avoids port conflicts** in CI/CD pipelines where multiple builds may run in parallel - **Eliminates "Address already in use" errors** that occur with hardcoded ports - **Automatically exposes** the actual port to your application via `configureExposedConfiguration` ```kotlin hl_lines="11-13 20" Stove() .with { wiremock { WireMockSystemOptions( // Dynamic port (default) configureExposedConfiguration = { cfg -> // cfg.baseUrl = "http://localhost:" // cfg.port = // cfg.host = "localhost" listOf( "payment.service.url=${cfg.baseUrl}", "inventory.service.url=${cfg.baseUrl}", "notification.service.url=${cfg.baseUrl}" ) } ) } springBoot( runner = { params -> com.myapp.run(params) } // No need to specify external service URLs here - // they're automatically injected via configureExposedConfiguration ) } .run() ``` ### Fixed Port Configuration If you need a specific port (not recommended for CI), you can set it explicitly: ```kotlin wiremock { WireMockSystemOptions( port = 9090 // Fixed port ) } ``` ### Options ```kotlin data class WireMockSystemOptions( /** * Port of wiremock server. * Defaults to 0, which lets WireMock pick an available port automatically. * This avoids port conflicts, especially in CI environments. */ val port: Int = 0, /** * Configures wiremock server */ val configure: WireMockConfiguration.() -> WireMockConfiguration = { this.notifier(ConsoleNotifier(true)) }, /** * Removes the stub when request matches/completes * Default value is false */ val removeStubAfterRequestMatched: Boolean = false, /** * Called after stub removed */ val afterStubRemoved: AfterStubRemoved = { _, _ -> }, /** * Called after request handled */ val afterRequest: AfterRequestHandler = { _, _ -> }, /** * ObjectMapper for serialization/deserialization */ val serde: StoveSerde = StoveSerde.jackson.anyByteArraySerde(), /** * Configures the exposed configuration for the application under test. * Use this to inject WireMock's URL into your application's configuration. */ val configureExposedConfiguration: (WireMockExposedConfiguration) -> List = { _ -> listOf() } ) : SystemOptions /** * Configuration exposed by WireMock after it starts. */ data class WireMockExposedConfiguration( val host: String, // e.g., "localhost" val port: Int // The actual port WireMock is listening on ) { val baseUrl: String // e.g., "http://localhost:54321" } ``` ## Mocking Wiremock starts a mock server on `localhost`. With dynamic ports (the default), the actual port is automatically determined and exposed via `configureExposedConfiguration`. !!! warning "Critical: External Service URLs Must Match WireMock" **All external service URLs in your application must be configured to point to the WireMock server.** This is one of the most common configuration mistakes. If your application's external service URLs don't match WireMock's URL, your mocks won't be hit and tests will fail or timeout. ### URL Configuration Say your application calls external services in production: - `http://payment-service.com/api/payments` - `http://inventory-service.com/api/stock` - `http://notification-service.com/api/notify` For testing, **all** these base URLs must be replaced with the WireMock URL. Use `configureExposedConfiguration` to automatically inject the correct URL: ```kotlin Stove() .with { wiremock { WireMockSystemOptions( // port = 0 (default) - dynamic port configureExposedConfiguration = { cfg -> // cfg.baseUrl contains the actual WireMock URL (e.g., http://localhost:54321) listOf( "payment.service.url=${cfg.baseUrl}", "inventory.service.url=${cfg.baseUrl}", "notification.service.url=${cfg.baseUrl}" ) } ) } springBoot( // or ktor runner = { params -> com.myapp.run(params) } // External service URLs are automatically configured via configureExposedConfiguration ) } .run() ``` !!! tip "Why Dynamic Ports?" Using `port = 0` (the default) lets WireMock pick an available port automatically. This is especially important in CI environments where: - Multiple test runs may execute in parallel - Other services might already be using common ports like 8080, 9090 - You get "Address already in use" errors with fixed ports The `configureExposedConfiguration` callback receives the actual port after WireMock starts, ensuring your application always connects to the correct URL. ### Application Configuration Tips Make your external service URLs configurable in your application: === "Spring Boot (application.yml)" ```yaml external: payment-service: url: ${PAYMENT_SERVICE_URL:http://payment-service.com} inventory-service: url: ${INVENTORY_SERVICE_URL:http://inventory-service.com} ``` === "Ktor" ```kotlin val paymentUrl = environment.config.propertyOrNull("payment.service.url") ?.getString() ?: "http://payment-service.com" ``` Then in your tests, Stove passes the WireMock URL through parameters, overriding the defaults. All service endpoints will be pointing to the WireMock server. You can now define the stubs for the services that your application calls. ## Usage ### GET Requests Mock GET requests with various configurations: ```kotlin hl_lines="4-5 14-15 26-27" stove { wiremock { // Simple GET mock mockGet( url = "/api/products", statusCode = 200, responseBody = listOf( Product("1", "Laptop", 999.99), Product("2", "Mouse", 29.99) ).some() ) // GET with custom headers mockGet( url = "/api/user/profile", statusCode = 200, responseBody = UserProfile(id = "123", name = "John").some(), responseHeaders = mapOf( "Content-Type" to "application/json", "X-Rate-Limit" to "100" ) ) // GET returning error mockGet( url = "/api/products/999", statusCode = 404, responseBody = ErrorResponse("Product not found").some() ) } } ``` ### POST Requests Mock POST requests with request/response bodies: ```kotlin stove { wiremock { // POST with request and response body mockPost( url = "/api/orders", statusCode = 201, requestBody = CreateOrderRequest(items = listOf("item1", "item2")).some(), responseBody = OrderResponse(orderId = "order-123", status = "CREATED").some() ) // POST with metadata matching mockPost( url = "/api/users", statusCode = 201, requestBody = CreateUserRequest(name = "John").some(), responseBody = UserResponse(id = "user-123", name = "John").some(), metadata = mapOf("Content-Type" to "application/json") ) // POST returning error mockPost( url = "/api/orders", statusCode = 400, requestBody = InvalidOrderRequest().some(), responseBody = ValidationError("Invalid order data").some() ) } } ``` ### PUT Requests Mock PUT requests for updates: ```kotlin stove { wiremock { // PUT with full update mockPut( url = "/api/products/123", statusCode = 200, requestBody = UpdateProductRequest(name = "Updated Product", price = 899.99).some(), responseBody = Product("123", "Updated Product", 899.99).some() ) // PUT with no response body mockPut( url = "/api/settings/update", statusCode = 204, requestBody = UpdateSettingsRequest(theme = "dark").some() ) } } ``` ### PATCH Requests Mock PATCH requests for partial updates: ```kotlin stove { wiremock { // PATCH for partial update mockPatch( url = "/api/users/123", statusCode = 200, requestBody = mapOf("email" to "newemail@example.com").some(), responseBody = UserResponse(id = "123", email = "newemail@example.com").some() ) } } ``` ### DELETE Requests Mock DELETE requests: ```kotlin stove { wiremock { // DELETE returning success mockDelete( url = "/api/products/123", statusCode = 204 ) // DELETE with metadata mockDelete( url = "/api/users/456", statusCode = 200, metadata = mapOf("Authorization" to "Bearer token123") ) } } ``` ### HEAD Requests Mock HEAD requests: ```kotlin stove { wiremock { mockHead( url = "/api/products/exists/123", statusCode = 200 ) mockHead( url = "/api/products/exists/999", statusCode = 404 ) } } ``` ### Advanced Configuration For complex scenarios, use the configure methods: ```kotlin hl_lines="4-5 19-20" stove { wiremock { // Advanced GET configuration mockGetConfigure( url = "/api/search", urlPatternFn = { urlPathMatching("/api/search.*") } ) { builder, serde -> builder .withQueryParam("q", matching(".*laptop.*")) .willReturn( aResponse() .withStatus(200) .withBody(serde.serialize(SearchResults(items = listOf("item1", "item2")))) ) } // Advanced POST configuration mockPostConfigure( url = "/api/webhooks", urlPatternFn = { urlEqualTo(it) } ) { builder, serde -> builder .withHeader("X-Webhook-Secret", equalTo("secret123")) .withRequestBody(containing("event_type")) .willReturn( aResponse() .withStatus(200) .withBody("Webhook received") ) } } } ``` ### Partial Body Matching When you only need to match specific fields in a request body without specifying the entire payload, use the `*Containing` methods. This is useful when: - The request body has fields you don't control (timestamps, generated IDs) - You only care about matching certain business-critical fields - The request body structure is complex but you need to match a single unique identifier #### Basic Partial Matching Match requests containing specific fields: ```kotlin stove { wiremock { // Only matches requests where productId = 123, ignores other fields mockPostContaining( url = "/api/orders", requestContaining = mapOf("productId" to 123), statusCode = 201, responseBody = OrderResponse(orderId = "order-123").some() ) } } // This request WILL match (extra fields are ignored): // POST /api/orders // {"productId": 123, "quantity": 5, "userId": "user-456", "timestamp": "2024-01-01T00:00:00Z"} ``` #### Multiple Field Matching (AND Logic) When you specify multiple fields, they are matched using **AND** logic - **all** specified fields must be present and match for the stub to be triggered: ```kotlin stove { wiremock { // ALL three fields must match for this stub to respond mockPostContaining( url = "/api/payments", requestContaining = mapOf( "orderId" to "order-123", // AND "amount" to 99.99, // AND "currency" to "USD" ), statusCode = 200, responseBody = PaymentResponse(transactionId = "txn-789").some() ) } } // ✅ MATCHES - all three fields present and correct: // {"orderId": "order-123", "amount": 99.99, "currency": "USD", "extra": "ignored"} // ❌ DOES NOT MATCH - missing "currency": // {"orderId": "order-123", "amount": 99.99} // ❌ DOES NOT MATCH - wrong value for "amount": // {"orderId": "order-123", "amount": 50.00, "currency": "USD"} ``` #### Deep Nested Matching with Dot Notation Match specific fields deep within nested JSON structures using dot notation: ```kotlin stove { wiremock { // Match a single field deep in the JSON structure mockPostContaining( url = "/api/orders", requestContaining = mapOf("order.customer.id" to "cust-123"), statusCode = 200, responseBody = OrderConfirmation(status = "confirmed").some() ) } } // This request WILL match: // POST /api/orders // { // "order": { // "id": "order-999", // "customer": { // "id": "cust-123", <-- Only this field is matched // "name": "John Doe", // "email": "john@example.com" // }, // "items": [...] // }, // "metadata": {...} // } ``` #### Multiple Deep Nested Fields Match multiple fields at different levels of nesting: ```kotlin stove { wiremock { mockPostContaining( url = "/api/checkout", requestContaining = mapOf( "order.customer.id" to "cust-123", "order.payment.method" to "credit_card", "metadata.source" to "mobile_app" ), statusCode = 200, responseBody = CheckoutResponse(success = true).some() ) } } ``` #### Nested Object Matching Match nested objects with partial comparison (extra fields in nested objects are ignored): ```kotlin stove { wiremock { // Match if the "settings" object contains at least {enabled: true} mockPutContaining( url = "/api/config", requestContaining = mapOf( "settings" to mapOf("enabled" to true) ), statusCode = 200 ) } } // This request WILL match (extra fields in settings are ignored): // PUT /api/config // { // "settings": { // "enabled": true, <-- Matched // "level": 5, <-- Ignored // "features": [...] <-- Ignored // } // } ``` #### Available Partial Matching Methods | Method | HTTP Method | Description | |--------|-------------|-------------| | `mockPostContaining` | POST | Partial body matching for POST requests | | `mockPutContaining` | PUT | Partial body matching for PUT requests | | `mockPatchContaining` | PATCH | Partial body matching for PATCH requests | All methods support: - **Simple values**: strings, numbers, booleans - **Dot notation**: `"order.customer.id"` for deep nested access - **Nested objects**: `mapOf("user" to mapOf("id" to 123))` - **Arrays**: `mapOf("tags" to listOf("important", "urgent"))` - **URL patterns**: Use `urlPatternFn` parameter for regex URL matching #### URL Pattern with Partial Matching Combine URL patterns with partial body matching: ```kotlin stove { wiremock { mockPostContaining( url = "/api/v[0-9]+/orders", requestContaining = mapOf("orderId" to "order-123"), statusCode = 200, urlPatternFn = { urlPathMatching(it) } // Enable regex URL matching ) } } // Matches: POST /api/v1/orders, POST /api/v2/orders, etc. ``` ### Behavioral Mocking Simulate service behavior changes over multiple calls: ```kotlin test("service recovers from failure") { stove { wiremock { behaviourFor("/api/external-service", WireMock::get) { initially { aResponse() .withStatus(503) .withBody("Service unavailable") } then { aResponse() .withStatus(503) .withBody("Still unavailable") } then { aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(it.serialize(ServiceResponse(status = "OK"))) } } } http { // First call - failure getResponse("/api/external-service") { response -> response.status shouldBe 503 } // Second call - still failing getResponse("/api/external-service") { response -> response.status shouldBe 503 } // Third call - success get("/api/external-service") { response -> response.status shouldBe "OK" } } } } ``` ### Testing Circuit Breaker Test circuit breaker patterns with WireMock: ```kotlin test("circuit breaker opens after failures") { stove { wiremock { // Mock service that fails mockGet( url = "/api/unreliable-service", statusCode = 500, responseBody = "Internal Server Error".some() ) } // Application calls the service multiple times repeat(5) { http { getResponse("/api/call-external") { response -> // First few calls fail response.status shouldBe 500 } } } // Update mock to return success wiremock { mockGet( url = "/api/unreliable-service", statusCode = 200, responseBody = ServiceResponse(status = "OK").some() ) } // Circuit breaker should open, need to wait for recovery delay(5.seconds) http { get("/api/call-external") { response -> response.status shouldBe "OK" } } } } ``` ## Complete Example Here's a complete test with multiple external service mocks: ```kotlin test("should create order with external service validation") { stove { val userId = "user-123" val productId = "product-456" val categoryId = 1 // Mock user service wiremock { mockGet( url = "/users/$userId", statusCode = 200, responseBody = User(id = userId, name = "John Doe", active = true).some(), responseHeaders = mapOf("X-Service" to "UserService") ) } // Mock product catalog service wiremock { mockGet( url = "/products/$productId", statusCode = 200, responseBody = Product( id = productId, name = "Laptop", price = 999.99, stock = 10 ).some() ) } // Mock category service wiremock { mockGet( url = "/categories/$categoryId", statusCode = 200, responseBody = Category(id = categoryId, name = "Electronics", active = true).some() ) } // Mock inventory service (POST to reserve stock) wiremock { mockPost( url = "/inventory/reserve", statusCode = 200, requestBody = ReserveStockRequest(productId = productId, quantity = 1).some(), responseBody = ReservationResponse(reservationId = "res-789", success = true).some() ) } // Create order via your API http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest( userId = userId, productId = productId, quantity = 1 ).some() ) { response -> response.status shouldBe 201 response.body().orderId shouldNotBe null response.body().status shouldBe "CREATED" } } // Verify order was stored postgresql { shouldQuery( "SELECT * FROM orders WHERE user_id = ?", mapper = { row -> Order( id = row.long("id"), userId = row.string("user_id"), productId = row.string("product_id"), quantity = row.int("quantity") ) } ) { orders -> orders.size shouldBe 1 orders.first().userId shouldBe userId orders.first().productId shouldBe productId } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.userId == userId && actual.productId == productId } } } } ``` ## Error Scenarios Test how your application handles external service failures: ```kotlin test("should handle external service unavailability") { stove { // Mock external service returning 503 wiremock { mockGet( url = "/external-api/data", statusCode = 503, responseBody = ErrorResponse("Service temporarily unavailable").some() ) } // Your application should handle this gracefully http { getResponse("/api/fetch-data") { response -> response.status shouldBe 503 // or your fallback status } } } } test("should handle timeout") { stove { wiremock { mockGetConfigure("/slow-endpoint") { builder, _ -> builder.willReturn( aResponse() .withStatus(200) .withBody("Response") .withFixedDelay(5000) // 5 second delay ) } } http { getResponse("/api/call-slow-service") { response -> // Your application should timeout and handle it response.status shouldBe 504 // Gateway timeout } } } } ``` ## Integration Testing Test complex integrations with multiple services: ```kotlin test("should orchestrate multiple services") { stove { val userId = "user-123" // Mock authentication service wiremock { mockPost( url = "/auth/validate", statusCode = 200, requestBody = TokenRequest(token = "jwt-token").some(), responseBody = TokenValidation(valid = true, userId = userId).some() ) } // Mock permissions service wiremock { mockGet( url = "/permissions/$userId", statusCode = 200, responseBody = Permissions( userId = userId, roles = listOf("USER", "ADMIN") ).some() ) } // Make authenticated request http { get( uri = "/api/secure-data", token = "jwt-token".some() ) { data -> data.accessible shouldBe true } } } } ``` ## Request Verification Verify that requests were made as expected: ```kotlin test("should verify request details") { stove { wiremock { mockPost( url = "/api/webhook", statusCode = 200, metadata = mapOf( "X-Signature" to "expected-signature" ) ) } // Trigger webhook http { postAndExpectBodilessResponse( uri = "/trigger-webhook", body = WebhookTrigger(event = "user.created").some() ) { response -> response.status shouldBe 200 } } // Verify the webhook was called with correct signature // (WireMock will only match if headers match) } } ``` ================================================ FILE: docs/Components/05-http.md ================================================ # HTTP Client === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-http:$version") } ``` ## Configure Once you've added the dependency, you'll have access to the `httpClient` function when configuring Stove: ```kotlin hl_lines="3 5" Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080", ) } } .run() ``` The other options that you can set are: ```kotlin data class HttpClientSystemOptions( /** * Base URL of the HTTP client. */ val baseUrl: String, /** * Content converter for the HTTP client. Default is JacksonConverter. You can use GsonConverter or any other converter. * If you want to use your own converter, you can implement ContentConverter interface. */ val contentConverter: ContentConverter = JacksonConverter(StoveSerde.jackson.default), /** * Timeout for the HTTP client. Default is 30 seconds. */ val timeout: Duration = 30.seconds, /** * Create client function for the HTTP client. Default is jsonHttpClient. */ val createClient: () -> io.ktor.client.HttpClient = { jsonHttpClient(timeout, contentConverter) } ) ``` ## Usage ### GET Requests Making GET requests with various options: ```kotlin hl_lines="4 10 20 25" stove { http { // Simple GET request with type-safe response get("/users/123") { user -> user.id shouldBe 123 user.name shouldBe "John Doe" } // GET with query parameters get("/api/index", queryParams = mapOf("keyword" to "search-term")) { response -> response shouldContain "search-term" } // GET with headers get("/profile", headers = mapOf("X-Custom-Header" to "value")) { profile -> profile.email shouldNotBe null } // GET with authentication token get("/secure-endpoint", token = "jwt-token".some()) { data -> data.isAuthorized shouldBe true } // GET multiple items (list response) getMany("/products", queryParams = mapOf("page" to "1", "size" to "10")) { products -> products.size shouldBe 10 products.first().name shouldNotBe null } } } ``` ### GET with Full Response Access When you need access to status code and headers: ```kotlin stove { http { getResponse("/users/123") { response -> response.status shouldBe 200 response.headers["Content-Type"] shouldContain "application/json" response.body().id shouldBe 123 } // Bodiless response (only status and headers) getResponse("/health") { response -> response.status shouldBe 200 } } } ``` ### POST Requests Various POST request patterns: ```kotlin stove { http { // POST with request body and expect JSON response postAndExpectJson("/users") { CreateUserRequest(name = "John", email = "john@example.com") } { user -> user.id shouldNotBe null user.name shouldBe "John" } // POST and expect bodiless response (only status) postAndExpectBodilessResponse( uri = "/products/activate", body = ActivateRequest(productId = 123).some() ) { response -> response.status shouldBe 200 } // POST with full response access postAndExpectBody( uri = "/products", body = CreateProductRequest(name = "Laptop", price = 999.99).some() ) { response -> response.status shouldBe 201 response.headers["Location"] shouldNotBe null response.body().id shouldNotBe null } // POST with headers and token postAndExpectJson( uri = "/orders", body = CreateOrderRequest(items = listOf("item1", "item2")).some(), headers = mapOf("X-Request-ID" to "12345"), token = "jwt-token".some() ) { order -> order.id shouldNotBe null order.status shouldBe "CREATED" } } } ``` ### PUT Requests Update operations with PUT: ```kotlin stove { http { // PUT with response body putAndExpectJson("/users/123") { UpdateUserRequest(name = "Jane Doe", email = "jane@example.com") } { user -> user.name shouldBe "Jane Doe" user.email shouldBe "jane@example.com" } // PUT without response body putAndExpectBodilessResponse( uri = "/products/123", body = UpdateProductRequest(name = "Updated Product").some() ) { response -> response.status shouldBe 200 } // PUT with full response access putAndExpectBody( uri = "/products/456", body = UpdateProductRequest(price = 899.99).some() ) { response -> response.status shouldBe 200 response.body().price shouldBe 899.99 } } } ``` ### PATCH Requests Partial updates with PATCH: ```kotlin stove { http { // PATCH with response body patchAndExpectBody( uri = "/users/123", body = mapOf("email" to "newemail@example.com").some() ) { response -> response.status shouldBe 200 response.body().email shouldBe "newemail@example.com" } } } ``` ### DELETE Requests Delete operations: ```kotlin stove { http { // DELETE without response body deleteAndExpectBodilessResponse("/users/123") { response -> response.status shouldBe 204 } // DELETE with authentication deleteAndExpectBodilessResponse( uri = "/products/456", token = "jwt-token".some() ) { response -> response.status shouldBe 200 } } } ``` ### File Upload with Multipart Upload files using multipart form data: ```kotlin stove { http { postMultipartAndExpectResponse( uri = "/products/import", body = listOf( StoveMultiPartContent.Text("productName", "Laptop"), StoveMultiPartContent.Text("description", "A powerful laptop"), StoveMultiPartContent.File( param = "file", fileName = "products.csv", content = csvBytes, contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE ) ) ) { response -> response.status shouldBe 200 response.body().uploadedFiles.size shouldBe 1 response.body().message shouldContain "products.csv" } } } ``` ### Advanced: Using Ktor Client Directly For advanced scenarios, access the underlying Ktor HttpClient: ```kotlin stove { http { client { baseUrl -> // Direct access to Ktor HttpClient val response = get { url(baseUrl.buildString() + "/custom-endpoint") header("Custom-Header", "value") } println(response.status) } } } ``` ## Complete Example Here's a complete CRUD test example: ```kotlin hl_lines="7 20 30 38" test("should perform CRUD operations on products") { stove { var productId: Long? = null // CREATE http { postAndExpectBody( uri = "/products", body = CreateProductRequest(name = "Laptop", price = 999.99, categoryId = 1).some() ) { response -> response.status shouldBe 201 productId = response.body().id response.body().name shouldBe "Laptop" } } // READ http { get("/products/$productId") { product -> product.id shouldBe productId product.name shouldBe "Laptop" product.price shouldBe 999.99 } } // UPDATE http { putAndExpectJson("/products/$productId") { UpdateProductRequest(price = 899.99) } { product -> product.price shouldBe 899.99 } } // DELETE http { deleteAndExpectBodilessResponse("/products/$productId") { response -> response.status shouldBe 204 } } // Verify deletion http { getResponse("/products/$productId") { response -> response.status shouldBe 404 } } } } ``` ## Integration with Other Components ### HTTP + Database ```kotlin hl_lines="4 12" stove { // Create via API and capture user ID var userId: Long = 0 http { postAndExpectBody("/users", body = CreateUserRequest(name = "John").some()) { response -> userId = response.body().id } } // Verify in database postgresql { shouldQuery( query = "SELECT * FROM users WHERE id = $userId", mapper = { row -> User(row.long("id"), row.string("name")) } ) { users -> users.size shouldBe 1 users.first().name shouldBe "John" } } } ``` ### HTTP + Kafka ```kotlin stove { // Trigger event via API http { postAndExpectBodilessResponse("/orders", body = CreateOrderRequest(amount = 100.0).some()) { response -> response.status shouldBe 201 } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.amount == 100.0 } } } ``` ### HTTP + WireMock ```kotlin stove { // Mock external service wiremock { mockGet( url = "/external-api/data", statusCode = 200, responseBody = ExternalData(id = 1, value = "test").some() ) } // Call your API that depends on external service http { get("/data") { response -> response.value shouldBe "test" } } } ``` ## Error Handling ```kotlin stove { http { // Test validation errors postAndExpectBody("/users", body = InvalidUserRequest().some()) { response -> response.status shouldBe 400 response.body().errors shouldContain "name is required" } // Test authentication errors getResponse("/secure-endpoint") { response -> response.status shouldBe 401 } // Test not found getResponse("/users/999999") { response -> response.status shouldBe 404 } // Test business logic errors postAndExpectBody("/products", body = InvalidProductRequest().some()) { response -> response.status shouldBe 409 // Conflict response.body().message shouldContain "already exists" } } } ``` ## WebSocket Support Stove provides built-in support for testing WebSocket endpoints. The WebSocket functionality is integrated into the HTTP system and uses Ktor's WebSocket client under the hood. ### Basic WebSocket Usage Send and receive messages through a WebSocket connection: ```kotlin hl_lines="3 5" stove { http { webSocket("/chat") { // Send a text message send("Hello, WebSocket!") // Receive a text message val response = receiveText() response shouldBe "Echo: Hello, WebSocket!" } } } ``` ### Sending Messages Multiple ways to send messages: ```kotlin stove { http { webSocket("/endpoint") { // Send text message send("Hello") // Send binary data send(byteArrayOf(1, 2, 3, 4, 5)) // Send using sealed class send(StoveWebSocketMessage.Text("Hello via sealed class")) send(StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3))) } } } ``` ### Receiving Messages Various methods to receive messages: ```kotlin stove { http { webSocket("/endpoint") { // Receive text val text = receiveText() text shouldBe "expected message" // Receive binary val bytes = receiveBinary() bytes shouldBe byteArrayOf(1, 2, 3) // Receive as sealed class (auto-detect type) val message = receive() when (message) { is StoveWebSocketMessage.Text -> println(message.content) is StoveWebSocketMessage.Binary -> println(message.content.size) null -> println("Connection closed") } // Receive with timeout val response = receiveTextWithTimeout(5.seconds) response.isSome() shouldBe true response.getOrNull() shouldBe "expected" } } } ``` ### Collecting Multiple Messages Collect a batch of messages: ```kotlin stove { http { webSocket("/broadcast") { // Collect 5 text messages with a 10 second timeout val messages = collectTexts(count = 5, timeout = 10.seconds) messages.size shouldBe 5 messages[0] shouldBe "Message 1" messages[4] shouldBe "Message 5" // Collect binary messages val binaryMessages = collectBinaries(count = 3, timeout = 5.seconds) binaryMessages.size shouldBe 3 } } } ``` ### Streaming with Flow Use Kotlin Flow for streaming scenarios: ```kotlin stove { http { webSocket("/events") { // Stream text messages val messages = incomingTexts() .take(10) .toList() messages.size shouldBe 10 // Stream binary messages incomingBinaries() .take(5) .collect { bytes -> println("Received ${bytes.size} bytes") } // Stream all message types incoming() .take(5) .collect { message -> when (message) { is StoveWebSocketMessage.Text -> println(message.content) is StoveWebSocketMessage.Binary -> println(message.content.size) } } } } } ``` ### Authentication and Headers Connect with authentication or custom headers: ```kotlin stove { http { // With bearer token webSocket( uri = "/secure-chat", token = "jwt-token".some() ) { val response = receiveText() response shouldBe "Authenticated successfully" } // With custom headers webSocket( uri = "/chat", headers = mapOf( "X-Custom-Header" to "value", "Authorization" to "Bearer custom-token" ) ) { send("Hello with custom headers") receiveText() shouldNotBe null } } } ``` ### WebSocket Expect (Assertion Alias) Use `webSocketExpect` for assertion-focused tests: ```kotlin stove { http { webSocketExpect("/notifications") { val messages = collectTexts(count = 3) messages.size shouldBe 3 messages.all { it.startsWith("notification:") } shouldBe true } } } ``` ### Raw WebSocket Access For advanced scenarios, access the underlying Ktor WebSocket session: ```kotlin stove { http { webSocketRaw("/advanced") { // Direct access to Ktor's DefaultClientWebSocketSession send(Frame.Text("raw frame")) for (frame in incoming) { when (frame) { is Frame.Text -> println(frame.readText()) is Frame.Binary -> println(frame.readBytes().size) is Frame.Close -> break else -> {} } } } } } ``` ### Underlying Session Access Access the underlying session from within `StoveWebSocketSession`: ```kotlin stove { http { webSocket("/endpoint") { // Use simplified API first send("Hello") // Then access underlying session for advanced operations underlyingSession { send(Frame.Text("Advanced operation")) val frame = incoming.receive() (frame as Frame.Text).readText() shouldBe "Response" } } } } ``` ### Closing Connections Gracefully close WebSocket connections: ```kotlin stove { http { webSocket("/chat") { send("Hello") receiveText() // Close with custom reason close("Test completed") } } } ``` ### Complete WebSocket Test Example A comprehensive example testing a chat application: ```kotlin test("should handle chat room operations") { stove { http { // Test echo functionality webSocket("/chat/echo") { send("Hello, World!") receiveText() shouldBe "Echo: Hello, World!" send("Another message") receiveText() shouldBe "Echo: Another message" } // Test broadcast with authentication webSocket( uri = "/chat/room/123", token = "user-jwt-token".some() ) { // Verify join notification val joinMessage = receiveText() joinMessage shouldContain "joined" // Send a message send("Hi everyone!") // Collect broadcast responses val messages = collectTexts(count = 2, timeout = 5.seconds) messages.any { it.contains("Hi everyone!") } shouldBe true } // Test binary data (e.g., file sharing) webSocket("/chat/files") { val fileData = "Hello".toByteArray() send(fileData) val response = receiveBinary() response shouldNotBe null } } } } ``` ### WebSocket + Kafka Integration Test WebSocket events that trigger Kafka messages: ```kotlin stove { http { webSocket("/events") { send("""{"type": "order", "action": "create", "amount": 100.0}""") val confirmation = receiveText() confirmation shouldContain "received" } } kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.amount == 100.0 } } } ``` ================================================ FILE: docs/Components/06-postgresql.md ================================================ # PostgreSQL === "Gradle" ``` kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-postgres") } ``` ## Configure Once you've added the dependency, you can configure PostgreSQL in your Stove setup: ```kotlin hl_lines="4 6-12" Stove() .with { postgresql { PostgresqlOptions( configureExposedConfiguration = { cfg -> listOf( "postgresql.host=${cfg.host}", "postgresql.port=${cfg.port}", "postgresql.database=${cfg.database}", "postgresql.username=${cfg.username}", "postgresql.password=${cfg.password}" ) } ) } }.run() ``` The `it` reference gives you access to the PostgreSQL container's connection details, which you can pass to your application. ## Migrations Stove provides a way to run database migrations before tests start: ```kotlin hl_lines="1-2 5-6" class InitialMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute( """ CREATE TABLE IF NOT EXISTS users ( id serial PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """.trimIndent() ) } } ``` Register migrations in your Stove configuration: ```kotlin Stove() .with { postgresql { PostgresqlOptions( databaseName = "testing", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } } .run() ``` ## Usage ### Executing SQL Execute DDL and DML statements: ```kotlin stove { postgresql { // Create tables shouldExecute( """ DROP TABLE IF EXISTS products; CREATE TABLE IF NOT EXISTS products ( id serial PRIMARY KEY, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT DEFAULT 0 ); """.trimIndent() ) // Insert data shouldExecute( """ INSERT INTO products (name, price, stock) VALUES ('Laptop', 999.99, 10) """.trimIndent() ) // Update data shouldExecute("UPDATE products SET stock = 5 WHERE name = 'Laptop'") // Delete data shouldExecute("DELETE FROM products WHERE stock = 0") } } ``` ### Querying Data Query data with type-safe mappers: ```kotlin hl_lines="11 13 21" data class Product( val id: Long, val name: String, val price: Double, val stock: Int ) stove { postgresql { shouldQuery( query = "SELECT * FROM products WHERE price > 500", mapper = { row -> Product( id = row.long("id"), name = row.string("name"), price = row.double("price"), stock = row.int("stock") ) } ) { products -> products.size shouldBeGreaterThan 0 products.all { it.price > 500 } shouldBe true } } } ``` ### Query with Parameters Use parameterized queries for safety: ```kotlin stove { postgresql { val minPrice = 100.0 shouldQuery( query = "SELECT * FROM products WHERE price >= ?", mapper = { row -> Product( id = row.long("id"), name = row.string("name"), price = row.double("price"), stock = row.int("stock") ) } ) { products -> products.all { it.price >= minPrice } shouldBe true } } } ``` ### Working with Nullable Fields Handle nullable columns: ```kotlin data class User( val id: Long, val name: String, val email: String?, val phone: String? ) stove { postgresql { shouldQuery( query = "SELECT * FROM users", mapper = { row -> User( id = row.long("id"), name = row.string("name"), email = row.stringOrNull("email"), phone = row.stringOrNull("phone") ) } ) { users -> users.size shouldBeGreaterThan 0 } } } ``` ### Complex Queries Execute joins and aggregations: ```kotlin data class OrderSummary( val userId: Long, val userName: String, val totalOrders: Int, val totalAmount: Double ) stove { postgresql { shouldQuery( query = """ SELECT u.id as user_id, u.name as user_name, COUNT(o.id) as total_orders, SUM(o.amount) as total_amount FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name HAVING COUNT(o.id) > 0 """.trimIndent(), mapper = { row -> OrderSummary( userId = row.long("user_id"), userName = row.string("user_name"), totalOrders = row.int("total_orders"), totalAmount = row.double("total_amount") ) } ) { summaries -> summaries.all { it.totalOrders > 0 } shouldBe true } } } ``` ### Pause and Unpause Container Test failure scenarios: ```kotlin stove { postgresql { // Database is running shouldQuery( "SELECT COUNT(*) as count FROM products", mapper = { row -> row.int("count") } ) { result -> result.first() shouldBeGreaterThanOrEqual 0 } // Pause the database pause() // Your application should handle the failure // ... // Unpause the database unpause() // Verify recovery shouldQuery( "SELECT COUNT(*) as count FROM products", mapper = { row -> row.int("count") } ) { result -> result.first() shouldBeGreaterThanOrEqual 0 } } } ``` ## Complete Example Here's a complete end-to-end test: ```kotlin hl_lines="7 12 24 28" test("should create user via API and verify in database") { stove { val userName = "John Doe" val userEmail = "john@example.com" // Create user via API http { postAndExpectBody( uri = "/users", body = CreateUserRequest(name = userName, email = userEmail).some() ) { response -> response.status shouldBe 201 response.body().name shouldBe userName } } // Verify in PostgreSQL postgresql { shouldQuery( query = "SELECT * FROM users WHERE email = ?", mapper = { row -> User( id = row.long("id"), name = row.string("name"), email = row.string("email") ) } ) { users -> users.size shouldBe 1 users.first().name shouldBe userName users.first().email shouldBe userEmail } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.name == userName && actual.email == userEmail } } } } ``` ## Integration with Application Use the bridge to access application components: ```kotlin test("should use repository to save user") { stove { val user = User(id = 1L, name = "Jane Doe", email = "jane@example.com") // Use application's repository using { save(user) } // Verify in database postgresql { shouldQuery( query = "SELECT * FROM users WHERE id = ?", mapper = { row -> User( id = row.long("id"), name = row.string("name"), email = row.string("email") ) } ) { users -> users.size shouldBe 1 users.first().name shouldBe "Jane Doe" } } } } ``` ## Batch Operations Execute multiple operations: ```kotlin stove { postgresql { // Create tables shouldExecute( """ CREATE TABLE IF NOT EXISTS categories ( id serial PRIMARY KEY, name VARCHAR(50) NOT NULL ); CREATE TABLE IF NOT EXISTS products ( id serial PRIMARY KEY, name VARCHAR(100) NOT NULL, category_id INT REFERENCES categories(id) ); """.trimIndent() ) // Insert categories listOf("Electronics", "Books", "Clothing").forEach { category -> shouldExecute("INSERT INTO categories (name) VALUES ('$category')") } // Verify all inserted shouldQuery( "SELECT name FROM categories", mapper = { it.string("name") } ) { categories -> categories.size shouldBe 3 categories shouldContain "Electronics" categories shouldContain "Books" } } } ``` ## Advanced: Direct SQL Operations Access SQL operations directly for advanced use cases: ```kotlin stove { postgresql { val ops = operations() // Execute with parameters ops.execute( "INSERT INTO users (name, email) VALUES (?, ?)", Parameter("name", "Alice"), Parameter("email", "alice@example.com") ) // Custom select operation val users = ops.select("SELECT * FROM users") { row -> User( id = row.long("id"), name = row.string("name"), email = row.string("email") ) } users.size shouldBeGreaterThan 0 } } ``` ## Multiple Databases In production, your application might connect to multiple PostgreSQL instances (e.g., separate databases for users, orders, analytics). With Stove, you can achieve the same behavior using a **single PostgreSQL container** by creating multiple databases through migrations. ### The Pattern 1. Create additional databases in migrations 2. Expose all database configurations to your application 3. Your application connects to each database as if they were separate instances ### Implementation #### Step 1: Create a Multi-Database Migration ```kotlin class CreateDatabasesMigration : DatabaseMigration { override val order: Int = 0 // Run first! override suspend fun execute(connection: PostgresSqlMigrationContext) { // Create additional databases // Note: You're connected to the default database, create others from here connection.operations.execute("CREATE DATABASE IF NOT EXISTS users_db") connection.operations.execute("CREATE DATABASE IF NOT EXISTS orders_db") connection.operations.execute("CREATE DATABASE IF NOT EXISTS analytics_db") } } class UsersDbMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { // This runs on the default database // For users_db schema, you'll set it up via application or separate connection connection.operations.execute( """ CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(100), email VARCHAR(100) ) """.trimIndent() ) } } ``` #### Step 2: Configure Stove with Multiple Database URLs ```kotlin Stove() .with { postgresql { PostgresqlOptions( databaseName = "main_db", // Default/main database configureExposedConfiguration = { cfg -> // Expose multiple database URLs to the application // All databases are on the same host:port, just different DB names listOf( // Main database "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}", // Users database (same host, different database name) "users.datasource.url=jdbc:postgresql://${cfg.host}:${cfg.port}/users_db", "users.datasource.username=${cfg.username}", "users.datasource.password=${cfg.password}", // Orders database "orders.datasource.url=jdbc:postgresql://${cfg.host}:${cfg.port}/orders_db", "orders.datasource.username=${cfg.username}", "orders.datasource.password=${cfg.password}", // Analytics database "analytics.datasource.url=jdbc:postgresql://${cfg.host}:${cfg.port}/analytics_db", "analytics.datasource.username=${cfg.username}", "analytics.datasource.password=${cfg.password}" ) } ).migrations { register() register() } } } .run() ``` #### Step 3: Application Configuration Your application should read these separate datasource configurations: ```kotlin // Spring Boot example with multiple DataSources @Configuration class DataSourceConfig { @Bean @Primary @ConfigurationProperties("spring.datasource") fun mainDataSource(): DataSource = DataSourceBuilder.create().build() @Bean @ConfigurationProperties("users.datasource") fun usersDataSource(): DataSource = DataSourceBuilder.create().build() @Bean @ConfigurationProperties("orders.datasource") fun ordersDataSource(): DataSource = DataSourceBuilder.create().build() @Bean @ConfigurationProperties("analytics.datasource") fun analyticsDataSource(): DataSource = DataSourceBuilder.create().build() } ``` ### Complete Example ```kotlin object DatabaseNames { const val USERS = "users_db" const val ORDERS = "orders_db" const val ANALYTICS = "analytics_db" } class SetupDatabasesMigration : DatabaseMigration { override val order: Int = 0 override suspend fun execute(connection: PostgresSqlMigrationContext) { listOf(DatabaseNames.USERS, DatabaseNames.ORDERS, DatabaseNames.ANALYTICS).forEach { db -> connection.operations.execute("CREATE DATABASE IF NOT EXISTS $db") } } } // Test configuration Stove() .with { postgresql { PostgresqlOptions( databaseName = "main", configureExposedConfiguration = { cfg -> val baseUrl = "jdbc:postgresql://${cfg.host}:${cfg.port}" listOf( "db.users.url=$baseUrl/${DatabaseNames.USERS}", "db.users.username=${cfg.username}", "db.users.password=${cfg.password}", "db.orders.url=$baseUrl/${DatabaseNames.ORDERS}", "db.orders.username=${cfg.username}", "db.orders.password=${cfg.password}", "db.analytics.url=$baseUrl/${DatabaseNames.ANALYTICS}", "db.analytics.username=${cfg.username}", "db.analytics.password=${cfg.password}" ) } ).migrations { register() } } springBoot( runner = { params -> myApp.run(params) } ) } .run() // In tests test("should save user and create order in separate databases") { stove { // Create user (goes to users_db) http { postAndExpectBodilessResponse("/users", body = CreateUserRequest(...).some()) { it.status shouldBe 201 } } // Create order (goes to orders_db) http { postAndExpectBodilessResponse("/orders", body = CreateOrderRequest(...).some()) { it.status shouldBe 201 } } // Verify using application repositories (each connects to its own DB) using { userRepo, orderRepo -> userRepo.count() shouldBe 1 orderRepo.count() shouldBe 1 } } } ``` ### With Provided Instances The same pattern works with provided PostgreSQL instances: ```kotlin Stove() .with { postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://shared-postgres:5432/main_db", host = "shared-postgres", port = 5432, databaseName = "main_db", username = "postgres", password = "postgres", runMigrations = true, // Creates additional databases configureExposedConfiguration = { cfg -> listOf( "db.users.url=jdbc:postgresql://${cfg.host}:${cfg.port}/users_db", "db.orders.url=jdbc:postgresql://${cfg.host}:${cfg.port}/orders_db", // ... credentials ) } ).migrations { register() } } } ``` !!! tip "Production vs Test" In production, these might be completely separate PostgreSQL instances (even in different regions). In tests, they're all in one container but behave identically from your application's perspective. ================================================ FILE: docs/Components/07-mongodb.md ================================================ # MongoDB === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-mongodb:$version") } ``` ## Configure Once you've added the dependency, you'll have access to the `mongodb` function when configuring Stove. This function configures the MongoDB Docker container that is going to be started. ```kotlin hl_lines="4 6-9" Stove() .with { mongodb { MongodbSystemOptions( configureExposedConfiguration = { cfg -> listOf( "mongodb.uri=${cfg.connectionString}", "mongodb.host=${cfg.host}", "mongodb.port=${cfg.port}" ) } ) } } .run() ``` ### Container Options Customize the MongoDB container: ```kotlin Stove() .with { mongodb { MongodbSystemOptions( container = MongoContainerOptions( registry = "docker.io", image = "mongo", tag = "6.0", containerFn = { container -> // Additional container configuration container.withEnv("MONGO_INITDB_DATABASE", "testdb") } ), configureExposedConfiguration = { cfg -> listOf( "mongodb.uri=${cfg.connectionString}", "mongodb.host=${cfg.host}", "mongodb.port=${cfg.port}" ) } ) } } .run() ``` ### Database Options Configure the default database and collection: ```kotlin Stove() .with { mongodb { MongodbSystemOptions( databaseOptions = DatabaseOptions( default = DatabaseOptions.DefaultDatabase( name = "myDatabase", collection = "myCollection" ) ), configureExposedConfiguration = { cfg -> listOf( "mongodb.uri=${cfg.connectionString}" ) } ) } } .run() ``` ### Custom Client Configuration Customize the MongoDB client settings: ```kotlin Stove() .with { mongodb { MongodbSystemOptions( configureClient = { settings -> settings.applyToConnectionPoolSettings { pool -> pool.maxSize(10) pool.minSize(1) } settings.applyToSocketSettings { socket -> socket.connectTimeout(10, TimeUnit.SECONDS) socket.readTimeout(30, TimeUnit.SECONDS) } }, configureExposedConfiguration = { cfg -> listOf("mongodb.uri=${cfg.connectionString}") } ) } } .run() ``` ### Custom Serialization Configure custom serialization for your documents: ```kotlin Stove() .with { mongodb { val customSerde = StoveSerde.jackson.anyJsonStringSerde( StoveSerde.jackson.byConfiguring { disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(MapperFeature.DEFAULT_VIEW_INCLUSION) registerModule(JavaTimeModule()) registerModule(KotlinModule.Builder().build()) } ) MongodbSystemOptions( serde = customSerde, configureExposedConfiguration = { cfg -> listOf("mongodb.uri=${cfg.connectionString}") } ) } } .run() ``` ## Migrations Stove provides a way to run migrations before tests start: ```kotlin class CreateIndexesMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: MongodbMigrationContext) { val db = connection.client.getDatabase(connection.options.databaseOptions.default.name) // Create indexes db.getCollection("users").createIndex( Indexes.ascending("email"), IndexOptions().unique(true) ) db.getCollection("products").createIndex( Indexes.compoundIndex( Indexes.ascending("category"), Indexes.descending("createdAt") ) ) } } ``` Register migrations in your Stove configuration: ```kotlin Stove() .with { mongodb { MongodbSystemOptions( configureExposedConfiguration = { cfg -> listOf("mongodb.uri=${cfg.connectionString}") } ).migrations { register() } } } .run() ``` ## Usage ### Saving Documents Save documents to MongoDB collections: ```kotlin data class User( val id: String, val name: String, val email: String, val age: Int ) stove { mongodb { val userId = ObjectId().toHexString() // Save to default collection save( instance = User(id = userId, name = "John Doe", email = "john@example.com", age = 30), objectId = userId ) // Save to specific collection save( instance = User(id = userId, name = "Jane Doe", email = "jane@example.com", age = 28), objectId = userId, collection = "users" ) } } ``` ### Getting Documents Retrieve and validate documents by ObjectId: ```kotlin hl_lines="5 12" stove { mongodb { val userId = ObjectId().toHexString() // First save the document save( instance = User(id = userId, name = "John Doe", email = "john@example.com", age = 30), objectId = userId, collection = "users" ) // Get from specific collection shouldGet(objectId = userId, collection = "users") { user -> user.id shouldBe userId user.name shouldBe "John Doe" user.email shouldBe "john@example.com" user.age shouldBe 30 } } } ``` ### Checking Non-Existence Verify that documents don't exist: ```kotlin stove { mongodb { val nonExistentId = ObjectId().toHexString() // Check default collection shouldNotExist(objectId = nonExistentId) // Check specific collection shouldNotExist(objectId = nonExistentId, collection = "users") } } ``` ### Deleting Documents Delete documents and verify deletion: ```kotlin stove { mongodb { val userId = ObjectId().toHexString() // Save a document save( instance = User(id = userId, name = "John Doe", email = "john@example.com", age = 30), objectId = userId, collection = "users" ) // Delete it shouldDelete(objectId = userId, collection = "users") // Verify deletion shouldNotExist(objectId = userId, collection = "users") } } ``` ### Querying Documents Query documents using MongoDB query syntax: ```kotlin hl_lines="13 22" stove { mongodb { // Setup test data listOf( User(id = ObjectId().toHexString(), name = "Alice", email = "alice@example.com", age = 25), User(id = ObjectId().toHexString(), name = "Bob", email = "bob@example.com", age = 35), User(id = ObjectId().toHexString(), name = "Charlie", email = "charlie@example.com", age = 28) ).forEach { user -> save(instance = user, objectId = ObjectId().toHexString(), collection = "users") } // Simple query shouldQuery( query = """{ "age": { "${'$'}gte": 30 } }""", collection = "users" ) { users -> users.size shouldBe 1 users.first().name shouldBe "Bob" } // Query with multiple conditions shouldQuery( query = """ { "${'$'}and": [ { "age": { "${'$'}gte": 25 } }, { "age": { "${'$'}lte": 30 } } ] } """.trimIndent(), collection = "users" ) { users -> users.size shouldBe 2 users.map { it.name } shouldContainAll listOf("Alice", "Charlie") } } } ``` ### Accessing the Client Directly For advanced operations, access the MongoDB client: ```kotlin stove { mongodb { val mongoClient = client() // Access the database val db = mongoClient.getDatabase("myDatabase") // List collections val collections = db.listCollectionNames().toList() // Perform custom operations db.getCollection("users") .find() .limit(10) .toList() .also { documents -> documents.size shouldBeLessThanOrEqual 10 } } } ``` ### Pause and Unpause Container Control the MongoDB container for testing failure scenarios: ```kotlin stove { mongodb { val userId = ObjectId().toHexString() // MongoDB is running save( instance = User(id = userId, name = "John", email = "john@example.com", age = 30), objectId = userId, collection = "users" ) // Pause the container pause() // Your application should handle the failure // ... // Unpause the container unpause() // Verify recovery shouldGet(objectId = userId, collection = "users") { user -> user.name shouldBe "John" } } } ``` !!! warning `pause()`, `unpause()`, and `inspect()` operations are not supported when using a provided instance. ### Container Inspection Inspect the MongoDB container: ```kotlin stove { mongodb { val info = inspect() info?.let { println("Container ID: ${it.containerId}") println("Network: ${it.network}") println("IP Address: ${it.ipAddress}") } } } ``` ## Complete Example Here's a complete end-to-end test combining HTTP, MongoDB, and Kafka: ```kotlin hl_lines="20 30 46" data class Product( val id: String, val name: String, val description: String, val price: Double, val categoryId: Int, val stock: Int, val createdAt: Instant = Instant.now() ) test("should create product and store in mongodb") { stove { val productId = ObjectId().toHexString() val productName = "Gaming Laptop" val categoryId = 1 // Mock external service wiremock { mockGet( url = "/categories/$categoryId", statusCode = 200, responseBody = Category(id = categoryId, name = "Electronics", active = true).some() ) } // Create product via API http { postAndExpectBody( uri = "/products", body = ProductCreateRequest( name = productName, description = "High-performance gaming laptop", price = 1299.99, categoryId = categoryId, stock = 10 ).some() ) { response -> response.status shouldBe 201 response.body().id shouldNotBe null } } // Verify stored in MongoDB mongodb { shouldQuery( query = """{ "name": "$productName" }""", collection = "products" ) { products -> products.size shouldBe 1 products.first().also { product -> product.name shouldBe productName product.price shouldBe 1299.99 product.categoryId shouldBe categoryId product.stock shouldBe 10 } } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.name == productName && actual.price == 1299.99 } } // Update product stock via API http { putAndExpectBodilessResponse( uri = "/products/$productId/stock", body = UpdateStockRequest(quantity = -2).some() ) { response -> response.status shouldBe 200 } } // Verify stock updated in MongoDB mongodb { shouldQuery( query = """{ "name": "$productName" }""", collection = "products" ) { products -> products.first().stock shouldBe 8 } } } } ``` ## Integration with Application Verify application behavior using the bridge: ```kotlin test("should use repository to save product") { stove { val productId = ObjectId().toHexString() val product = Product( id = productId, name = "Test Product", description = "Test Description", price = 99.99, categoryId = 1, stock = 5 ) // Use application's repository using { save(product) } // Verify in MongoDB mongodb { shouldQuery( query = """{ "name": "Test Product" }""", collection = "products" ) { products -> products.size shouldBe 1 products.first().id shouldBe productId products.first().price shouldBe 99.99 } } } } ``` ## Advanced Operations ### Aggregation Queries ```kotlin stove { mongodb { val mongoClient = client() val db = mongoClient.getDatabase("myDatabase") // Aggregation pipeline val pipeline = listOf( Aggregates.match(Filters.gte("price", 100)), Aggregates.group("${'$'}categoryId", Accumulators.sum("totalProducts", 1), Accumulators.avg("avgPrice", "${'$'}price") ), Aggregates.sort(Sorts.descending("totalProducts")) ) db.getCollection("products") .aggregate(pipeline) .toList() .also { results -> results.size shouldBeGreaterThan 0 // Each result has categoryId, totalProducts, and avgPrice } } } ``` ### Bulk Operations ```kotlin stove { mongodb { val mongoClient = client() val db = mongoClient.getDatabase("myDatabase") val collection = db.getCollection("users") // Bulk insert val users = (1..100).map { i -> Document() .append("_id", ObjectId()) .append("name", "User $i") .append("email", "user$i@example.com") .append("age", 20 + (i % 50)) } collection.insertMany(users) // Bulk update collection.updateMany( Filters.gte("age", 40), Updates.set("status", "senior") ) // Verify val seniorCount = collection.countDocuments(Filters.eq("status", "senior")) seniorCount shouldBeGreaterThan 0 } } ``` ### Transaction Support ```kotlin stove { mongodb { val mongoClient = client() mongoClient.startSession().use { session -> session.startTransaction() try { val db = mongoClient.getDatabase("myDatabase") // Perform operations in transaction db.getCollection("accounts") .updateOne( session, Filters.eq("accountId", "sender"), Updates.inc("balance", -100.0) ) db.getCollection("accounts") .updateOne( session, Filters.eq("accountId", "receiver"), Updates.inc("balance", 100.0) ) session.commitTransaction() } catch (e: Exception) { session.abortTransaction() throw e } } } } ``` ### Working with Indexes ```kotlin stove { mongodb { val mongoClient = client() val db = mongoClient.getDatabase("myDatabase") val collection = db.getCollection("users") // Create unique index collection.createIndex( Indexes.ascending("email"), IndexOptions().unique(true) ) // Create compound index collection.createIndex( Indexes.compoundIndex( Indexes.ascending("status"), Indexes.descending("createdAt") ) ) // Create text index for search collection.createIndex( Indexes.text("name") ) // List indexes collection.listIndexes().toList().also { indexes -> indexes.size shouldBeGreaterThan 1 } } } ``` ## Provided Instance (External MongoDB) For CI/CD pipelines or shared infrastructure: ```kotlin Stove() .with { mongodb { MongodbSystemOptions.provided( connectionString = System.getenv("MONGODB_URI") ?: "mongodb://localhost:27017", host = System.getenv("MONGODB_HOST") ?: "localhost", port = System.getenv("MONGODB_PORT")?.toInt() ?: 27017, cleanup = { client -> // Clean up test data after tests client.getDatabase("testdb").drop() }, configureExposedConfiguration = { cfg -> listOf( "mongodb.uri=${cfg.connectionString}", "mongodb.host=${cfg.host}", "mongodb.port=${cfg.port}" ) } ) } } .run() ``` ## Error Handling ```kotlin stove { mongodb { // Document not found val nonExistentId = ObjectId().toHexString() shouldNotExist(objectId = nonExistentId, collection = "users") // Attempting to get non-existent document throws exception assertThrows { shouldGet(objectId = nonExistentId, collection = "users") { } } // Verify existence check on existing document val existingId = ObjectId().toHexString() save( instance = User(id = existingId, name = "Existing", email = "existing@example.com", age = 25), objectId = existingId, collection = "users" ) assertThrows { shouldNotExist(objectId = existingId, collection = "users") } } } ``` ## Working with ObjectId MongoDB uses `ObjectId` as the default identifier. Stove handles this transparently: ```kotlin data class UserWithStringId( val id: String, // String representation of ObjectId val name: String, val email: String ) stove { mongodb { // Generate ObjectId val objectId = ObjectId() val stringId = objectId.toHexString() // Save with string ID save( instance = UserWithStringId(id = stringId, name = "Test", email = "test@example.com"), objectId = stringId, collection = "users" ) // Retrieve using string ID shouldGet(objectId = stringId, collection = "users") { user -> user.id shouldBe stringId user.name shouldBe "Test" } } } ``` ================================================ FILE: docs/Components/08-mssql.md ================================================ # Microsoft SQL Server (MSSQL) === "Gradle" ``` kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-mssql") } ``` ## Configure Once you've added the dependency, you'll have access to the `mssql` function when configuring Stove. This function configures the MSSQL Docker container that is going to be started. ```kotlin hl_lines="4 9-12" Stove() .with { mssql { MsSqlOptions( databaseName = "testdb", userName = "sa", password = "YourStrong@Passw0rd", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` ### Container Options Customize the MSSQL container: ```kotlin Stove() .with { mssql { MsSqlOptions( container = MsSqlContainerOptions( registry = "mcr.microsoft.com/", image = "mssql/server", tag = "2019-latest", containerFn = { container -> container.withEnv("ACCEPT_EULA", "Y") } ), applicationName = "stove-tests", databaseName = "testdb", userName = "sa", password = "YourStrong@Passw0rd", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` ## Migrations Stove provides a way to run database migrations before tests start: ```kotlin class InitialMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: MsSqlMigrationContext) { connection.operations.execute( """ CREATE TABLE Person ( PersonID INT PRIMARY KEY IDENTITY(1,1), LastName VARCHAR(255) NOT NULL, FirstName VARCHAR(255) NOT NULL, Address VARCHAR(255), City VARCHAR(255) ); """.trimIndent() ) } } class CreateOrdersTableMigration : DatabaseMigration { override val order: Int = 2 override suspend fun execute(connection: MsSqlMigrationContext) { connection.operations.execute( """ CREATE TABLE Orders ( OrderID INT PRIMARY KEY IDENTITY(1,1), PersonID INT NOT NULL, OrderDate DATETIME DEFAULT GETDATE(), Amount DECIMAL(10, 2), FOREIGN KEY (PersonID) REFERENCES Person(PersonID) ); """.trimIndent() ) } } ``` Register migrations in your Stove configuration: ```kotlin Stove() .with { mssql { MsSqlOptions( databaseName = "testdb", userName = "sa", password = "YourStrong@Passw0rd", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() register() } } } .run() ``` ## Usage ### Executing SQL Execute DDL and DML statements: ```kotlin hl_lines="11 16 24 27" stove { mssql { // Create tables shouldExecute( """ CREATE TABLE Products ( ProductID INT PRIMARY KEY IDENTITY(1,1), ProductName NVARCHAR(100) NOT NULL, Price DECIMAL(10, 2) NOT NULL, Stock INT DEFAULT 0, CreatedAt DATETIME DEFAULT GETDATE() ); """.trimIndent() ) // Insert data shouldExecute( """ INSERT INTO Products (ProductName, Price, Stock) VALUES ('Laptop', 999.99, 10) """.trimIndent() ) // Update data shouldExecute("UPDATE Products SET Stock = 5 WHERE ProductName = 'Laptop'") // Delete data shouldExecute("DELETE FROM Products WHERE Stock = 0") } } ``` ### Querying Data Query data with type-safe mappers: ```kotlin hl_lines="14 17 28-29" data class Person( val personId: Int, val lastName: String, val firstName: String, val address: String?, val city: String? ) stove { mssql { // Insert test data shouldExecute("INSERT INTO Person (LastName, FirstName, Address, City) VALUES ('Doe', 'John', '123 Main St', 'Springfield')") // Query with mapper shouldQuery( query = "SELECT * FROM Person", mapper = { resultSet -> Person( personId = resultSet.getInt(1), lastName = resultSet.getString(2), firstName = resultSet.getString(3), address = resultSet.getString(4), city = resultSet.getString(5) ) } ) { result -> result.size shouldBe 1 result.first().apply { personId shouldBe 1 lastName shouldBe "Doe" firstName shouldBe "John" address shouldBe "123 Main St" city shouldBe "Springfield" } } } } ``` ### Using Operations Directly Access SQL operations directly for advanced use cases: ```kotlin stove { mssql { ops { // Simple select val result = select("SELECT 1 AS value") { it.getInt(1) } result.first() shouldBe 1 // Execute insert execute("INSERT INTO Person (LastName, FirstName) VALUES ('Smith', 'Jane')") // Select with parameters val users = select("SELECT * FROM Person WHERE LastName = 'Smith'") { rs -> Person( personId = rs.getInt("PersonID"), lastName = rs.getString("LastName"), firstName = rs.getString("FirstName"), address = rs.getString("Address"), city = rs.getString("City") ) } users.size shouldBeGreaterThan 0 } } } ``` ### Complex Queries Execute joins, aggregations, and complex queries: ```kotlin data class OrderSummary( val personId: Int, val personName: String, val totalOrders: Int, val totalAmount: Double ) stove { mssql { // Setup test data shouldExecute("INSERT INTO Person (LastName, FirstName, Address, City) VALUES ('Doe', 'John', '123 Main St', 'NYC')") shouldExecute("INSERT INTO Orders (PersonID, Amount) VALUES (1, 100.00)") shouldExecute("INSERT INTO Orders (PersonID, Amount) VALUES (1, 250.50)") shouldExecute("INSERT INTO Orders (PersonID, Amount) VALUES (1, 75.25)") // Aggregate query shouldQuery( query = """ SELECT p.PersonID, CONCAT(p.FirstName, ' ', p.LastName) AS PersonName, COUNT(o.OrderID) AS TotalOrders, SUM(o.Amount) AS TotalAmount FROM Person p INNER JOIN Orders o ON p.PersonID = o.PersonID GROUP BY p.PersonID, p.FirstName, p.LastName HAVING COUNT(o.OrderID) > 0 """.trimIndent(), mapper = { rs -> OrderSummary( personId = rs.getInt("PersonID"), personName = rs.getString("PersonName"), totalOrders = rs.getInt("TotalOrders"), totalAmount = rs.getDouble("TotalAmount") ) } ) { summaries -> summaries.size shouldBe 1 summaries.first().apply { personName shouldBe "John Doe" totalOrders shouldBe 3 totalAmount shouldBe 425.75 } } } } ``` ### Working with Nullable Fields Handle nullable columns properly: ```kotlin data class PersonWithNullable( val personId: Int, val firstName: String, val lastName: String, val address: String?, val city: String?, val email: String? ) stove { mssql { // Insert with null values shouldExecute("INSERT INTO Person (LastName, FirstName) VALUES ('Solo', 'Han')") shouldQuery( query = "SELECT * FROM Person WHERE LastName = 'Solo'", mapper = { rs -> PersonWithNullable( personId = rs.getInt("PersonID"), firstName = rs.getString("FirstName"), lastName = rs.getString("LastName"), address = rs.getString("Address"), // Can be null city = rs.getString("City"), // Can be null email = rs.getString("Email") // Can be null ) } ) { persons -> persons.first().apply { firstName shouldBe "Han" lastName shouldBe "Solo" address shouldBe null city shouldBe null } } } } ``` ### Pause and Unpause Container Test failure scenarios: ```kotlin stove { mssql { // Database is running shouldQuery( "SELECT COUNT(*) FROM Person", mapper = { rs -> rs.getInt(1) } ) { result -> result.first() shouldBeGreaterThanOrEqual 0 } // Pause the database pause() // Your application should handle the failure // ... // Unpause the database unpause() // Verify recovery shouldQuery( "SELECT COUNT(*) FROM Person", mapper = { rs -> rs.getInt(1) } ) { result -> result.first() shouldBeGreaterThanOrEqual 0 } } } ``` ## Complete Example Here's a complete end-to-end test: ```kotlin hl_lines="15 25" data class User( val id: Int, val username: String, val email: String, val createdAt: LocalDateTime ) test("should create user via API and verify in database") { stove { val username = "johndoe" val email = "john@example.com" // Create user via API http { postAndExpectBody( uri = "/users", body = CreateUserRequest(username = username, email = email).some() ) { response -> response.status shouldBe 201 response.body().username shouldBe username } } // Verify in MSSQL mssql { shouldQuery( query = "SELECT * FROM Users WHERE Email = '$email'", mapper = { rs -> User( id = rs.getInt("UserID"), username = rs.getString("Username"), email = rs.getString("Email"), createdAt = rs.getTimestamp("CreatedAt").toLocalDateTime() ) } ) { users -> users.size shouldBe 1 users.first().apply { username shouldBe "johndoe" email shouldBe "john@example.com" } } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.username == username && actual.email == email } } } } ``` ## Integration with Application Use the bridge to access application components: ```kotlin test("should use repository to save user") { stove { val user = User(id = 0, username = "janedoe", email = "jane@example.com", createdAt = LocalDateTime.now()) // Use application's repository using { save(user) } // Verify in database mssql { shouldQuery( query = "SELECT * FROM Users WHERE Username = 'janedoe'", mapper = { rs -> User( id = rs.getInt("UserID"), username = rs.getString("Username"), email = rs.getString("Email"), createdAt = rs.getTimestamp("CreatedAt").toLocalDateTime() ) } ) { users -> users.size shouldBe 1 users.first().email shouldBe "jane@example.com" } } } } ``` ## Batch Operations Execute multiple operations: ```kotlin stove { mssql { // Create tables shouldExecute( """ CREATE TABLE Categories ( CategoryID INT PRIMARY KEY IDENTITY(1,1), CategoryName NVARCHAR(50) NOT NULL ); CREATE TABLE Products ( ProductID INT PRIMARY KEY IDENTITY(1,1), ProductName NVARCHAR(100) NOT NULL, CategoryID INT REFERENCES Categories(CategoryID) ); """.trimIndent() ) // Insert categories listOf("Electronics", "Books", "Clothing").forEach { category -> shouldExecute("INSERT INTO Categories (CategoryName) VALUES ('$category')") } // Verify all inserted shouldQuery( "SELECT CategoryName FROM Categories", mapper = { it.getString("CategoryName") } ) { categories -> categories.size shouldBe 3 categories shouldContainAll listOf("Electronics", "Books", "Clothing") } } } ``` ## Stored Procedures Test stored procedures: ```kotlin stove { mssql { // Create stored procedure shouldExecute( """ CREATE PROCEDURE GetPersonsByCity @City NVARCHAR(100) AS BEGIN SELECT * FROM Person WHERE City = @City END """.trimIndent() ) // Insert test data shouldExecute("INSERT INTO Person (LastName, FirstName, City) VALUES ('Doe', 'John', 'NYC')") shouldExecute("INSERT INTO Person (LastName, FirstName, City) VALUES ('Smith', 'Jane', 'NYC')") shouldExecute("INSERT INTO Person (LastName, FirstName, City) VALUES ('Brown', 'Bob', 'LA')") // Execute stored procedure shouldQuery( query = "EXEC GetPersonsByCity @City = 'NYC'", mapper = { rs -> Person( personId = rs.getInt("PersonID"), lastName = rs.getString("LastName"), firstName = rs.getString("FirstName"), address = rs.getString("Address"), city = rs.getString("City") ) } ) { persons -> persons.size shouldBe 2 persons.all { it.city == "NYC" } shouldBe true } } } ``` ## Transactions Test transaction behavior: ```kotlin stove { mssql { ops { // Start transaction manually via SQL execute("BEGIN TRANSACTION") try { execute("INSERT INTO Person (LastName, FirstName) VALUES ('Test', 'User1')") execute("INSERT INTO Person (LastName, FirstName) VALUES ('Test', 'User2')") // Commit transaction execute("COMMIT TRANSACTION") } catch (e: Exception) { execute("ROLLBACK TRANSACTION") throw e } // Verify val count = select("SELECT COUNT(*) FROM Person WHERE LastName = 'Test'") { it.getInt(1) } count.first() shouldBe 2 } } } ``` ## Provided Instance (External MSSQL) For CI/CD pipelines or shared infrastructure: ```kotlin Stove() .with { mssql { MsSqlOptions.provided( jdbcUrl = System.getenv("MSSQL_JDBC_URL") ?: "jdbc:sqlserver://localhost:1433;databaseName=testdb", host = System.getenv("MSSQL_HOST") ?: "localhost", port = System.getenv("MSSQL_PORT")?.toInt() ?: 1433, databaseName = "testdb", username = System.getenv("MSSQL_USERNAME") ?: "sa", password = System.getenv("MSSQL_PASSWORD") ?: "YourStrong@Passw0rd", runMigrations = true, cleanup = { operations -> operations.execute("DELETE FROM Orders") operations.execute("DELETE FROM Person") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` ## Data Types Working with various SQL Server data types: ```kotlin data class DataTypesExample( val id: Int, val intValue: Int, val bigIntValue: Long, val decimalValue: BigDecimal, val floatValue: Double, val bitValue: Boolean, val dateValue: LocalDate, val timeValue: LocalTime, val dateTimeValue: LocalDateTime, val nvarcharValue: String, val varcharValue: String ) stove { mssql { // Create table with various types shouldExecute( """ CREATE TABLE DataTypes ( ID INT PRIMARY KEY IDENTITY(1,1), IntValue INT, BigIntValue BIGINT, DecimalValue DECIMAL(18, 4), FloatValue FLOAT, BitValue BIT, DateValue DATE, TimeValue TIME, DateTimeValue DATETIME2, NVarcharValue NVARCHAR(100), VarcharValue VARCHAR(100) ) """.trimIndent() ) // Insert test data shouldExecute( """ INSERT INTO DataTypes (IntValue, BigIntValue, DecimalValue, FloatValue, BitValue, DateValue, TimeValue, DateTimeValue, NVarcharValue, VarcharValue) VALUES (42, 9223372036854775807, 1234.5678, 3.14159, 1, '2024-01-15', '14:30:00', '2024-01-15 14:30:00', N'Unicode: 日本語', 'ASCII text') """.trimIndent() ) // Query and verify shouldQuery( query = "SELECT * FROM DataTypes", mapper = { rs -> DataTypesExample( id = rs.getInt("ID"), intValue = rs.getInt("IntValue"), bigIntValue = rs.getLong("BigIntValue"), decimalValue = rs.getBigDecimal("DecimalValue"), floatValue = rs.getDouble("FloatValue"), bitValue = rs.getBoolean("BitValue"), dateValue = rs.getDate("DateValue").toLocalDate(), timeValue = rs.getTime("TimeValue").toLocalTime(), dateTimeValue = rs.getTimestamp("DateTimeValue").toLocalDateTime(), nvarcharValue = rs.getString("NVarcharValue"), varcharValue = rs.getString("VarcharValue") ) } ) { results -> results.first().apply { intValue shouldBe 42 bitValue shouldBe true nvarcharValue shouldContain "日本語" } } } } ``` ================================================ FILE: docs/Components/09-redis.md ================================================ # Redis === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-redis:$version") } ``` ## Configure ```kotlin hl_lines="4 6-9" Stove() .with { redis { RedisOptions( configureExposedConfiguration = { cfg -> listOf( "redis.host=${cfg.host}", "redis.port=${cfg.port}", "redis.password=${cfg.password}" ) } ) } }.run() ``` ## Migrations Redis supports migrations for setting up initial data or configuration: ```kotlin class SeedCacheData : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: RedisMigrationContext) { connection.connection.sync().apply { // Seed initial cache data set("config:feature-flag", "enabled") hset("defaults:settings", mapOf( "timeout" to "30", "retries" to "3" )) } } } // Register migrations redis { RedisOptions( configureExposedConfiguration = { cfg -> listOf(...) } ).migrations { register() } } ``` ## Usage The Redis component provides access to the underlying Lettuce Redis client, allowing you to test all Redis operations. ### Accessing the Redis Client Access the Redis client using the `client()` extension function: ```kotlin stove { redis { val redisClient = client() val connection = redisClient.connect() // Use the connection for Redis operations connection.close() } } ``` ### String Operations Test basic string operations: ```kotlin hl_lines="3 7 8 12" stove { redis { val connection = client().connect().sync() // SET and GET connection.set("user:123:name", "John Doe") val name = connection.get("user:123:name") name shouldBe "John Doe" // SET with expiration connection.setex("session:abc", 3600, "session-data") val ttl = connection.ttl("session:abc") ttl shouldBeGreaterThan 0 // INCREMENT connection.set("counter", "0") connection.incr("counter") connection.incr("counter") val counter = connection.get("counter") counter shouldBe "2" // Multiple keys connection.mset(mapOf( "key1" to "value1", "key2" to "value2", "key3" to "value3" )) val values = connection.mget("key1", "key2", "key3") values.size shouldBe 3 } } ``` ### Hash Operations Test Redis hash operations: ```kotlin stove { redis { val connection = client().connect().sync() // HSET and HGET connection.hset("user:123", "name", "John Doe") connection.hset("user:123", "email", "john@example.com") connection.hset("user:123", "age", "30") val name = connection.hget("user:123", "name") name shouldBe "John Doe" // HGETALL val user = connection.hgetall("user:123") user["name"] shouldBe "John Doe" user["email"] shouldBe "john@example.com" user["age"] shouldBe "30" // HMSET connection.hmset("product:456", mapOf( "name" to "Laptop", "price" to "999.99", "stock" to "10" )) // HINCRBY connection.hincrby("product:456", "stock", -1) val stock = connection.hget("product:456", "stock") stock shouldBe "9" // HDEL connection.hdel("user:123", "age") val age = connection.hget("user:123", "age") age shouldBe null } } ``` ### List Operations Test Redis list operations: ```kotlin stove { redis { val connection = client().connect().sync() // LPUSH and RPUSH connection.rpush("queue:tasks", "task1", "task2", "task3") connection.lpush("queue:tasks", "urgent-task") // LRANGE val tasks = connection.lrange("queue:tasks", 0, -1) tasks.size shouldBe 4 tasks.first() shouldBe "urgent-task" // LPOP and RPOP val firstTask = connection.lpop("queue:tasks") firstTask shouldBe "urgent-task" val lastTask = connection.rpop("queue:tasks") lastTask shouldBe "task3" // LLEN val length = connection.llen("queue:tasks") length shouldBe 2 } } ``` ### Set Operations Test Redis set operations: ```kotlin stove { redis { val connection = client().connect().sync() // SADD connection.sadd("tags:123", "kotlin", "testing", "redis") // SMEMBERS val tags = connection.smembers("tags:123") tags.size shouldBe 3 tags shouldContain "kotlin" // SISMEMBER val isKotlin = connection.sismember("tags:123", "kotlin") isKotlin shouldBe true // SREM connection.srem("tags:123", "redis") val remainingTags = connection.smembers("tags:123") remainingTags.size shouldBe 2 // Set operations connection.sadd("set1", "a", "b", "c") connection.sadd("set2", "b", "c", "d") // SINTER (intersection) val intersection = connection.sinter("set1", "set2") intersection.size shouldBe 2 intersection shouldContain "b" intersection shouldContain "c" // SUNION val union = connection.sunion("set1", "set2") union.size shouldBe 4 } } ``` ### Sorted Set Operations Test Redis sorted set operations: ```kotlin stove { redis { val connection = client().connect().sync() // ZADD connection.zadd("leaderboard", 100.0, "player1") connection.zadd("leaderboard", 250.0, "player2") connection.zadd("leaderboard", 175.0, "player3") // ZRANGE (ascending) val ascending = connection.zrange("leaderboard", 0, -1) ascending.size shouldBe 3 ascending.first() shouldBe "player1" ascending.last() shouldBe "player2" // ZREVRANGE (descending) val descending = connection.zrevrange("leaderboard", 0, -1) descending.first() shouldBe "player2" // ZSCORE val score = connection.zscore("leaderboard", "player2") score shouldBe 250.0 // ZRANK val rank = connection.zrank("leaderboard", "player3") rank shouldBe 1L // 0-indexed // ZINCRBY connection.zincrby("leaderboard", 50.0, "player1") val newScore = connection.zscore("leaderboard", "player1") newScore shouldBe 150.0 } } ``` ### Async Operations Use async operations for better performance: ```kotlin stove { redis { val connection = client().connect().async() // Async SET val setFuture = connection.set("async:key", "async:value") setFuture.await() shouldBe "OK" // Async GET val getFuture = connection.get("async:key") val value = getFuture.await() value shouldBe "async:value" // Pipeline multiple operations connection.setAutoFlushCommands(false) val futures = listOf( connection.set("key1", "value1"), connection.set("key2", "value2"), connection.set("key3", "value3") ) connection.flushCommands() futures.forEach { it.await() shouldBe "OK" } } } ``` ### Pub/Sub Operations Test Redis Pub/Sub: ```kotlin stove { redis { val pubConnection = client().connectPubSub().sync() val subConnection = client().connectPubSub().sync() // Subscribe to channel val messages = mutableListOf() subConnection.addListener(object : RedisPubSubAdapter() { override fun message(channel: String, message: String) { messages.add(message) } }) subConnection.subscribe("notifications") // Publish messages pubConnection.publish("notifications", "User logged in") pubConnection.publish("notifications", "Order created") // Wait for messages delay(1.seconds) messages.size shouldBe 2 messages shouldContain "User logged in" messages shouldContain "Order created" subConnection.unsubscribe("notifications") } } ``` ### Expiration and TTL Test key expiration: ```kotlin stove { redis { val connection = client().connect().sync() // Set with expiration connection.setex("temp:data", 5, "temporary-value") // Check TTL val ttl = connection.ttl("temp:data") ttl shouldBeGreaterThan 0 ttl shouldBeLessThanOrEqual 5 // Set expiration on existing key connection.set("permanent", "data") connection.expire("permanent", 10) val newTtl = connection.ttl("permanent") newTtl shouldBeGreaterThan 0 // Remove expiration connection.persist("permanent") val persistedTtl = connection.ttl("permanent") persistedTtl shouldBe -1 // No expiration } } ``` ### Transactions Test Redis transactions: ```kotlin stove { redis { val connection = client().connect().sync() connection.multi() connection.set("account:1:balance", "1000") connection.decrby("account:1:balance", 100) connection.incrby("account:2:balance", 100) val results = connection.exec() results.size shouldBe 3 val balance1 = connection.get("account:1:balance") balance1 shouldBe "900" val balance2 = connection.get("account:2:balance") balance2 shouldBe "100" } } ``` ### Pause and Unpause Container Test failure scenarios: ```kotlin hl_lines="11 15 19" stove { redis { val connection = client().connect().sync() // Redis is running connection.set("test", "value") connection.get("test") shouldBe "value" // Pause container pause() // Operations should fail shouldThrow { connection.get("test") } // Unpause container unpause() // Wait for recovery delay(2.seconds) // Operations should work again val value = connection.get("test") value shouldBe "value" } } ``` ## Complete Example Here's a complete caching test example: ```kotlin hl_lines="7 14 22 30" test("should cache product data in redis") { stove { val productId = "product-123" // Product not in cache - verify using client() redis { val conn = client().connect().sync() val cached = conn.get("cache:product:$productId") cached shouldBe null } // Fetch from database via API (application should cache the result) http { get("/products/$productId") { product -> product.id shouldBe productId product.name shouldNotBe null } } // Application should have cached the product - verify redis { val conn = client().connect().sync() val cachedData = conn.get("cache:product:$productId") cachedData shouldNotBe null val cachedProduct = objectMapper.readValue(cachedData, ProductResponse::class.java) cachedProduct.id shouldBe productId } // Verify TTL is set redis { val conn = client().connect().sync() val ttl = conn.ttl("cache:product:$productId") ttl shouldBeGreaterThan 0 ttl shouldBeLessThanOrEqual 3600 } } } ``` ## Integration with Application Test application caching behavior: ```kotlin test("should use redis for session management") { stove { val sessionId = UUID.randomUUID().toString() // Create session via API http { postAndExpectBody( uri = "/auth/login", body = LoginRequest(username = "user", password = "pass").some() ) { response -> response.status shouldBe 200 response.body().sessionId shouldBe sessionId } } // Verify session in Redis redis { val connection = client().connect().sync() val sessionData = connection.get("session:$sessionId") sessionData shouldNotBe null val session = objectMapper.readValue(sessionData, Session::class.java) session.username shouldBe "user" session.createdAt shouldNotBe null } // Use session http { get( uri = "/profile", headers = mapOf("X-Session-ID" to sessionId) ) { profile -> profile.username shouldBe "user" } } // Logout http { postAndExpectBodilessResponse( uri = "/auth/logout", body = LogoutRequest(sessionId = sessionId).some() ) { response -> response.status shouldBe 200 } } // Verify session removed from Redis redis { val connection = client().connect().sync() val sessionData = connection.get("session:$sessionId") sessionData shouldBe null } } } ``` ## Advanced: Custom Extensions Create reusable extensions for common patterns: ```kotlin // Custom extension functions fun RedisSystem.shouldGet(key: String, assertion: (String?) -> Unit): RedisSystem { val connection = client().connect().sync() val value = connection.get(key) assertion(value) return this } fun RedisSystem.shouldSet(key: String, value: String): RedisSystem { val connection = client().connect().sync() connection.set(key, value) return this } // Usage in tests stove { redis { shouldSet("user:123", "John Doe") shouldGet("user:123") { value -> value shouldBe "John Doe" } } } ``` ================================================ FILE: docs/Components/10-bridge.md ================================================ # Bridge The Bridge component gives you direct access to your application's dependency injection (DI) container from your tests. This lets you grab any bean or service your application has registered, which is super useful for testing internal state, verifying side effects, or setting up test data through your application's own services. ## When You'd Use This When writing end-to-end tests, you often need to: - **Check internal state** that isn't exposed through APIs - **Use application services** to set up test data - **Call domain services directly** to test business logic - **Swap out time-dependent implementations** for deterministic tests - **Verify side effects** that happen inside the application Bridge gives you a type-safe way to access any component from your application's DI container. ## Configuration Bridge is built into the supported framework starters, so no extra dependency is needed. !!! warning "Quarkus" `stove-quarkus` does not provide `bridge()` support yet. Quarkus application beans live under the Quarkus runtime classloader, so use HTTP, Kafka, database, gRPC, and tracing assertions instead. === "Spring Boot" ```kotlin dependencies { testImplementation("com.trendyol:stove-spring:$version") } ``` === "Ktor" ```kotlin dependencies { testImplementation("com.trendyol:stove-ktor:$version") } ``` === "Micronaut" ```kotlin dependencies { testImplementation("com.trendyol:stove-micronaut:$version") } ``` ### Setup Enable Bridge in your Stove configuration: ```kotlin hl_lines="5 7" Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } bridge() // Enable access to DI container springBoot( runner = { params -> myApp.run(params) }, withParameters = listOf("server.port=8080") ) } .run() ``` ## Framework Support ### Spring Boot For Spring Boot applications, Bridge provides access to the `ApplicationContext`: ```kotlin hl_lines="2" // Bridge resolves beans from ApplicationContext using { // 'this' is the UserService bean from Spring context findById(123) } ``` Under the hood, it uses `ApplicationContext.getBean()`: ```kotlin class SpringBridgeSystem(testSystem: TestSystem) : BridgeSystem(testSystem) { override fun get(klass: KClass): D = ctx.getBean(klass.java) } ``` ### Ktor Ktor Bridge supports multiple dependency injection frameworks with automatic detection: - **Koin** - Popular DI framework for Kotlin - **Ktor-DI** - Ktor's native DI plugin - **Custom** - Any DI framework via custom resolver Auto-detection is based on **runtime installation state** in the Ktor `Application` (not classpath presence only): - If `dependencies { ... }` is active, Bridge uses **Ktor-DI** - Otherwise, if Koin is active (for example `install(Koin) { ... }`), Bridge uses **Koin** - If both are active, Bridge prefers **Ktor-DI** - If neither is active, Bridge throws a setup error with guidance ```kotlin // Bridge resolves beans from your DI container using { // 'this' is the UserRepository from your DI save(user) } ``` #### DI Framework Setup **Using Koin:** ```kotlin dependencies { testImplementation("io.insert-koin:koin-ktor:$koinVersion") } // In your test setup - bridge() uses Koin when Ktor-DI is not active at runtime Stove() .with { bridge() ktor(runner = { params -> MyApp.run(params) }) } .run() ``` **Using Ktor-DI:** ```kotlin dependencies { testImplementation("io.ktor:ktor-server-di:$ktorVersion") } // In your test setup - bridge() uses Ktor-DI when dependencies { ... } is active Stove() .with { bridge() ktor(runner = { params -> MyApp.run(params) }) } .run() ``` **Using Custom Resolver:** ```kotlin // For any other DI framework (Kodein, Dagger, etc.) Stove() .with { bridge { application, type -> // type is KType - preserves generic info like List myDiContainer.resolve(type) } ktor(runner = { params -> MyApp.run(params) }) } .run() ``` #### Generic Type Resolution Bridge preserves generic type information, enabling resolution of types like `List`: ```kotlin // Works with Koin or Ktor-DI using> { forEach { service -> service.pay(order) } } ``` #### Registering Test Dependencies in Ktor Unlike Spring Boot's unified `addTestDependencies`, Ktor test dependency registration differs by DI framework: **Koin - Using Modules:** ```kotlin object MyApp { fun run( args: Array, testModules: List = emptyList() // Accept test modules ): Application { return embeddedServer(Netty, port = args.getPort()) { install(Koin) { modules( productionModule, *testModules.toTypedArray() // Add test modules ) } configureRouting() }.start(wait = false).application } } // In your test setup Stove() .with { bridge() ktor( runner = { params -> MyApp.run( params, testModules = listOf( module { // Override production beans with test doubles single(override = true) { FixedTimeProvider() } single(override = true) { MockEmailService() } } ) ) } ) } .run() ``` **Ktor-DI - Using Dependencies Block:** ```kotlin object MyApp { fun run( args: Array, testDependencies: (DependencyRegistrar.() -> Unit)? = null // Accept test registrations ): Application { return embeddedServer(Netty, port = args.getPort()) { install(DI) { dependencies { // Production dependencies provide { UserServiceImpl() } provide { SystemTimeProvider() } // Apply test overrides if provided testDependencies?.invoke(this) } } configureRouting() }.start(wait = false).application } } // In your test setup Stove() .with { bridge() ktor( runner = { params -> MyApp.run(params) { // Override production beans with test doubles provide { FixedTimeProvider() } provide { MockEmailService() } } } ) } .run() ``` !!! tip "Test Dependency Patterns" - **Koin**: Use `override = true` in test modules to replace production beans - **Ktor-DI**: Later `provide` calls override earlier ones - Both frameworks support the pattern of passing test-specific configuration to your app's run function ## Usage ### Single Bean Access Access a single bean and perform operations: ```kotlin stove { using { // 'this' refers to UserService val user = findById(123) user.name shouldBe "John Doe" user.email shouldBe "john@example.com" } } ``` ### Multiple Bean Access Access multiple beans in a single block (up to 5 beans supported): ```kotlin stove { // Two beans using { userService, orderService -> val user = userService.findById(123) val orders = orderService.findByUserId(123) orders.size shouldBeGreaterThan 0 } // Three beans using { users, products, inventory -> val product = products.findById("SKU-123") val stock = inventory.getStock(product.id) stock shouldBeGreaterThan 0 } // Four beans using { a, b, c, d -> // Work with all four services } // Five beans using { a, b, c, d, e -> // Work with all five services } } ``` ### Capturing Values for Later Use When you need to capture a value from inside the `using` block for later use, declare a variable outside the block and assign it inside: ```kotlin stove { // Declare variable outside, assign inside var userId: Long = 0 using { userId = createUser(CreateUserRequest(name = "John", email = "john@example.com")).id } // Use the captured value in subsequent operations http { get("/users/$userId") { user -> user.name shouldBe "John" } } // Capture multiple values var user: User? = null var token: String? = null using { user = register(email = "test@example.com", password = "secret") token = generateToken(user!!) } // Or use lateinit for non-nullable types lateinit var order: Order using { order = findById(orderId) } // Use captured values http { getResponse("/orders/${order.id}", headers = mapOf("Authorization" to "Bearer $token")) { response -> response.status shouldBe 200 } } } ``` !!! tip "Variable Capture Pattern" Since `using` blocks don't return values, use the pattern of declaring variables outside and assigning inside when you need to pass data between blocks. ## Use Cases ### 1. Setting Up Test Data Use application repositories to set up test data: ```kotlin test("should return user orders") { stove { // Create test data using application's repository var userId: Long = 0 using { userId = save(User(name = "Test User", email = "test@example.com")).id } using { save(Order(userId = userId, amount = 100.0)) save(Order(userId = userId, amount = 250.0)) } // Test the API http { get>("/users/$userId/orders") { orders -> orders.size shouldBe 2 orders.sumOf { it.amount } shouldBe 350.0 } } } } ``` ### 2. Verifying Internal State Verify state that isn't exposed through APIs: ```kotlin test("should update inventory after order") { stove { val productId = "PROD-123" // Check initial inventory var initialStock = 0 using { initialStock = getStock(productId) } // Place an order via API http { postAndExpectBodilessResponse( uri = "/orders", body = CreateOrderRequest(productId = productId, quantity = 5).some() ) { response -> response.status shouldBe 201 } } // Verify inventory was reduced (internal side effect) using { getStock(productId) shouldBe (initialStock - 5) } } } ``` ### 3. Testing Domain Services Directly Test business logic that may be complex to trigger through APIs: ```kotlin test("should calculate shipping cost correctly") { stove { using { // Test various scenarios directly calculate(weight = 1.0, destination = "US") shouldBe 5.99 calculate(weight = 5.0, destination = "US") shouldBe 12.99 calculate(weight = 1.0, destination = "EU") shouldBe 15.99 } } } ``` ### 4. Triggering Scheduled Jobs Manually trigger scheduled jobs for testing: ```kotlin test("should process pending orders when scheduler runs") { stove { // Setup: Create pending orders using { save(Order(status = "PENDING", createdAt = Instant.now().minusHours(2))) save(Order(status = "PENDING", createdAt = Instant.now().minusHours(3))) } // Trigger the scheduled job manually using { processPendingOrders() } // Verify orders were processed using { findByStatus("PENDING").size shouldBe 0 findByStatus("PROCESSED").size shouldBe 2 } } } ``` ### 5. Time Control Control time-dependent behavior: ```kotlin hl_lines="9 18 34" // First, create a testable time provider interface interface TimeProvider { fun now(): Instant } // Production implementation class SystemTimeProvider : TimeProvider { override fun now(): Instant = Instant.now() } // Test implementation class FixedTimeProvider(private var time: Instant) : TimeProvider { override fun now(): Instant = time fun advance(duration: Duration) { time = time.plus(duration) } } // Register test implementation in your Stove setup addTestDependencies { bean(isPrimary = true) { FixedTimeProvider(Instant.parse("2024-01-01T00:00:00Z")) } } // Use in tests test("should expire session after timeout") { stove { // Create session and capture the session ID var sessionId: String = "" http { postAndExpectBody("/login", body = credentials.some()) { response -> sessionId = response.body().sessionId } } // Advance time past session timeout using { advance(Duration.ofHours(2)) } // Session should be expired http { getResponse("/protected", headers = mapOf("Session-ID" to sessionId)) { response -> response.status shouldBe 401 } } } } ``` ### 6. Event Verification Capture and verify domain events: ```kotlin // Test event listener (registered via addTestDependencies) class TestEventCapture { private val events = ConcurrentLinkedQueue() @EventListener fun capture(event: Any) { events.add(event) } inline fun getEvents(): List = events.filterIsInstance() fun clear() = events.clear() } test("should publish UserCreatedEvent when user registers") { stove { // Clear previous events using { clear() } // Perform action http { postAndExpectBodilessResponse("/users", body = newUser.some()) { it.status shouldBe 201 } } // Verify event was published using { val events = getEvents() events.size shouldBe 1 events.first().email shouldBe newUser.email } } } ``` ## Test Bean Registration Register test-specific beans using `addTestDependencies`: **Spring Boot 2.x / 3.x:** ```kotlin import com.trendyol.stove.addTestDependencies Stove() .with { bridge() springBoot( runner = { params -> runApplication(*params) { addTestDependencies { // Replace production beans with test doubles bean(isPrimary = true) { FixedTimeProvider(Instant.now()) } bean(isPrimary = true) { MockEmailService() } // Add test utilities bean() bean() } } } ) } .run() ``` **Spring Boot 4.x:** ```kotlin import com.trendyol.stove.addTestDependencies4x Stove() .with { bridge() springBoot( runner = { params -> runApplication(*params) { addTestDependencies4x { // Replace production beans with test doubles registerBean(primary = true) { FixedTimeProvider(Instant.now()) } registerBean(primary = true) { MockEmailService() } // Add test utilities registerBean() registerBean() } } } ) } .run() ``` ### Alternative: Using `addInitializers` Directly For more control, you can use `addInitializers` with `stoveSpringRegistrar`: ```kotlin // Spring Boot 2.x / 3.x addInitializers(stoveSpringRegistrar { bean(isPrimary = true) { FixedTimeProvider(Instant.now()) } bean() }) // Spring Boot 4.x addInitializers(stoveSpring4xRegistrar { registerBean(primary = true) { FixedTimeProvider(Instant.now()) } registerBean() }) ``` ## Integration with Other Systems Bridge works seamlessly with other Stove systems: ```kotlin test("should process order end-to-end") { stove { val orderId = UUID.randomUUID().toString() // Mock external payment service wiremock { mockPost("/payments/charge", statusCode = 200, responseBody = PaymentResult(success = true).some()) } // Create order via API http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(id = orderId, amount = 99.99).some() ) { response -> response.status shouldBe 201 } } // Verify in database using application's repository using { val order = findById(orderId) order.status shouldBe "PAID" order.paymentId shouldNotBe null } // Verify Kafka event kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.orderId == orderId } } // Verify in Couchbase (if using) couchbase { shouldGet("orders", orderId) { order -> order.status shouldBe "PAID" } } // Access domain service for additional verification using { getTodaysTotalRevenue() shouldBeGreaterThanOrEqual 99.99 } } } ``` ## Best Practices ### 1. Use Bridge for Setup, HTTP for Actions ```kotlin // ✅ Good: Use bridge for setup, HTTP for testing using { save(Product(id = "123", name = "Test", price = 99.99)) } http { get("/products/123") { product -> product.name shouldBe "Test" } } // ❌ Avoid: Using bridge for everything using { create(product) val retrieved = findById("123") // Not testing actual API retrieved.name shouldBe "Test" } ``` ### 2. Prefer Application Services Over Direct Repository Access ```kotlin // ✅ Good: Use application services that encapsulate business logic using { createOrder(CreateOrderRequest(...)) // Triggers all business logic } // ⚠️ Be careful: Direct repository access bypasses business logic using { save(Order(...)) // No validation, no events, no side effects } ``` ### 3. Clean Up Test Data ```kotlin // Use cleanup functions or explicit cleanup in tests stove { var userId: Long = 0 using { userId = save(user).id } try { // Test logic http { /* ... */ } } finally { // Cleanup using { deleteById(userId) } } } ``` ### 4. Keep Test Beans Minimal Only replace what's necessary: ```kotlin // ✅ Good: Replace only time-sensitive components addTestDependencies { bean(isPrimary = true) { Clock.fixed(fixedInstant, ZoneId.UTC) } } // ❌ Avoid: Replacing too many components (reduces test value) addTestDependencies { bean(isPrimary = true) { MockUserService() } bean(isPrimary = true) { MockOrderService() } bean(isPrimary = true) { MockPaymentService() } } ``` ## Summary The Bridge component enables: | Capability | Example Use Case | |------------|-----------------| | **Bean Access** | Resolve any bean from DI container | | **State Verification** | Check internal state not exposed by APIs | | **Test Setup** | Create test data using application services | | **Time Control** | Replace time providers for deterministic tests | | **Event Capture** | Verify domain events were published | | **Job Triggering** | Manually trigger scheduled tasks | | **Service Testing** | Test domain services directly | Bridge is essential for comprehensive e2e testing, allowing you to verify and control aspects of your application that aren't accessible through external interfaces alone. ================================================ FILE: docs/Components/11-provided-instances.md ================================================ # Provided Instances (Testcontainer-less Mode) Stove supports using externally provided infrastructure instances instead of testcontainers. This is particularly useful for: - **CI/CD pipelines** with shared infrastructure - **Reducing startup time** by reusing existing instances - **Lower memory/CPU usage** by avoiding container overhead - **Working with pre-configured environments** ## Overview Instead of starting a testcontainer, you can configure Stove to connect to an existing instance using the `.provided(...)` companion function on the options class itself. ## Core Concept Each system's options class (e.g., `CouchbaseSystemOptions`, `PostgresqlOptions`) has a companion function called `provided(...)` that returns a specialized options subclass configured for external instances. ## Usage Pattern All systems follow the same pattern: ```kotlin hl_lines="5 15" Stove() .with { // Option 1: Container-based (default) systemName { SystemOptions( // System-specific options cleanup = { client -> /* cleanup logic */ }, configureExposedConfiguration = { cfg -> listOf("property=${cfg.value}") } ) } // Option 2: Provided instance using .provided() companion function systemName { SystemOptions.provided( // Connection parameters for external instance runMigrations = true, cleanup = { client -> /* cleanup logic */ }, configureExposedConfiguration = { cfg -> listOf("property=${cfg.value}") } ) } } .run() ``` ## Supported Systems ### Couchbase ```kotlin hl_lines="24-25" // Container-based with cleanup Stove() .with { couchbase { CouchbaseSystemOptions( defaultBucket = "myBucket", cleanup = { cluster -> cluster.query("DELETE FROM `myBucket` WHERE type = 'test'") }, configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } } .run() // Provided instance Stove() .with { couchbase { CouchbaseSystemOptions.provided( connectionString = "couchbase://localhost:8091", username = "admin", password = "password", defaultBucket = "myBucket", runMigrations = true, cleanup = { cluster -> cluster.query("DELETE FROM `myBucket` WHERE type = 'test'") }, configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } } .run() ``` ### Cassandra ```kotlin // Container-based Stove() .with { cassandra { CassandraSystemOptions( keyspace = "my_keyspace", configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ) } } .run() // Provided instance Stove() .with { cassandra { CassandraSystemOptions.provided( host = "cassandra-host", port = 9042, datacenter = "datacenter1", keyspace = "my_keyspace", runMigrations = true, cleanup = { session -> session.execute("TRUNCATE my_keyspace.users") }, configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ) } } .run() ``` ### Kafka ```kotlin // Container-based Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptorClasses=${cfg.interceptorClass}" ) } ) } } .run() // Provided instance Stove() .with { kafka { KafkaSystemOptions.provided( bootstrapServers = "localhost:9092", configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptorClasses=${cfg.interceptorClass}" ) } ) } } .run() ``` ### Redis ```kotlin // Container-based Stove() .with { redis { RedisOptions( cleanup = { client -> client.connect().sync().flushdb() }, configureExposedConfiguration = { cfg -> listOf( "redis.host=${cfg.host}", "redis.port=${cfg.port}", "redis.password=${cfg.password}" ) } ) } } .run() // Provided instance Stove() .with { redis { RedisOptions.provided( host = "localhost", port = 6379, password = "password", database = 8, cleanup = { client -> client.connect().sync().flushdb() }, configureExposedConfiguration = { cfg -> listOf( "redis.host=${cfg.host}", "redis.port=${cfg.port}", "redis.password=${cfg.password}" ) } ) } } .run() ``` ### PostgreSQL ```kotlin // Container-based Stove() .with { postgresql { PostgresqlOptions( databaseName = "testdb", cleanup = { operations -> operations.execute("DELETE FROM users WHERE email LIKE '%@test.com'") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() // Provided instance Stove() .with { postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://localhost:5432/testdb", host = "localhost", port = 5432, databaseName = "testdb", username = "postgres", password = "postgres", runMigrations = true, cleanup = { operations -> operations.execute("DELETE FROM users WHERE email LIKE '%@test.com'") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` ### MySQL ```kotlin // Container-based Stove() .with { mysql { MySqlOptions( databaseName = "testdb", cleanup = { operations -> operations.execute("DELETE FROM users WHERE email LIKE '%@test.com'") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() // Provided instance Stove() .with { mysql { MySqlOptions.provided( jdbcUrl = "jdbc:mysql://localhost:3306/testdb", host = "localhost", port = 3306, databaseName = "testdb", username = "root", password = "password", runMigrations = true, cleanup = { operations -> operations.execute("DELETE FROM users WHERE email LIKE '%@test.com'") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` ### MSSQL ```kotlin // Container-based Stove() .with { mssql { MsSqlOptions( applicationName = "stove-tests", databaseName = "testdb", userName = "sa", password = "YourStrong@Passw0rd", cleanup = { operations -> operations.execute("DELETE FROM Orders WHERE OrderDate < GETDATE() - 1") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() // Provided instance Stove() .with { mssql { MsSqlOptions.provided( jdbcUrl = "jdbc:sqlserver://localhost:1433;databaseName=testdb", host = "localhost", port = 1433, databaseName = "testdb", username = "sa", password = "YourStrong@Passw0rd", runMigrations = true, cleanup = { operations -> operations.execute("DELETE FROM Orders WHERE OrderDate < GETDATE() - 1") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` ### MongoDB ```kotlin // Container-based Stove() .with { mongodb { MongodbSystemOptions( cleanup = { client -> client.getDatabase("testdb").drop() }, configureExposedConfiguration = { cfg -> listOf( "mongodb.uri=${cfg.connectionString}", "mongodb.host=${cfg.host}", "mongodb.port=${cfg.port}" ) } ) } } .run() // Provided instance Stove() .with { mongodb { MongodbSystemOptions.provided( connectionString = "mongodb://localhost:27017", host = "localhost", port = 27017, cleanup = { client -> client.getDatabase("testdb").drop() }, configureExposedConfiguration = { cfg -> listOf( "mongodb.uri=${cfg.connectionString}", "mongodb.host=${cfg.host}", "mongodb.port=${cfg.port}" ) } ) } } .run() ``` ### Elasticsearch ```kotlin // Container-based Stove() .with { elasticsearch { ElasticsearchSystemOptions( cleanup = { esClient -> esClient.indices().delete { it.index("test-*") } }, configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ) } } .run() // Provided instance Stove() .with { elasticsearch { ElasticsearchSystemOptions.provided( host = "localhost", port = 9200, password = "", // Leave empty if security is disabled runMigrations = true, cleanup = { esClient -> esClient.indices().delete { it.index("test-*") } }, configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.port=${cfg.port}" ) } ) } } .run() ``` ## Cleanup Function The `cleanup` parameter is available for both container-based and provided instance modes. It executes during `close()` before the system is stopped - this ensures cleanup runs after all tests have completed. ### Use Cases 1. **Clear test data** from previous runs 2. **Reset state** to a known baseline 3. **Delete test-specific records** that shouldn't persist ### Example with Container Mode and keepDependenciesRunning The cleanup function is especially useful when using containers with `keepDependenciesRunning`: ```kotlin Stove { keepDependenciesRunning() }.with { couchbase { CouchbaseSystemOptions( defaultBucket = "myBucket", cleanup = { cluster -> // Clean test data between runs when reusing containers cluster.query("DELETE FROM `myBucket` WHERE type = 'test'") }, configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } }.run() ``` ## Migration Handling When using provided instances, migrations are controlled by the `runMigrations` parameter in the `.provided()` function: - **`runMigrations = true` (default for databases)**: Migrations will run on every test execution - **`runMigrations = false` (default for Kafka/Redis)**: Migrations are skipped ```kotlin hl_lines="4 11" Stove() .with { postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://localhost:5432/mydb", host = "localhost", port = 5432, databaseName = "mydb", username = "user", password = "pass", runMigrations = false, // Schema already exists configureExposedConfiguration = { cfg -> listOf(/* ... */) } ) } } .run() ``` ## Limitations When using provided instances, some operations are not available: - **`pause()`** - Cannot pause an external instance - **`unpause()`** - Cannot unpause an external instance - **`inspect()`** - Container inspection not available These methods will log a warning and return without effect when called on a provided instance. ## Complete Example Here's a complete setup for a CI/CD pipeline using provided instances: ```kotlin class TestSetup : AbstractProjectConfig() { override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } bridge() couchbase { CouchbaseSystemOptions.provided( connectionString = System.getenv("COUCHBASE_CONNECTION_STRING"), username = System.getenv("COUCHBASE_USERNAME"), password = System.getenv("COUCHBASE_PASSWORD"), defaultBucket = "app-bucket", runMigrations = true, cleanup = { cluster -> cluster.query("DELETE FROM `app-bucket` WHERE _type = 'test'") }, configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } kafka { KafkaSystemOptions.provided( bootstrapServers = System.getenv("KAFKA_BOOTSTRAP_SERVERS"), configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptorClasses=${cfg.interceptorClass}" ) } ) } springBoot( runner = { params -> com.example.Application.run(params) } ) } .run() } override suspend fun afterProject() { Stove.stop() } } ``` ## Test Isolation with Shared Infrastructure !!! warning "Critical: Prevent Test Run Collisions" When using provided instances (shared infrastructure), **multiple test runs can interfere with each other** if they use the same resource names. This is especially important in CI/CD pipelines where parallel builds may run against the same infrastructure. ### The Problem Consider this scenario: - Build #1 creates records in `orders` table - Build #2 starts while Build #1 is still running - Build #2 reads Build #1's test data → **Test failures!** - Both builds try to create the same Kafka topic → **Conflicts!** ### The Solution: Unique Resource Prefixes Generate unique prefixes for each test run and use them for all resource names: ```kotlin object TestRunContext { // Unique prefix for this test run val runId: String = System.getenv("CI_JOB_ID") ?: System.getenv("BUILD_NUMBER") ?: UUID.randomUUID().toString().take(8) // Resource names with unique prefixes val databaseName = "testdb_$runId" val topicPrefix = "test_${runId}_" val indexPrefix = "test_${runId}_" val bucketPrefix = "test_${runId}_" val cacheKeyPrefix = "test:$runId:" } ``` ### Implementation by System #### PostgreSQL / MSSQL - Unique Database ```kotlin Stove() .with { postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://shared-db:5432/${TestRunContext.databaseName}", host = "shared-db", port = 5432, databaseName = TestRunContext.databaseName, username = "postgres", password = "postgres", runMigrations = true, // Creates tables in unique database cleanup = { ops -> // Optional: cleanup is less critical with unique database ops.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;") }, configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } springBoot( withParameters = listOf( "spring.datasource.url=jdbc:postgresql://shared-db:5432/${TestRunContext.databaseName}" ) ) } ``` !!! tip "Database Creation" You can create the database using Stove's migration system: ```kotlin class CreateDatabaseMigration : DatabaseMigration { override val order: Int = 0 // Run first override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute( "CREATE DATABASE IF NOT EXISTS ${TestRunContext.databaseName}" ) } } ``` !!! tip "Multiple Databases" If your application uses multiple databases in production (e.g., separate databases for users, orders, analytics), you can create all of them via migrations and expose separate connection URLs: ```kotlin configureExposedConfiguration = { cfg -> val baseUrl = "jdbc:postgresql://${cfg.host}:${cfg.port}" listOf( "db.users.url=$baseUrl/users_${TestRunContext.runId}", "db.orders.url=$baseUrl/orders_${TestRunContext.runId}", "db.analytics.url=$baseUrl/analytics_${TestRunContext.runId}", // ... common credentials ) } ``` See [PostgreSQL - Multiple Databases](06-postgresql.md#multiple-databases) for a complete guide. #### Kafka - Unique Topic Prefix ```kotlin Stove() .with { kafka { KafkaSystemOptions.provided( bootstrapServers = "shared-kafka:9092", topicSuffixes = TopicSuffixes( // These are suffixes for error/retry topics error = ".error", retry = ".retry" ), cleanup = { admin -> // Delete only topics with our prefix val ourTopics = admin.listTopics().names().get() .filter { it.startsWith(TestRunContext.topicPrefix) } if (ourTopics.isNotEmpty()) { admin.deleteTopics(ourTopics).all().get() } }, configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.topicPrefix=${TestRunContext.topicPrefix}" ) } ) } springBoot( withParameters = listOf( // Application uses this prefix for all topic names "kafka.topic.orders=${TestRunContext.topicPrefix}orders", "kafka.topic.payments=${TestRunContext.topicPrefix}payments", "kafka.topic.notifications=${TestRunContext.topicPrefix}notifications" ) ) } ``` #### Elasticsearch - Unique Index Prefix ```kotlin Stove() .with { elasticsearch { ElasticsearchSystemOptions.provided( host = "shared-elasticsearch", port = 9200, password = "", runMigrations = true, cleanup = { esClient -> // Delete only indices with our prefix esClient.indices().delete { it.index("${TestRunContext.indexPrefix}*") } }, configureExposedConfiguration = { cfg -> listOf( "elasticsearch.host=${cfg.host}", "elasticsearch.indexPrefix=${TestRunContext.indexPrefix}" ) } ) } springBoot( withParameters = listOf( "elasticsearch.index.products=${TestRunContext.indexPrefix}products", "elasticsearch.index.orders=${TestRunContext.indexPrefix}orders" ) ) } ``` #### Couchbase - Unique Document Prefix or Scope ```kotlin Stove() .with { couchbase { CouchbaseSystemOptions.provided( connectionString = "couchbase://shared-couchbase:8091", username = "admin", password = "password", defaultBucket = "shared-bucket", runMigrations = true, cleanup = { cluster -> // Delete only documents with our prefix cluster.query( "DELETE FROM `shared-bucket` WHERE META().id LIKE '${TestRunContext.bucketPrefix}%'" ) }, configureExposedConfiguration = { cfg -> listOf( "couchbase.documentPrefix=${TestRunContext.bucketPrefix}" ) } ) } springBoot( withParameters = listOf( "couchbase.documentPrefix=${TestRunContext.bucketPrefix}" ) ) } ``` #### MongoDB - Unique Database or Collection Prefix ```kotlin Stove() .with { mongodb { MongodbSystemOptions.provided( connectionString = "mongodb://shared-mongo:27017", host = "shared-mongo", port = 27017, cleanup = { client -> // Drop our unique database client.getDatabase(TestRunContext.databaseName).drop() }, configureExposedConfiguration = { cfg -> listOf( "mongodb.database=${TestRunContext.databaseName}" ) } ) } springBoot( withParameters = listOf( "spring.data.mongodb.database=${TestRunContext.databaseName}" ) ) } ``` #### Redis - Unique Key Prefix or Database Number ```kotlin Stove() .with { redis { // Use unique database number (0-15) or key prefix val redisDb = (TestRunContext.runId.hashCode() and 0xF) // 0-15 RedisOptions.provided( host = "shared-redis", port = 6379, password = "", database = redisDb, cleanup = { client -> // Flush only our database client.connect().sync().flushdb() }, configureExposedConfiguration = { cfg -> listOf( "spring.redis.database=$redisDb" ) } ) } } ``` ### Complete CI/CD Example ```kotlin object TestRunContext { val runId: String = System.getenv("CI_JOB_ID") ?: System.getenv("GITHUB_RUN_ID") ?: System.getenv("BUILD_NUMBER") ?: UUID.randomUUID().toString().take(8) val databaseName = "test_$runId" val topicPrefix = "test_${runId}_" val indexPrefix = "test_${runId}_" val keyPrefix = "test:$runId:" init { println("Test Run ID: $runId") println("Database: $databaseName") println("Topic Prefix: $topicPrefix") } } class TestConfig : AbstractProjectConfig() { override suspend fun beforeProject() { Stove() .with { postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://db:5432/${TestRunContext.databaseName}", databaseName = TestRunContext.databaseName, // ... other config ) } kafka { KafkaSystemOptions.provided( bootstrapServers = "kafka:9092", cleanup = { admin -> val topics = admin.listTopics().names().get() .filter { it.startsWith(TestRunContext.topicPrefix) } if (topics.isNotEmpty()) admin.deleteTopics(topics).all().get() }, // ... other config ) } elasticsearch { ElasticsearchSystemOptions.provided( host = "elasticsearch", port = 9200, cleanup = { es -> es.indices().delete { it.index("${TestRunContext.indexPrefix}*") } }, // ... other config ) } springBoot( runner = { params -> myApp.run(params) }, withParameters = listOf( "spring.datasource.url=jdbc:postgresql://db:5432/${TestRunContext.databaseName}", "kafka.topic.orders=${TestRunContext.topicPrefix}orders", "elasticsearch.index.products=${TestRunContext.indexPrefix}products" ) ) } .run() } override suspend fun afterProject() { Stove.stop() // Resources cleaned up by cleanup functions } } ``` ### Best Practices for Test Isolation | Practice | Description | |----------|-------------| | **Use CI Job ID** | Most CI systems provide unique job/build IDs - use them | | **Prefix everything** | Database names, topics, indices, keys - all should be unique | | **Clean up after** | Use cleanup functions to remove test data | | **Short prefixes** | Keep prefixes short but unique (8 chars usually enough) | | **Log the prefix** | Print the run ID at test start for debugging | | **Application support** | Your app must read resource names from configuration | ### Debugging Isolation Issues If tests fail intermittently in CI: 1. **Check for hardcoded names:** ```kotlin // ❌ Bad - hardcoded val topic = "orders" // ✅ Good - configurable val topic = config.getString("kafka.topic.orders") ``` 2. **Verify cleanup runs:** ```kotlin cleanup = { admin -> println("Cleaning up topics with prefix: ${TestRunContext.topicPrefix}") // ... cleanup code } ``` 3. **Check parallel job interference:** ```bash # In CI logs, look for overlapping run IDs grep "Test Run ID" build-*.log ``` ================================================ FILE: docs/Components/12-grpc.md ================================================ # gRPC === "Gradle" ``` kotlin dependencies { testImplementation("com.trendyol:stove-grpc:$version") } ``` ## Configure Once you've added the dependency, you'll have access to the `grpc` function when configuring Stove: ```kotlin hl_lines="3 5-6" Stove() .with { grpc { GrpcSystemOptions( host = "localhost", port = 50051 ) } } .run() ``` ### Configuration Options ```kotlin data class GrpcSystemOptions( /** * The gRPC server host. */ val host: String, /** * The gRPC server port. */ val port: Int, /** * Whether to use plaintext (no TLS). Default is true for testing. */ val usePlaintext: Boolean = true, /** * Request timeout duration (default: 30 seconds). */ val timeout: Duration = 30.seconds, /** * List of client interceptors for logging, auth, tracing, etc. */ val interceptors: List = emptyList(), /** * Default metadata (headers) to send with every request. */ val metadata: Map = emptyMap(), /** * Factory function for creating the underlying ManagedChannel. */ val createChannel: (host: String, port: Int) -> ManagedChannel = { h, p -> defaultChannelBuilder(h, p, usePlaintext, timeout, interceptors, metadata) }, /** * Factory function for creating Wire's GrpcClient with resources. */ val createWireClient: (host: String, port: Int) -> WireClientResources = { h, p -> defaultWireGrpcClient(h, p, timeout, metadata) } ) ``` ### With Authentication ```kotlin grpc { GrpcSystemOptions( host = "localhost", port = 50051, metadata = mapOf("authorization" to "Bearer $token"), interceptors = listOf(LoggingInterceptor()) ) } ``` ## Usage Stove's gRPC module supports multiple gRPC providers through a provider-agnostic design: - **Wire clients** (`wireClient`) - For Wire-generated clients - **Typed channel** (`channel`) - For any stub with a Channel constructor - **Custom providers** (`withEndpoint`) - For any gRPC library - **Raw channel** (`rawChannel`) - For advanced scenarios ### Wire Clients For services generated with [Wire](https://github.com/square/wire): ```kotlin hl_lines="3 5" stove { grpc { wireClient { val response = SayHello().execute(HelloRequest(name = "World")) response.message shouldBe "Hello, World!" } } } ``` ### Typed Channel (grpc-kotlin and Wire stubs) For any stub that takes a Channel constructor. This works with both grpc-kotlin generated stubs and Wire-generated stubs: ```kotlin hl_lines="3 5" stove { grpc { channel { // 'this' is the stub - direct method calls val response = sayHello(HelloRequest(name = "World")) response.message shouldBe "Hello, World!" } } } ``` #### With Per-Call Metadata ```kotlin stove { grpc { channel( metadata = mapOf("authorization" to "Bearer custom-token") ) { val response = sayHello(HelloRequest(name = "Authenticated")) response.message shouldBe "Hello, Authenticated!" } } } ``` ### Custom Providers For any other gRPC library, use `withEndpoint` with a factory function: ```kotlin stove { grpc { withEndpoint({ host, port -> // Create your client however you want MyCustomGrpcClient.connect(host, port) }) { // 'this' is your client this.call() shouldBe expected } } } ``` ### Raw Channel Access For advanced scenarios where you need full control: ```kotlin stove { grpc { rawChannel { channel -> // Full control over channel val stub = GreeterGrpc.newBlockingStub(channel) val response = stub.sayHello(request) response.message shouldBe "Hello!" } } } ``` ## Streaming All streaming types work naturally with Kotlin coroutines. ### Server Streaming ```kotlin stove { grpc { channel { val responses = serverStream(request).toList() responses.size shouldBe 5 responses[0].message shouldBe "Item 0" responses[4].message shouldBe "Item 4" } } } ``` ### Client Streaming ```kotlin stove { grpc { channel { val requestFlow = flow { emit(Request(message = "First")) emit(Request(message = "Second")) emit(Request(message = "Third")) } val response = clientStream(requestFlow) response.message shouldBe "Received: First, Second, Third" response.count shouldBe 3 } } } ``` ### Bidirectional Streaming ```kotlin stove { grpc { channel { val requestFlow = flow { emit(Request(message = "A")) emit(Request(message = "B")) } val responses = bidiStream(requestFlow).toList() responses.size shouldBe 2 responses[0].message shouldBe "Echo: A" responses[1].message shouldBe "Echo: B" } } } ``` ## Wire Client Details ### Direct GrpcClient Access ```kotlin stove { grpc { rawWireClient { client -> val service = client.create(GreeterServiceClient::class) val response = service.SayHello().execute(HelloRequest(name = "Direct")) response.message shouldBe "Hello, Direct!" } } } ``` ### Wire Client with Custom OkHttp Configuration ```kotlin stove { grpc { withEndpoint({ host, port -> val okHttpClient = OkHttpClient.Builder() .protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE)) .addInterceptor { chain -> val request = chain.request().newBuilder() .addHeader("authorization", "Bearer my-token") .build() chain.proceed(request) } .build() GrpcClient.Builder() .client(okHttpClient) .baseUrl("http://$host:$port") .build() .create(GreeterServiceClient::class) }) { val response = SayHello().execute(HelloRequest(name = "Custom")) response.message shouldBe "Hello, Custom!" } } } ``` ## Authentication & Interceptors ### Global Interceptors ```kotlin class LoggingInterceptor : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall { println("Calling: ${method.fullMethodName}") return next.newCall(method, callOptions) } } Stove() .with { grpc { GrpcSystemOptions( host = "localhost", port = 50051, interceptors = listOf(LoggingInterceptor()) ) } } ``` ### Per-Call Metadata ```kotlin stove { grpc { // Metadata is applied via interceptor automatically channel( metadata = mapOf( "authorization" to "Bearer jwt-token", "x-request-id" to "12345" ) ) { val response = secureEndpoint(request) response.success shouldBe true } } } ``` ## Error Handling ### Testing Authentication Errors ```kotlin stove { grpc { // Wire client - throws GrpcException wireClient { val exception = shouldThrow { SecureCall().execute(Request(message = "Hello")) } exception.grpcStatus shouldBe GrpcStatus.UNAUTHENTICATED } // grpc-kotlin - throws StatusException channel { val exception = shouldThrow { secureCall(request) } exception.status.code shouldBe Status.Code.UNAUTHENTICATED } } } ``` ### Testing Not Found ```kotlin stove { grpc { channel { val exception = shouldThrow { getUser(GetUserRequest(id = 999999)) } exception.status.code shouldBe Status.Code.NOT_FOUND } } } ``` ## Complete Example Here's a complete test example with various gRPC operations: ```kotlin test("should perform gRPC operations") { stove { // Test unary call grpc { channel { val response = createUser(CreateUserRequest(name = "John", email = "john@example.com")) response.id shouldNotBe null response.name shouldBe "John" } } // Test with authentication grpc { channel( metadata = mapOf("authorization" to "Bearer admin-token") ) { val users = listUsers(ListUsersRequest(limit = 10)).toList() users.size shouldBeGreaterThan 0 } } // Test error handling grpc { channel { shouldThrow { getUser(GetUserRequest(id = -1)) }.status.code shouldBe Status.Code.INVALID_ARGUMENT } } } } ``` ## Integration with Other Components ### gRPC + Database ```kotlin stove { // Create via gRPC var userId: Long = 0 grpc { channel { val response = createUser(CreateUserRequest(name = "John")) userId = response.id } } // Verify in database postgresql { shouldQuery( query = "SELECT * FROM users WHERE id = $userId", mapper = { row -> User(row.long("id"), row.string("name")) } ) { users -> users.size shouldBe 1 users.first().name shouldBe "John" } } } ``` ### gRPC + Kafka ```kotlin stove { // Trigger event via gRPC grpc { channel { createOrder(CreateOrderRequest(amount = 100.0)) } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.amount == 100.0 } } } ``` ## Provider Support | Provider | DSL Method | Notes | |----------|------------|-------| | Wire | `wireClient` | For Wire-generated service clients | | grpc-kotlin | `channel` | Works with any stub with Channel constructor | | Wire stubs | `channel` | Works with Wire server stubs | | Custom | `withEndpoint` | Any library with factory function | | Advanced | `rawChannel` | Direct ManagedChannel access | | Advanced | `rawWireClient` | Direct Wire GrpcClient access | ================================================ FILE: docs/Components/13-reporting.md ================================================ # Reporting When tests fail, you want to know what went wrong. Stove's reporting system tracks everything that happens during test execution—every HTTP call, database query, Kafka message, and more. When something fails, you get a detailed report showing exactly what happened, making debugging much easier. ## What You Get - **Automatic tracking** of all system interactions (HTTP requests, Kafka messages, database queries, etc.) - **Rich failure reports** that show what happened before the failure - **Multiple output formats** - human-readable console output or machine-readable JSON - **Framework integration** with Kotest and JUnit (optional extensions) ## Quick Start The reporting extensions are optional but recommended. They automatically enrich test failures with detailed execution reports, making debugging much easier. ### Kotest Integration If you're using Kotest, add the extension dependency: ```kotlin hl_lines="3" dependencies { testImplementation("com.trendyol:stove-extensions-kotest") } ``` !!! info "Test Framework Extensions" `StoveKotestExtension` (`stove-extensions-kotest`) and `StoveJUnitExtension` (`stove-extensions-junit`) are separate packages. **Kotest** requires **6.1.3+**; **JUnit** requires **Jupiter 6.x** if possible. For Kotest, add a `kotest.properties` file with `kotest.framework.config.fqn=`. See the [Getting Started guide](../getting-started.md#step-3-create-test-configuration) for details. Then register it in your project config: ```kotlin hl_lines="5" import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove class TestConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { // your configuration } .run() } override suspend fun afterProject() { Stove.stop() } } ``` ### JUnit Integration For JUnit, add the extension dependency: ```kotlin hl_lines="3" dependencies { testImplementation("com.trendyol:stove-extensions-junit") } ``` Then annotate your test class: ```kotlin hl_lines="4 6" import com.trendyol.stove.extensions.junit.StoveJUnitExtension import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(StoveJUnitExtension::class) class MyE2ETest { // your tests } ``` The JUnit extension works with both JUnit 5 and 6 since they share the same Jupiter API. ## Configuration You can configure reporting options when setting up Stove: ```kotlin hl_lines="3-5" Stove { reporting { enabled() // Enable reporting (default: true) dumpOnFailure() // Dump report when tests fail (default: true) failureRenderer(PrettyConsoleRenderer) // Set the renderer } }.with { // your configuration }.run() ``` Or use the direct methods if you prefer: ```kotlin hl_lines="2-4" Stove { reportingEnabled(true) dumpReportOnTestFailure(true) failureRenderer(PrettyConsoleRenderer) }.with { // your configuration }.run() ``` ## What Gets Reported ### Actions Every interaction with a Stove system is recorded:
- **HTTP**: All requests and responses (GET, POST, PUT, DELETE, etc.) - **Kafka**: Message publishing, consumption, and failure assertions - **Database**: Queries, saves, deletes (Couchbase, PostgreSQL, MongoDB, etc.) - **WireMock**: Stub registrations and verifications - **gRPC**: Client connections and calls
### Assertions Both successful and failed assertions are tracked: - Expected vs. actual values - Assertion descriptions - Error messages ## Example Output When a test fails, you'll see output like this: ``` expected:<2> but was:<1> ═══════════════════════════════════════════════════════════════════════════════ STOVE EXECUTION REPORT ═══════════════════════════════════════════════════════════════════════════════ ╔══════════════════════════════════════════════════════════════════════════════╗ ║ STOVE TEST REPORT ║ ║ Test: ExampleTest::should save the product ║ ╠══════════════════════════════════════════════════════════════════════════════╣ ║ 14:47:38.215 ✓ PASSED [HTTP] POST /api/products ║ ║ Input: {"id":1234,"name":"Test Product"} ║ ║ Output: 201 Created ║ ║ ║ ║ 14:47:38.341 ✗ FAILED [PostgreSQL] Query ║ ║ Input: SELECT * FROM Products WHERE id=1234 ║ ║ Output: 1 row(s) returned ║ ║ Expected: 2 ║ ║ Actual: 1 ║ ║ Error: expected:<2> but was:<1> ║ ╚══════════════════════════════════════════════════════════════════════════════╝ ``` ## Renderers Stove provides two built-in renderers: ### PrettyConsoleRenderer (Default) Human-readable format with: - Colorized output (when terminal supports ANSI) - Box-drawing characters for structure - Timestamps for each action - Clear pass/fail indicators ### JsonReportRenderer Machine-readable JSON format, useful for: - CI/CD integration - Log aggregation systems - Custom report processing ```kotlin hl_lines="2" Stove { failureRenderer(JsonReportRenderer) } ``` Example JSON output: ```json { "testId": "ExampleTest::should save the product", "testName": "should save the product", "entries": [ { "type": "action", "system": "HTTP", "action": "POST /api/products", "timestamp": "2025-01-05T14:47:38.215", "result": "PASSED", "input": {"id": 1234, "name": "Test Product"}, "output": "201 Created" }, { "type": "action_with_result", "system": "PostgreSQL", "action": "Query", "timestamp": "2025-01-05T14:47:38.341", "result": "FAILED", "expected": 2, "actual": 1, "error": "expected:<2> but was:<1>" } ], "summary": { "totalActions": 2, "totalAssertions": 0, "passedAssertions": 0, "failedAssertions": 1 } } ``` To use the JSON renderer: ```kotlin hl_lines="2" Stove { failureRenderer(JsonReportRenderer) } ``` ## System Snapshots Some systems provide state snapshots when tests fail, giving you visibility into the system's internal state: ### Kafka Snapshot Shows all messages in the message store: ``` ╔══════════════════════════════════════════════════════════════════════════════╗ ║ ┌─ KAFKA ────────────────────────────────────────────────────────────────────║ ║ ║ ║ Consumed: 1 ║ ║ Produced: 1 ║ ║ Failed: 0 ║ ║ ║ ║ State Details: ║ ║ produced: 1 item(s) ║ ║ [0] ║ ║ topic: product-events ║ ║ key: 1234 ║ ║ value: {"id":1234,"name":"Test Product"} ║ ║ consumed: 1 item(s) ║ ║ [0] ║ ║ topic: product-events ║ ║ value: {"id":1234,"name":"Test Product"} ║ ║ failed: 0 item(s) ║ ╚══════════════════════════════════════════════════════════════════════════════╝ ``` ### WireMock Snapshot Shows registered stubs and unmatched requests: ``` ╔══════════════════════════════════════════════════════════════════════════════╗ ║ ┌─ WIREMOCK ─────────────────────────────────────────────────────────────────║ ║ ║ ║ Registered stubs: 2 ║ ║ Served requests: 1 (matched: 1) ║ ║ Unmatched requests: 0 ║ ║ ║ ║ State Details: ║ ║ registeredStubs: 2 item(s) ║ ║ servedRequests: 1 item(s) ║ ║ unmatchedRequests: 0 item(s) ║ ╚══════════════════════════════════════════════════════════════════════════════╝ ``` ## Disabling Reporting If you need to disable reporting (e.g., for performance-sensitive test runs): ```kotlin Stove { reporting { disabled() } } ``` Or: ```kotlin Stove { reportingEnabled(false) } ``` ## Best Practices ### 1. Use the Extension for Better Debugging While optional, the extensions make debugging much easier by automatically tracking test context and enriching failures with detailed reports. Just add the dependency for your test framework: - Kotest: `testImplementation("com.trendyol:stove-extensions-kotest")` - JUnit: `testImplementation("com.trendyol:stove-extensions-junit")` ### 2. Use Descriptive Actions When writing custom assertions, provide meaningful descriptions: ```kotlin shouldQuery("SELECT * FROM products WHERE active = true") { products -> products.size shouldBe expectedCount } ``` ### 3. Review Reports on CI The JSON renderer is particularly useful for CI/CD pipelines. You can: - Parse the JSON output for custom reporting - Store reports as build artifacts - Integrate with test management tools ## Troubleshooting ### Reports Not Showing If you're not seeing reports when tests fail, check these: 1. **Extension dependency added?** (optional but recommended) - Kotest: `testImplementation("com.trendyol:stove-extensions-kotest")` - JUnit: `testImplementation("com.trendyol:stove-extensions-junit")` 2. **Extension registered?** - Kotest: `override val extensions = listOf(StoveKotestExtension())` - JUnit: `@ExtendWith(StoveJUnitExtension::class)` 3. **Reporting enabled?** ```kotlin Stove { reportingEnabled(true) dumpReportOnTestFailure(true) } ``` 4. **Stove initialized?** Make sure `Stove().run()` is called before your tests execute. ### Truncated Output If output appears truncated in your console, try: - Using a wider terminal window - Switching to `JsonReportRenderer` for full output - Checking your logging configuration ### MCP Endpoint Unavailable If an AI agent cannot connect to Stove MCP, first confirm that `stove` is running and check the startup banner for the actual `http://localhost:/mcp` endpoint. You can also call `GET /api/v1/meta` and verify `mcp.enabled` is `true`. MCP is optional. If it is unavailable, ambiguous, or missing data for a run, agents should fall back to the normal failure report, test output, and logs. ================================================ FILE: docs/Components/14-grpc-mock.md ================================================ # gRPC Mock `stove-grpc-mock` provides a native gRPC mock server for testing gRPC service integrations. Unlike WireMock-based solutions, this implementation provides **full support for all gRPC RPC types** without external dependency conflicts. ## Features | Feature | Support | |---------|---------| | Unary RPC | ✅ Full support | | Server Streaming | ✅ Full support | | Client Streaming | ✅ Full support | | Bidirectional Streaming | ✅ Full support | | Error responses | ✅ Full support | | Request matching | ✅ Full support | | **Authentication** | ✅ Full support | | Multiple services | ✅ Same port | ## Installation ```kotlin dependencies { testImplementation("com.trendyol:stove-grpc-mock:$stoveVersion") } ``` ## Configuration By default, gRPC Mock uses a **dynamic port** (port = 0), which lets the system pick an available port automatically. This avoids port conflicts, especially in CI environments. ```kotlin hl_lines="4-5 11-12" Stove() .with { grpcMock { GrpcMockSystemOptions( // port = 0 by default (dynamic port) removeStubAfterRequestMatched = true, // optional, default false configureExposedConfiguration = { cfg -> // cfg.host = "localhost" // cfg.port = listOf( "grpcService.host=${cfg.host}", "grpcService.port=${cfg.port}" ) } ) } // Your application configuration - gRPC settings are auto-injected ktor( runner = { parameters -> run(parameters) } ) } ``` ### Using Fixed Port (Not Recommended for CI) If you need a specific port: ```kotlin grpcMock { GrpcMockSystemOptions( port = 9090 // Fixed port ) } ``` !!! tip "Dynamic Ports Avoid CI Conflicts" Using `port = 0` (the default) lets the system pick an available port automatically. This is essential in CI environments where: - Multiple test runs may execute in parallel - Other services might already be using common ports - You get "Address already in use" errors with fixed ports The `configureExposedConfiguration` callback receives the actual port after the server starts. ## Usage ### Mocking Unary Calls ```kotlin hl_lines="4-5 16" test("should mock unary gRPC call") { stove { grpcMock { mockUnary( serviceName = "greeting.GreeterService", methodName = "SayHello", response = HelloResponse.newBuilder() .setMessage("Hello from mock!") .build() ) } // Your test that triggers the gRPC call http { get("/api/greet/World") { response -> response.body shouldContain "Hello from mock!" } } } } ``` ### Mocking with Request Matching ```kotlin hl_lines="3 6 15 18" grpcMock { // Match specific request mockUnary( serviceName = "users.UserService", methodName = "GetUser", requestMatcher = RequestMatcher.ExactMessage( GetUserRequest.newBuilder().setUserId("123").build() ), response = GetUserResponse.newBuilder() .setName("John Doe") .build() ) // Custom matcher mockUnary( serviceName = "users.UserService", methodName = "GetUser", requestMatcher = RequestMatcher.Custom { bytes -> // Parse and inspect request bytes val request = GetUserRequest.parseFrom(bytes) request.userId.startsWith("vip-") }, response = GetUserResponse.newBuilder() .setName("VIP User") .build() ) } ``` ### Mocking Server Streaming ```kotlin grpcMock { mockServerStream( serviceName = "streaming.ItemService", methodName = "ListItems", responses = listOf( Item.newBuilder().setId("1").setName("Item 1").build(), Item.newBuilder().setId("2").setName("Item 2").build(), Item.newBuilder().setId("3").setName("Item 3").build() ) ) } ``` ### Mocking Client Streaming ```kotlin grpcMock { mockClientStream( serviceName = "upload.UploadService", methodName = "UploadChunks", response = UploadResponse.newBuilder() .setTotalSize(1024) .setSuccess(true) .build() ) } ``` > **Note:** For client streaming, the `requestMatcher` is evaluated against **only the first request** in the stream. This is because stub matching happens before the full stream is received. If you need to validate all requests in a client stream, use the bidirectional streaming mock with a custom handler instead. ### Mocking Bidirectional Streaming ```kotlin grpcMock { mockBidiStream( serviceName = "chat.ChatService", methodName = "Chat" ) { requestFlow -> // Transform each request into a response requestFlow.map { requestBytes -> val request = ChatMessage.parseFrom(requestBytes) ChatMessage.newBuilder() .setMessage("Echo: ${request.message}") .build() } } } ``` ### Mocking Error Responses ```kotlin hl_lines="2 4-5 11 16-17" grpcMock { mockError( serviceName = "users.UserService", methodName = "GetUser", status = Status.Code.NOT_FOUND, message = "User not found" ) // With request matching mockError( serviceName = "users.UserService", methodName = "DeleteUser", requestMatcher = RequestMatcher.ExactMessage( DeleteUserRequest.newBuilder().setUserId("admin").build() ), status = Status.Code.PERMISSION_DENIED, message = "Cannot delete admin user" ) } ``` ## Authentication Support `stove-grpc-mock` provides full support for mocking authenticated gRPC calls. ### Bearer Token Authentication ```kotlin grpcMock { mockUnary( serviceName = "secure.SecureService", methodName = "GetSecret", metadataMatcher = MetadataMatcher.BearerToken("valid-token-123"), response = SecretResponse.newBuilder() .setData("confidential") .build() ) } // Call with proper token grpc { channel( metadata = mapOf("authorization" to "Bearer valid-token-123") ) { val response = getSecret(request) // Works! } } ``` ### Custom Header Matching ```kotlin grpcMock { mockUnary( serviceName = "api.ApiService", methodName = "GetData", metadataMatcher = MetadataMatcher.HasHeader("x-api-key", "secret-key"), response = DataResponse.newBuilder().build() ) } ``` ### Require Any Authentication ```kotlin grpcMock { // Matches any request with a non-empty authorization header mockUnary( serviceName = "auth.AuthService", methodName = "GetProfile", metadataMatcher = MetadataMatcher.RequiresAuth, response = ProfileResponse.newBuilder().build() ) } ``` ### Combined Matchers ```kotlin grpcMock { mockUnary( serviceName = "multi.MultiAuthService", methodName = "GetResource", metadataMatcher = MetadataMatcher.All( MetadataMatcher.BearerToken("valid-token"), MetadataMatcher.HasHeader("x-tenant-id", "tenant-123") ), response = ResourceResponse.newBuilder().build() ) } ``` ### Authenticated Streaming ```kotlin grpcMock { mockServerStream( serviceName = "secure.DataService", methodName = "StreamData", metadataMatcher = MetadataMatcher.BearerToken("stream-token"), responses = listOf(data1, data2, data3) ) mockClientStream( serviceName = "secure.UploadService", methodName = "Upload", metadataMatcher = MetadataMatcher.BearerToken("upload-token"), response = UploadResponse.newBuilder().setSuccess(true).build() ) mockBidiStream( serviceName = "secure.ChatService", methodName = "Chat", metadataMatcher = MetadataMatcher.BearerToken("chat-token") ) { requestFlow -> requestFlow.map { parseAndRespond(it) } } } ``` ### Testing Auth Failures ```kotlin test("should reject unauthenticated request") { stove { grpcMock { // Only accepts valid token mockUnary( serviceName = "secure.SecureService", methodName = "GetSecret", metadataMatcher = MetadataMatcher.BearerToken("valid-token"), response = SecretResponse.newBuilder().build() ) } grpc { // Call WITHOUT token - fails with UNIMPLEMENTED (no matching stub) channel { val exception = shouldThrow { getSecret(request) } exception.status.code shouldBe Status.Code.UNIMPLEMENTED } // Call WITH wrong token - also fails channel( metadata = mapOf("authorization" to "Bearer wrong-token") ) { val exception = shouldThrow { getSecret(request) } exception.status.code shouldBe Status.Code.UNIMPLEMENTED } } } } ``` ## Multiple gRPC Services The mock server can handle **multiple services on the same port**. Simply register stubs for different services: ```kotlin Stove() .with { grpcMock { GrpcMockSystemOptions(port = 9090) } ktor( withParameters = listOf( // All services point to the same mock server "featureToggle.host=localhost", "featureToggle.port=9090", "pricing.host=localhost", "pricing.port=9090", "inventory.host=localhost", "inventory.port=9090" ), runner = { parameters -> run(parameters) } ) } ``` Then mock each service in your tests: ```kotlin test("should handle multiple gRPC services") { stove { grpcMock { // Service 1: Feature Toggle mockUnary( serviceName = "featuretoggle.FeatureToggleService", methodName = "IsFeatureEnabled", response = IsFeatureEnabledResponse.newBuilder() .setEnabled(true) .build() ) // Service 2: Pricing mockUnary( serviceName = "pricing.PricingService", methodName = "CalculatePrice", response = CalculatePriceResponse.newBuilder() .setFinalPrice(29.99) .build() ) // Service 3: Inventory (error case) mockError( serviceName = "inventory.InventoryService", methodName = "CheckStock", status = Status.Code.UNAVAILABLE, message = "Inventory service is down" ) } // Test your application logic http { post("/api/checkout", body = checkoutRequest.some()) { response -> // Assert based on mocked responses } } } } ``` ## Stub Removal Options By default, stubs persist across requests. You can configure automatic removal: ```kotlin grpcMock { GrpcMockSystemOptions( port = 9090, removeStubAfterRequestMatched = true // Remove stub after first match ) } ``` This is useful when testing retry logic or different responses for sequential calls. ## Direct gRPC Client Testing You can also test gRPC calls directly using the `grpc` system: ```kotlin test("should call mocked gRPC service directly") { stove { grpcMock { mockUnary( serviceName = "greeting.GreeterService", methodName = "SayHello", response = HelloResponse.newBuilder() .setMessage("Hello!") .build() ) } grpc { channel { val response = sayHello( HelloRequest.newBuilder().setName("Test").build() ) response.message shouldBe "Hello!" } } } } ``` ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Your Application │ ├─────────────────────┬───────────────────────────────────────┤ │ ServiceA Client │ ServiceB Client │ │ (port 9090) │ (port 9090) │ └────────────┬────────┴───────────────┬───────────────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ stove-grpc-mock Server (port 9090) │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ Dynamic Handler Registry │ │ │ │ Routes by: serviceName/methodName │ │ │ ├─────────────────────┬──────────────────────────────────┤ │ │ │ serviceA.* │ serviceB.* │ │ │ │ → stub responses │ → stub responses │ │ │ └─────────────────────┴──────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ## Comparison with WireMock gRPC | Feature | stove-grpc-mock | WireMock gRPC | |---------|-----------------|---------------| | Unary RPC | ✅ | ✅ | | Server Streaming | ✅ Full | ⚠️ First response only | | Client Streaming | ✅ | ❌ Not supported | | Bidi Streaming | ✅ | ❌ Not supported | | Proto descriptors | Not needed | Required | | Dependency conflicts | None | Shaded protobuf issues | | Setup complexity | Simple | Requires descriptor generation | ## Best Practices 1. **Register stubs before triggering calls** - Stubs must be registered before your application makes gRPC calls. 2. **Use specific request matchers** - When testing different scenarios, use `RequestMatcher.ExactMessage` to ensure the right stub is matched. 3. **Test error scenarios** - Use `mockError()` to test how your application handles gRPC failures. 4. **Multiple services, single port** - Point all gRPC clients to the same mock server port for simpler configuration. 5. **Use `removeStubAfterRequestMatched`** - Enable this when testing retry logic or sequential calls with different responses. ================================================ FILE: docs/Components/15-tracing.md ================================================ # Tracing Your end-to-end test just failed. Now what? You stare at a stack trace that says *"expected message not found within timeout"*. You dig through application logs. You check Kafka topics. You wonder if the HTTP request even reached the controller. Was it a database error? A serialization issue? A Kafka consumer that silently died? **What if your test failure told you exactly what happened inside your application?** ``` ═══════════════════════════════════════════════════════════════════════════════ EXECUTION TRACE (Call Chain) ═══════════════════════════════════════════════════════════════════════════════ ✓ POST (377ms) ✓ POST /api/product/create (361ms) ✓ ProductController.create (141ms) ✓ ProductCreator.create (0ms) ✓ KafkaProducer.send (137ms) ✓ orders.created publish (81ms) ✗ orders.created process (82ms) ← FAILURE POINT ``` That's Stove tracing. When a test fails, you see the entire call chain of your application, powered by OpenTelemetry: every controller method, every database query, every Kafka message, every HTTP call, with timing and the exact point of failure. It's a unique feature. ## What You Get When tracing is enabled, every test failure comes with the full story: ``` STOVE EXECUTION REPORT ═══════════════════════════════════════════════════════════════════════════════ TIMELINE ──────── 14:45:38.439 ✓ PASSED [HTTP] POST /api/product/create 14:45:38.472 ✗ FAILED [Kafka] shouldBePublished SYSTEM SNAPSHOTS ──────────────── KAFKA Consumed: 0 Produced: 1 Failed: 1 [0] topic: orders.created reason: Something went wrong ═══════════════════════════════════════════════════════════════════════════════ EXECUTION TRACE (Call Chain) ═══════════════════════════════════════════════════════════════════════════════ ✓ POST (377ms) ✓ POST /api/product/create (361ms) ✓ ProductController.create (141ms) ✓ ProductCreator.create (0ms) ✓ KafkaProducer.send (137ms) ✓ orders.created publish (81ms) ✗ orders.created process (82ms) ← FAILURE POINT ``` Everything is automatic: - Traces **start and end** with each test - W3C `traceparent` headers are **injected into HTTP requests** - Trace headers are **injected into Kafka messages** - Trace metadata is **injected into gRPC calls** - All spans are **correlated back to the originating test** - Failure reports are **enriched with the execution trace** When failures include exceptions, you see those too: ``` ✗ PaymentGateway.charge [80ms] ⚠ FAILURE POINT ├── Exception: PaymentDeclinedException │ Message: Card declined │ at PaymentGateway.charge(PaymentGateway.kt:42) ``` Successful traces render as clean hierarchical trees: ``` ✓ OrderController.createOrder [100ms] ├── ✓ OrderService.processOrder [95ms] │ ├── ✓ UserRepository.findById [10ms] │ │ └── db.system: postgresql │ └── ✓ PaymentClient.charge [65ms] │ └── http.url: https://payment.api/charge Summary: 4 spans, 0 failures, total: 100ms ``` ## Setup Two steps. That's it. ### Step 1: Enable tracing in your Stove config ```kotlin hl_lines="3-4" Stove() .with { tracing { enableSpanReceiver() } // ... your other systems (http, kafka, etc.) } .run() ``` ### Step 2: Attach the OpenTelemetry agent in your build === "Gradle Plugin (Recommended)" ```kotlin hl_lines="2 6-7" plugins { id("com.trendyol.stove.tracing") version "" } stoveTracing { serviceName.set("my-service") } ``` The plugin is published to [Maven Central](https://central.sonatype.com/artifact/com.trendyol/stove-tracing-gradle-plugin). Add `mavenCentral()` to your `pluginManagement` repositories if not already present. === "buildSrc (Copy-Paste)" Copy [StoveTracingConfiguration.kt](https://github.com/Trendyol/stove/blob/main/buildSrc/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingConfiguration.kt) to your project's `buildSrc/src/main/kotlin/` directory, then add to your `build.gradle.kts`: ```kotlin hl_lines="3-4" import com.trendyol.stove.gradle.stoveTracing stoveTracing { serviceName = "my-service" } ``` Both approaches handle everything: downloading the OpenTelemetry Java Agent, configuring JVM arguments, attaching the agent to your test tasks, and dynamically assigning ports so parallel test runs don't conflict. !!! tip "That's all you need" Now write your tests as usual. When a test fails, you'll see the execution trace automatically. No code changes to your application required. The OpenTelemetry agent instruments 100+ libraries (Spring, JDBC, Kafka, gRPC, HTTP clients, Redis, MongoDB, and more) with zero code changes. ### Dependencies ```kotlin hl_lines="2" dependencies { testImplementation("com.trendyol:stove-tracing:$stoveVersion") testImplementation("com.trendyol:stove-extensions-kotest:$stoveVersion") // or testImplementation("com.trendyol:stove-extensions-junit:$stoveVersion") } ``` !!! info "Test Framework Extensions" `StoveKotestExtension` (`stove-extensions-kotest`) and `StoveJUnitExtension` (`stove-extensions-junit`) are separate packages that must be on your classpath. **Kotest** requires **6.1.3+**; **JUnit** requires **Jupiter 6.x** if possible. For Kotest, add a `kotest.properties` file with `kotest.framework.config.fqn=`. See the [Getting Started guide](../getting-started.md#step-3-create-test-configuration) for details. ## Zero-Effort Trace Propagation You don't need to do anything special in your test code. Stove injects trace headers into every interaction automatically: === "HTTP" ```kotlin http { get("/users/123") { user -> user.name shouldBe "John" } } ``` === "Kafka" ```kotlin kafka { publish("orders.created", OrderCreatedEvent(orderId = "123")) } ``` === "gRPC" ```kotlin grpc { channel { sayHello(HelloRequest(name = "World")) } } ``` Every HTTP request gets a `traceparent` header. Every Kafka message gets trace headers. Every gRPC call gets trace metadata. Your application picks these up through the OpenTelemetry agent, and Stove collects the resulting spans, all without you writing a single line of tracing code. ## Trace Validation DSL Beyond automatic failure reports, you can actively query and assert on traces using the `tracing { }` DSL. This is useful when you want to verify *how* your application handled a request, not just *that* it did. ```kotlin hl_lines="9 10 11 12" test("order processing should call payment service") { stove { http { post("/orders", orderRequest) { response -> response.status shouldBe "created" } } tracing { shouldContainSpan("OrderService.processOrder") shouldContainSpan("PaymentClient.charge") shouldNotHaveFailedSpans() executionTimeShouldBeLessThan(500.milliseconds) } } } ``` ### Span Assertions Verify which operations happened (or didn't) during a test: ```kotlin hl_lines="2 3 6 9" tracing { shouldContainSpan("UserService.findById") shouldContainSpanMatching { it.operationName.contains("Repository") } shouldNotContainSpan("AdminService.delete") shouldNotHaveFailedSpans() shouldHaveFailedSpan("PaymentGateway.charge") shouldHaveSpanWithAttribute("http.method", "GET") shouldHaveSpanWithAttributeContaining("http.url", "/api/users") } ``` ### Performance Assertions Assert on execution timing and span counts: ```kotlin hl_lines="2 6" tracing { executionTimeShouldBeLessThan(500.milliseconds) executionTimeShouldBeGreaterThan(10.milliseconds) spanCountShouldBe(10) spanCountShouldBeAtLeast(5) spanCountShouldBeAtMost(20) } ``` ### Debugging Helpers When you need to understand what happened during a test, render the trace: ```kotlin tracing { println(renderTree()) // Hierarchical tree view println(renderSummary()) // Compact summary val failedSpans = getFailedSpans() val totalDuration = getTotalDuration() val span = findSpanByName("OrderService.process") // Wait for spans to arrive before asserting (useful for async flows) waitForSpans(expectedCount = 5, timeoutMs = 3000) } ``` ## Real-World Example Here's a realistic scenario: an HTTP request triggers order processing, which publishes a Kafka event, which is consumed and writes to the database. ```kotlin hl_lines="28-33" test("should create order and notify downstream services") { stove { val orderId = UUID.randomUUID().toString() // 1. Create order via HTTP http { post("/orders", CreateOrderRequest(orderId, amount = 99.99)) { response -> response.status shouldBe "created" } } // 2. Verify Kafka event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.orderId == orderId } } // 3. Verify database state postgresql { shouldQuery("SELECT * FROM orders WHERE id = '$orderId'") { orders -> orders.size shouldBe 1 orders.first().status shouldBe "CREATED" } } // 4. Verify the execution flow tracing { shouldContainSpan("OrderController.create") shouldContainSpan("OrderService.processOrder") shouldContainSpan("orders.created publish") shouldNotHaveFailedSpans() } } } ``` If any step fails, the trace tree shows you exactly where and why: ``` ✓ POST (250ms) ✓ POST /orders (245ms) ✓ OrderController.create [120ms] ├── ✓ OrderService.processOrder [115ms] │ ├── ✓ INSERT INTO orders [15ms] │ │ └── db.system: postgresql │ └── ✓ KafkaProducer.send [90ms] │ └── ✓ orders.created publish [45ms] │ └── ✓ orders.created process [40ms] │ └── ✓ UPDATE orders SET status='CREATED' [8ms] Summary: 8 spans, 0 failures, total: 250ms ``` !!! note "Working example" For a complete working project with tracing, see the [spring-showcase recipe](https://github.com/Trendyol/stove/tree/main/recipes/jvm/kotlin-recipes/spring-showcase). ## Configuration Reference ### Stove Test Config Configure tracing behavior in your Stove setup: ```kotlin hl_lines="2" tracing { enableSpanReceiver() // Required: starts the span receiver spanCollectionTimeout(10.seconds) // How long to wait for spans (default: 5s) maxSpansPerTrace(2000) // Cap spans per trace (default: 1000) spanFilter { span -> // Filter which spans are collected !span.operationName.contains("health-check") } } ``` | Option | Default | Description | |--------|---------|-------------| | `enableSpanReceiver(port?)` | Port from `STOVE_TRACING_PORT` env or `4317` | Starts the OTLP gRPC receiver | | `spanCollectionTimeout` | `5.seconds` | How long to wait for spans when building failure reports | | `maxSpansPerTrace` | `1000` | Maximum spans stored per trace (prevents memory issues) | | `spanFilter` | Accept all | Predicate to filter which spans are collected | ### Gradle Plugin The Stove Tracing Gradle plugin configures the OpenTelemetry Java Agent for your test tasks. It is published to **Maven Central**. Add `mavenCentral()` to your `pluginManagement` repositories: ```kotlin // settings.gradle.kts pluginManagement { repositories { mavenCentral() gradlePluginPortal() } } ``` Then apply the plugin: ```kotlin plugins { id("com.trendyol.stove.tracing") version "" } ``` For snapshot versions, also add the Maven Central snapshot repository: ```kotlin // settings.gradle.kts pluginManagement { repositories { mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots") gradlePluginPortal() } } ``` Configure the plugin in your `build.gradle.kts`: ```kotlin hl_lines="2-4" stoveTracing { serviceName.set("my-service") testTaskNames.set(listOf("integrationTest")) // Only apply to specific tasks disabledInstrumentations.set(listOf("jdbc")) // Exclude noisy instrumentations } ``` | Option | Default | Description | |--------|---------|-------------| | `serviceName` | `"stove-traced-app"` | Service name shown in traces | | `enabled` | `true` | Toggle tracing on/off | | `protocol` | `"grpc"` | OTLP protocol (currently only `grpc` is supported) | | `testTaskNames` | `[]` | Apply only to specific test tasks (empty = all) | | `otelAgentVersion` | `"2.24.0"` | OpenTelemetry Java Agent version | | `captureHttpHeaders` | `true` | Include HTTP headers in spans | | `captureExperimentalTelemetry` | `true` | Enable experimental HTTP telemetry | | `disabledInstrumentations` | `[]` | Instrumentations to disable (e.g., `jdbc`, `hibernate`) | | `additionalInstrumentations` | `[]` | Extra instrumentations to enable | | `customAnnotations` | `[]` | Custom annotation classes to instrument | | `bspScheduleDelay` | `100` | Batch span processor delay in ms (lower = faster export) | | `bspMaxBatchSize` | `1` | Batch size for span export (1 = immediate) | ??? note "Alternative: buildSrc copy-paste approach" If you prefer not to use the plugin, copy [`StoveTracingConfiguration.kt`](https://github.com/Trendyol/stove/blob/main/buildSrc/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingConfiguration.kt) to your project's `buildSrc/src/main/kotlin/` directory and use `stoveTracing { ... }` in your build script. ??? note "Alternative: Manual OTel agent setup" If you prefer full control, you can configure the agent manually: ```kotlin // build.gradle.kts val otelAgent by configurations.creating { isTransitive = false } dependencies { otelAgent("io.opentelemetry.javaagent:opentelemetry-javaagent:2.24.0") } tasks.test { doFirst { jvmArgs( "-javaagent:${otelAgent.singleFile.absolutePath}", "-Dotel.traces.exporter=otlp", "-Dotel.exporter.otlp.protocol=grpc", "-Dotel.exporter.otlp.endpoint=http://localhost:4317", "-Dotel.metrics.exporter=none", "-Dotel.logs.exporter=none", "-Dotel.service.name=my-service", "-Dotel.propagators=tracecontext,baggage", "-Dotel.traces.sampler=always_on", "-Dotel.bsp.schedule.delay=100", "-Dotel.bsp.max.export.batch.size=1", "-Dotel.instrumentation.grpc.enabled=false" ) } } ``` ## Best Practices 1. **Just enable it.** Tracing is automatic and low-overhead; there's no reason not to use it 2. **Use `tracing { }` sparingly.** The automatic failure reports cover most debugging needs; use the DSL only when you want to assert on the execution flow 3. **Start with `shouldNotHaveFailedSpans()`.** The simplest assertion that catches unexpected errors 4. **Filter noise.** If you see too many spans, use `disabledInstrumentations` to exclude verbose libraries like `jdbc` or `spring-scheduling` 5. **CI just works.** Ports are dynamically assigned, so parallel test runs don't conflict !!! tip "Works with Reporting" Tracing integrates seamlessly with Stove's [Reporting](13-reporting.md) system. When both are enabled, test failures include the execution report *and* the trace tree together, giving you the complete picture. ## Troubleshooting ### No trace in failure reports 1. Ensure `stove-tracing` is in your dependencies 2. Verify `enableSpanReceiver()` is called in your Stove config 3. Verify the `com.trendyol.stove.tracing` plugin is applied in your `build.gradle.kts` 4. Look for *"Stove tracing: Attached OTel agent"* in test output ### Too many spans Use `disabledInstrumentations` to exclude noisy libraries: ```kotlin stoveTracing { serviceName.set("my-service") disabledInstrumentations.set(listOf("jdbc", "hibernate", "spring-scheduling")) } ``` ### Spans missing parent-child relationships 1. Ensure trace context is propagated through async boundaries 2. Check that the OTel agent version is compatible with your framework version ================================================ FILE: docs/Components/16-mysql.md ================================================ # MySQL === "Gradle" ``` kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-mysql") } ``` ## Configure Once you've added the dependency, you can configure MySQL in your Stove setup: ```kotlin hl_lines="4 7-8" Stove() .with { mysql { MySqlOptions { listOf( "mysql.jdbcUrl=${it.jdbcUrl}", "mysql.host=${it.host}", "mysql.port=${it.port}", "mysql.username=${it.username}", "mysql.password=${it.password}" ) } } }.run() ``` The `it` reference gives you access to the MySQL container's connection details, which you can pass to your application. ## Migrations Stove provides a way to run database migrations before tests start: ```kotlin class InitialMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: MySqlMigrationContext) { connection.operations.execute( """ CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """.trimIndent() ) } } ``` Register migrations in your Stove configuration: ```kotlin Stove() .with { mysql { MySqlOptions( databaseName = "testing", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } } .run() ``` ## Usage ### Executing SQL Execute DDL and DML statements with `shouldExecute`: ```kotlin hl_lines="4 11 19 22" stove { mysql { // Create tables shouldExecute( """ DROP TABLE IF EXISTS products; CREATE TABLE IF NOT EXISTS products ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT DEFAULT 0 ); """.trimIndent() ) // Insert data shouldExecute( """ INSERT INTO products (name, price, stock) VALUES ('Laptop', 999.99, 10) """.trimIndent() ) // Update data shouldExecute("UPDATE products SET stock = 5 WHERE name = 'Laptop'") // Delete data shouldExecute("DELETE FROM products WHERE stock = 0") } } ``` ### Querying Data Query data with type-safe mappers: ```kotlin hl_lines="4 12 17" data class Product( val id: Long, val name: String, val price: Double, val stock: Int ) stove { mysql { shouldQuery( query = "SELECT * FROM products WHERE price > 500", mapper = { row -> Product( id = row.long("id"), name = row.string("name"), price = row.double("price"), stock = row.int("stock") ) } ) { products -> products.size shouldBeGreaterThan 0 products.all { it.price > 500 } shouldBe true } } } ``` ### Query with Parameters Use parameterized queries for safety: ```kotlin stove { mysql { val minPrice = 100.0 shouldQuery( query = "SELECT * FROM products WHERE price >= ?", mapper = { row -> Product( id = row.long("id"), name = row.string("name"), price = row.double("price"), stock = row.int("stock") ) } ) { products -> products.all { it.price >= minPrice } shouldBe true } } } ``` ### Working with Nullable Fields Handle nullable columns: ```kotlin data class User( val id: Long, val name: String, val email: String?, val phone: String? ) stove { mysql { shouldQuery( query = "SELECT * FROM users", mapper = { row -> User( id = row.long("id"), name = row.string("name"), email = row.stringOrNull("email"), phone = row.stringOrNull("phone") ) } ) { users -> users.size shouldBeGreaterThan 0 } } } ``` ## Provided Instance (External MySQL) For CI/CD pipelines or shared infrastructure, connect to an existing MySQL instance instead of starting a container: ```kotlin Stove() .with { mysql { MySqlOptions.provided( jdbcUrl = "jdbc:mysql://localhost:3306/testdb", host = "localhost", port = 3306, databaseName = "testdb", username = "root", password = "password", runMigrations = true, cleanup = { operations -> operations.execute("DELETE FROM users WHERE email LIKE '%@test.com'") }, configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } .run() ``` See [Provided Instances](11-provided-instances.md) for detailed documentation on all supported systems and test isolation strategies. ================================================ FILE: docs/Components/17-cassandra.md ================================================ # Cassandra === "Gradle" ``` kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-cassandra") } ``` ## Configure Once you've added the dependency, you'll have access to the `cassandra` function when configuring Stove. This function configures the Cassandra Docker container that will be started for tests. ```kotlin hl_lines="4 6-10" Stove() .with { cassandra { CassandraSystemOptions( keyspace = "my_keyspace", configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ) } }.run() ``` The `cfg` reference gives you access to the Cassandra container's connection details, which you can pass to your application. ### Container Options Customize the Cassandra container version and configuration: ```kotlin Stove() .with { cassandra { CassandraSystemOptions( keyspace = "my_keyspace", datacenter = "datacenter1", container = CassandraContainerOptions( registry = "docker.io", image = "cassandra", tag = "4.1", containerFn = { container -> // Additional container configuration container.withEnv("CASSANDRA_CLUSTER_NAME", "test-cluster") } ), configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ) } }.run() ``` ### Cleanup Use the `cleanup` lambda to truncate tables or delete data between test runs: ```kotlin Stove() .with { cassandra { CassandraSystemOptions( keyspace = "my_keyspace", cleanup = { session -> session.execute("TRUNCATE my_keyspace.users") session.execute("TRUNCATE my_keyspace.events") }, configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ) } }.run() ``` ## Migrations Stove provides a way to run CQL migrations before tests start. Use this to create keyspaces, tables, indexes, and seed data. ```kotlin class CreateKeyspaceMigration : CassandraMigration { override val order: Int = 1 override suspend fun execute(connection: CassandraMigrationContext) { connection.session.execute( """ CREATE KEYSPACE IF NOT EXISTS ${connection.options.keyspace} WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1} """.trimIndent() ) } } class CreateTablesMigration : CassandraMigration { override val order: Int = 2 override suspend fun execute(connection: CassandraMigrationContext) { connection.session.execute( """ CREATE TABLE IF NOT EXISTS ${connection.options.keyspace}.users ( id uuid PRIMARY KEY, name text, email text, created_at timestamp ) """.trimIndent() ) } } ``` Register migrations in your Stove configuration: ```kotlin hl_lines="9-12" Stove() .with { cassandra { CassandraSystemOptions( keyspace = "my_keyspace", configureExposedConfiguration = { cfg -> listOf("spring.cassandra.contact-points=${cfg.host}:${cfg.port}") } ).migrations { register() register() } } }.run() ``` Migrations are executed in ascending `order` and are skipped on subsequent test runs (container reuse) unless `runMigrationsAlways` is enabled. ## Usage ### Executing CQL Statements Execute any DDL or DML statement: ```kotlin hl_lines="3 8 13" stove { cassandra { // Create a table shouldExecute( "CREATE TABLE IF NOT EXISTS my_keyspace.products (id uuid PRIMARY KEY, name text, price decimal)" ) // Insert data shouldExecute( "INSERT INTO my_keyspace.products (id, name, price) VALUES (uuid(), 'Laptop', 999.99)" ) // Delete data shouldExecute("DELETE FROM my_keyspace.products WHERE name = 'Laptop'") } } ``` ### Querying Data Execute a CQL query and assert on the returned `ResultSet`: ```kotlin hl_lines="3 5" stove { cassandra { shouldQuery("SELECT * FROM my_keyspace.products") { resultSet -> val rows = resultSet.all() rows.isNotEmpty() shouldBe true rows.first().getString("name") shouldBe "Laptop" } } } ``` ### Prepared Statements Use prepared (bound) statements for parameterized queries: ```kotlin hl_lines="6 11" stove { cassandra { // Prepare a statement using the raw session val prepared = session().prepare( "INSERT INTO my_keyspace.users (id, name, email) VALUES (?, ?, ?)" ) val bound = prepared.bind(java.util.UUID.randomUUID(), "Jane Doe", "jane@example.com") // Execute the bound statement shouldExecute(bound) // Query with a bound statement val selectPrepared = session().prepare( "SELECT * FROM my_keyspace.users WHERE id = ?" ) shouldQuery(selectPrepared.bind(bound.getUuid(0))) { resultSet -> val row = resultSet.one() row?.getString("name") shouldBe "Jane Doe" } } } ``` ### Direct Session Access Access the raw `CqlSession` for advanced operations not covered by the DSL: ```kotlin hl_lines="4" stove { cassandra { // Use session() for operations outside the DSL val result = session().execute("SELECT release_version FROM system.local") val version = result.one()?.getString("release_version") version shouldNotBe null } } ``` ### Pause and Unpause Container Test resilience scenarios by pausing the Cassandra container: ```kotlin hl_lines="5 10" stove { cassandra { // Verify database is reachable shouldQuery("SELECT * FROM my_keyspace.users") { it.all().size shouldBeGreaterThanOrEqual 0 } // Pause the container to simulate an outage pause() // Your application should handle the failure gracefully // ... // Restore the container unpause() // Verify recovery shouldQuery("SELECT * FROM my_keyspace.users") { it.all().size shouldBeGreaterThanOrEqual 0 } } } ``` !!! note `pause()` and `unpause()` are only supported in container mode. They are ignored (with a warning) when using a [provided instance](#provided-instances). ## Complete Example Here's a complete end-to-end test: ```kotlin hl_lines="7 12 22 30" test("should create user via API and verify in Cassandra") { stove { val userName = "John Doe" val userEmail = "john@example.com" // Create user via API http { postAndExpectBody( uri = "/users", body = CreateUserRequest(name = userName, email = userEmail).some() ) { response -> response.status shouldBe 201 } } // Verify user event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.name == userName && actual.email == userEmail } } // Verify user was stored in Cassandra cassandra { shouldQuery( "SELECT * FROM my_keyspace.users WHERE email = '$userEmail' ALLOW FILTERING" ) { resultSet -> val rows = resultSet.all() rows shouldHaveSize 1 rows.first().getString("name") shouldBe userName } } } } ``` ## Provided Instances Connect to an externally running Cassandra instance instead of a testcontainer. This is useful when Docker is unavailable or you want to use a shared cluster. ```kotlin hl_lines="4-11" Stove() .with { cassandra { CassandraSystemOptions.provided( host = "localhost", port = 9042, datacenter = "datacenter1", keyspace = "my_keyspace", runMigrations = true, cleanup = { session -> session.execute("TRUNCATE my_keyspace.users") }, configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ).migrations { register() register() } } }.run() ``` See [Provided Instances](11-provided-instances.md) for more details on connecting to existing infrastructure. ## Spring Boot Integration When using Spring Boot Data Cassandra, map the exposed configuration to Spring properties: ```kotlin CassandraSystemOptions( keyspace = "my_keyspace", configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}", "spring.cassandra.schema-action=CREATE_IF_NOT_EXISTS" ) } ) ``` For `application.yml`-based configuration: ```yaml spring: cassandra: contact-points: "${CASSANDRA_HOST}:${CASSANDRA_PORT}" local-datacenter: datacenter1 keyspace-name: my_keyspace ``` ================================================ FILE: docs/Components/18-dashboard.md ================================================ # Dashboard Your end-to-end tests pass. But do you *see* what they do? Stove Dashboard is a local observability dashboard for your e2e test runs. - **Captures everything** — HTTP calls, Kafka messages, database queries, gRPC calls, distributed traces, system snapshots - **Real-time web UI** — updates live via SSE as your tests execute - **Single binary** — receives events via gRPC, persists in SQLite, serves an embedded SPA - **Persistent** — browse test runs after they complete, across sessions - **Agent API** — exposes a local read-only MCP endpoint for compact failed-test evidence Unlike [Reporting](13-reporting.md) (console output on failure) and [Tracing](15-tracing.md) (span collection for assertions), Dashboard gives you a persistent, browsable view of your test runs — including successful ones. ## Install the CLI === "Homebrew (macOS)" ```bash brew install Trendyol/trendyol-tap/stove ``` === "Shell Script (macOS & Linux)" ```bash curl -fsSL https://raw.githubusercontent.com/Trendyol/stove/main/tools/stove-cli/install.sh | sh ``` Options: ```bash # Install a specific version curl -fsSL ... | sh -s -- --version 0.23.0 # Install to a custom directory curl -fsSL ... | sh -s -- --dir /usr/local/bin ``` === "Manual Download" Download the binary for your platform from [GitHub Releases](https://github.com/Trendyol/stove/releases): | Platform | Archive | |-------------|----------------------------------------------| | macOS arm64 | `stove--darwin-arm64.tar.gz` | | macOS amd64 | `stove--darwin-amd64.tar.gz` | | Linux amd64 | `stove--linux-amd64.tar.gz` | Each archive includes a `.sha256` checksum file. The CLI is a single binary with no runtime dependencies. It embeds the web UI, so there's nothing else to install. !!! info "Keep Versions Aligned" Keep `stove-cli`, the Stove BOM, and your Stove test dependencies on the same Stove version. The dashboard shows a warning when the runtime libraries and CLI drift apart, but matching versions avoids inconsistent dashboard data. ## Quick Start **1. Start the dashboard** ```bash stove ``` You'll see: ``` Stove CLI v0.23.0 running UI: http://localhost:4040 REST: http://localhost:4040/api/v1 MCP: http://localhost:4040/mcp gRPC: localhost:4041 ``` **2. Add the dependency** === "Gradle" ```kotlin hl_lines="3-4" dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-dashboard") testImplementation("com.trendyol:stove-tracing") } ``` === "Maven" ```xml hl_lines="3-6" com.trendyol stove-dashboard test ``` **3. Apply the tracing Gradle plugin** The tracing Gradle plugin attaches the OpenTelemetry agent to your test tasks, which is required for the dashboard's trace view to receive spans. ```kotlin // build.gradle.kts plugins { id("com.trendyol.stove.tracing") version "" } stoveTracing { serviceName.set("product-api") } ``` See [Tracing](15-tracing.md) for the full plugin configuration reference. **4. Register in your Stove config** === "Kotest" ```kotlin hl_lines="2 6-7" class StoveConfig : AbstractProjectConfig() { override val extensions = listOf(StoveKotestExtension()) override suspend fun beforeProject() = Stove().with { dashboard { DashboardSystemOptions(appName = "product-api") } tracing { enableSpanReceiver() } // recommended: enables distributed trace capture // ... other systems }.run() override suspend fun afterProject() = Stove.stop() } ``` === "JUnit" ```kotlin hl_lines="1" @ExtendWith(StoveJUnitExtension::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class BaseE2ETest { companion object { @JvmStatic @BeforeAll fun setup() = runBlocking { Stove().with { dashboard { DashboardSystemOptions(appName = "product-api") } tracing { enableSpanReceiver() } // ... other systems }.run() } @JvmStatic @AfterAll fun teardown() = runBlocking { Stove.stop() } } } ``` **5. Run your tests and open the dashboard** ```bash ./gradlew test ``` Navigate to [http://localhost:4040](http://localhost:4040). The UI updates in real time as tests execute. If the dashboard shows a version mismatch warning, align your Stove BOM and test dependencies with the `stove-cli` version, or upgrade `stove-cli` to the runtime version reported by the selected app. AI agents can connect to the local MCP endpoint shown in the startup output. See [MCP](21-mcp.md) for the tool list and fallback behavior. ## What Gets Captured Once `dashboard {}` is registered, Stove automatically captures everything — no code changes to your tests: | Event | Data | |--------------------|--------------------------------------------------------------| | **Run lifecycle** | Start/end timestamps, app name, active systems, pass/fail counts | | **Test lifecycle** | Test name, spec name, duration, status, error messages | | **Entries** | Every `http {}`, `kafka {}`, `postgresql {}` assertion — system, action, input/output, expected/actual, trace ID | | **Spans** | Distributed traces via OpenTelemetry — operation, service, duration, attributes, exceptions | | **Snapshots** | System state at test boundaries — database contents, Kafka offsets, WireMock stubs | ## The Dashboard The embedded SPA provides four views for each test: ### Timeline Chronological list of every action the test performed. Each entry shows timestamp, system badge (color-coded), action name, and pass/fail indicator. Click any entry to expand full detail: input, output, expected vs. actual, error, metadata. Recognized systems: HTTP, Kafka, PostgreSQL, MongoDB, Couchbase, Redis, Elasticsearch, WireMock, gRPC, MySQL, MSSQL, Cassandra. ### Trace Distributed trace tree built from OpenTelemetry spans. Spans are linked to tests via two mechanisms: - **Entry-based**: spans sharing a `trace_id` with a test entry - **Attribute-based**: spans containing `x-stove-test-id` in their attributes The tree shows operation name, service, duration, status, relevant attributes (`http.*`, `db.*`, `messaging.*`, `rpc.*`), and exception details with stack traces. !!! tip "Combine with Tracing" Dashboard's trace view is the visual counterpart to the [Tracing](15-tracing.md) component's console output. Enable both for the best experience: Tracing gives you assertion DSL and failure reports in the terminal, Dashboard gives you a browsable trace tree in the browser. ### Snapshots Grid of system state cards captured at test boundaries. Each card shows the system name with a color-coded icon and a summary of the captured state. ### Kafka Explorer Dedicated view filtering Kafka-specific entries. Shows consumed/published/failed message counts with expandable JSON payloads. ## Configuration ### DashboardSystemOptions ```kotlin DashboardSystemOptions( appName = "product-api", // required: identifies the application under test cliHost = "localhost", // where the stove CLI is running cliPort = 4041 // gRPC port of the stove CLI ) ``` | Parameter | Type | Default | Description | |-----------|----------|---------------|--------------------------------------------| | `appName` | `String` | *(required)* | Application name for grouping test runs | | `cliHost` | `String` | `"localhost"` | Hostname where `stove` CLI is running | | `cliPort` | `Int` | `4041` | gRPC port where `stove` CLI is listening | ### CLI Options ``` stove [OPTIONS] Options: --port HTTP port for the web UI and REST API [default: 4040] --grpc-port gRPC port for receiving events [default: 4041] --db Path to SQLite database file [default: ~/.stove-dashboard.db] --clear Clear all stored data and exit --fresh-start Back up and recreate the database, then start normally -h, --help Print help -V, --version Print version ``` ```bash # Run on custom ports stove --port 8080 --grpc-port 8081 # Use a project-specific database stove --db ./my-project-dashboard.db # Reset all data (exits after clearing) stove --clear # Drop and recreate the database (backs up first, then starts servers) stove --fresh-start ``` ## Fault Tolerance The dashboard emitter is designed to never break your tests: - Non-blocking event queue (capacity: 512) - Auto-disables after 5 consecutive gRPC failures - 3-second drain timeout on shutdown - If the dashboard CLI is not running, tests continue normally with zero overhead This means you can add `dashboard {}` to your config permanently. When the CLI is running, you get the dashboard. When it's not, nothing changes. ## REST API The dashboard exposes a REST API at `/api/v1` for programmatic access: | Method | Path | Description | |--------|----------------------------------------------|--------------------------------| | GET | `/meta` | CLI version and MCP discovery metadata | | GET | `/apps` | List applications with latest run info | | GET | `/runs?app={name}` | List runs, optionally filtered by app | | GET | `/runs/{run_id}` | Get a specific run | | GET | `/runs/{run_id}/tests` | List tests in a run | | GET | `/runs/{run_id}/tests/{test_id}/entries` | List entries for a test | | GET | `/runs/{run_id}/tests/{test_id}/spans` | List spans linked to a test | | GET | `/runs/{run_id}/tests/{test_id}/snapshots` | List snapshots for a test | | GET | `/traces/{trace_id}` | Get all spans in a trace | | GET | `/events/stream` | SSE stream for real-time events | For AI agents, prefer the MCP endpoint at `/mcp` over the REST API when the task is failed-test triage. MCP returns compact, scoped evidence and ready-to-use follow-up tool calls. See [MCP](21-mcp.md) for setup and fallback behavior. ### SSE Events The `/events/stream` endpoint delivers server-sent events with JSON payloads: ```json {"run_id": "abc-123", "event_type": "test_ended"} ``` Event types: `run_started`, `run_ended`, `test_started`, `test_ended`, `entry_recorded`, `span_recorded`, `snapshot`. ## Complete Example ```kotlin hl_lines="7-8" class StoveConfig : AbstractProjectConfig() { override val extensions = listOf(StoveKotestExtension()) override suspend fun beforeProject() = Stove() .with { dashboard { DashboardSystemOptions(appName = "spring-example") } tracing { enableSpanReceiver() } httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:$appPort") } postgresql { PostgresqlOptions(databaseName = "stove", configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") }) } kafka { KafkaSystemOptions(configureExposedConfiguration = { listOf("kafka.bootstrapServers=${it.bootstrapServers}") }) } springBoot(runner = { params -> run(params) { addTestSystemDependencies() } }) }.run() override suspend fun afterProject() = Stove.stop() } ``` Then write tests as usual — the dashboard captures everything automatically: ```kotlin test("should create order and publish event") { stove { http { postAndExpectBodilessResponse("/orders", body = CreateOrderRequest(orderId).some()) { it.status shouldBe 201 } } kafka { shouldBePublished { actual.orderId == orderId } } postgresql { shouldQuery("SELECT * FROM orders WHERE id = '$orderId'") { it.first().status shouldBe "CREATED" } } } } ``` Open [http://localhost:4040](http://localhost:4040) to see every HTTP request, Kafka message, database query, and distributed trace — in real time. ## How It Relates to Reporting and Tracing Dashboard, [Reporting](13-reporting.md), and [Tracing](15-tracing.md) are complementary: | Feature | Reporting | Tracing | Dashboard | |---------|-----------|---------|--------| | When | On test failure | On test failure | Always (real-time) | | Where | Console/CI output | Console/CI output | Browser UI | | What | Test actions + assertions | Application call chain | Everything + history | | Persistence | None (ephemeral) | None (ephemeral) | SQLite (across runs) | Use all three together for the best experience: - **Reporting** gives you immediate feedback in the terminal when something breaks - **Tracing** gives you the execution trace and assertion DSL in your test code - **Dashboard** gives you a browsable, persistent view of all test runs — successful and failed ## Troubleshooting ### Dashboard UI shows "Waiting for test events..." 1. Verify the `stove` CLI is running: `stove --version` 2. Check that gRPC ports match: CLI default is `4041`, Kotlin default is `4041` 3. Look for connection errors in the CLI's terminal output ### Tests run fine but nothing appears in Dashboard 1. Ensure `dashboard {}` is registered in your Stove config 2. Verify `stove-dashboard` is in your test dependencies 3. Check that the CLI started *before* running tests ### Dashboard works locally but not in CI Dashboard is designed for local development. In CI, use [Reporting](13-reporting.md) and [Tracing](15-tracing.md) for failure diagnostics — they output to the console and don't require a running server. ### Data from previous runs clutters the UI ```bash stove --clear ``` This wipes the SQLite database and exits. Start the CLI again for a clean slate. ### Database schema is corrupted or migrations fail ```bash stove --fresh-start ``` This backs up the existing database (printing the backup path), deletes it, and recreates a fresh one with all migrations applied. The servers start normally after — no need to run `stove` again. ================================================ FILE: docs/Components/19-provided-application.md ================================================ # Provided Application (Black-Box Testing) Stove normally starts the application under test locally via a framework starter (`springBoot()`, `ktor()`, etc.). With `providedApplication()`, you can skip that entirely and test against a remote, already-deployed application --- regardless of what language or framework it's built with. ## When to Use - **Staging/pre-production validation** --- verify deployed services before release - **Polyglot testing** --- the app can be Go, Python, .NET, Rust, Node.js, or anything else - **Microservice integration** --- test a service through its public API and verify side effects in databases, Kafka, and caches - **Smoke testing** --- run Stove tests as post-deployment checks in CI/CD ## Configure `providedApplication()` replaces the framework starter (`springBoot()`, `ktor()`, etc.) in the `with` block. HTTP, databases, and other systems are configured separately as usual. ```kotlin hl_lines="3 8-13" Stove().with { // Your app's API --- configured via httpClient as usual httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } // Signal: app is already running, don't start it providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet( url = "https://staging.myapp.com/actuator/health" ) ) } }.run() ``` ### Health Check The optional readiness check verifies the remote application is reachable before tests run. If the check fails after all retries, Stove throws immediately with a clear error. ```kotlin ReadinessStrategy.HttpGet( url = "https://staging.myapp.com/health", // Health endpoint URL timeout = 30.seconds, // HTTP request timeout retries = 10, // Number of retry attempts retryDelay = 1.seconds, // Delay between retries expectedStatusCodes = setOf(200) // Status codes considered healthy ) ``` ### No Health Check If you're sure the app is up, skip the readiness check entirely: ```kotlin providedApplication() // No-op --- just satisfies the AUT requirement ``` ## Complete Example ```kotlin hl_lines="4 11-14 17-24 27" class TestConfig : AbstractProjectConfig() { override suspend fun beforeProject() { Stove().with { // The app itself httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } // Infrastructure the app connects to postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", host = "staging-db", port = 5432, configureExposedConfiguration = { emptyList() } ) } kafka { KafkaSystemOptions.provided( bootstrapServers = "staging-kafka:9092", configureExposedConfiguration = { emptyList() } ) } // App is already deployed providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet( url = "https://staging.myapp.com/actuator/health", timeout = 15.seconds ) ) } }.run() } override suspend fun afterProject() = Stove.stop() } ``` ## Writing Tests Tests are written exactly the same way --- the DSL doesn't change: ```kotlin test("create order and verify side effects") { stove { http { postAndExpectJson("/orders", body = request.some()) { order -> order.id shouldNotBe null } } postgresql { shouldQuery("SELECT * FROM orders WHERE id = ?", listOf(orderId)) { rows -> rows shouldHaveSize 1 } } kafka { // Use consumer() to read directly from topics (no interceptor needed) consumer("orders.output", readOnly = true) { record -> record.value().orderId shouldBe orderId } } } } ``` ## Limitations | Feature | Available? | Notes | |---------|-----------|-------| | HTTP/gRPC assertions | Yes | Via `httpClient {}` and `grpc {}` | | Database queries | Yes | Via `postgresql {}`, `mongodb {}`, etc. | | Kafka publish + consumer | Yes | `publish()` and `consumer()` work directly | | Kafka `shouldBeConsumed` | No | Requires interceptor bridge inside the app | | `using {}` (Bridge) | No | Remote app's DI container is not accessible | !!! warning "Bridge Not Supported" `using { }` accesses the application's DI container, which is only possible when the app runs in the same JVM. With `providedApplication()`, calling `using` throws a clear error explaining this. ## Suggested Source Set For projects that have both local e2e tests and black-box tests against deployed apps: ``` my-service/ src/ main/ # Application code test/ # Unit tests test-e2e/ # Stove e2e tests (app started locally) test-blackbox/ # Stove black-box tests (providedApplication) ``` See also: [Multiple Systems](20-multiple-systems.md) for testing against multiple named service instances. ================================================ FILE: docs/Components/20-multiple-systems.md ================================================ # Multiple Systems By default, Stove registers one instance per system type --- one PostgreSQL, one Kafka, one HTTP client. With multiple systems, you can register multiple instances of the same system type, each identified by a typed key. ## When to Use - **Microservice integration** --- call multiple services, each with its own HTTP client or gRPC stub - **Multiple databases** --- verify state in separate PostgreSQL or MongoDB instances - **Multi-cluster Kafka** --- publish/consume from different Kafka clusters - **Cross-service verification** --- after calling your app, check that dependent services received the right data ## Define Keys Keys are Kotlin singleton objects implementing `SystemKey`: ```kotlin object OrderService : SystemKey object PaymentService : SystemKey object AppDb : SystemKey object AnalyticsDb : SystemKey ``` !!! tip "Why Objects Instead of Strings?" Kotlin objects give you compile-time safety, IDE autocomplete, and refactor-safe references. Typos become compile errors. The same key can be reused across protocols --- `httpClient(PaymentService)` and `grpc(PaymentService)` both refer to the same logical service. ## Configure Pass the key as the first argument to any system DSL function: ```kotlin hl_lines="3 8 13 18" Stove().with { // Default HTTP client --- your app httpClient { HttpClientSystemOptions(baseUrl = "https://myapp.staging.com") } // Keyed HTTP clients --- dependent services httpClient(OrderService) { HttpClientSystemOptions(baseUrl = "https://order.internal.com") } httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "https://payment.internal.com") } // Keyed databases postgresql(AppDb) { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", host = "staging-db", port = 5432, configureExposedConfiguration = { emptyList() } ) } postgresql(AnalyticsDb) { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://analytics-db:5432/analytics", host = "analytics-db", port = 5432, configureExposedConfiguration = { emptyList() } ) } providedApplication() }.run() ``` ## Write Tests Pass the key to the validation DSL: ```kotlin hl_lines="5 12 19 26" test("create order, verify across services and databases") { stove { // Call the app (default HTTP --- no key) http { postAndExpectJson("/orders", body = request.some()) { order -> order.id shouldNotBe null } } // Verify order service received the order http(OrderService) { getResponse("/api/orders/$orderId") { resp -> resp.status shouldBe 200 } } // Verify payment was processed http(PaymentService) { getResponse("/api/payments?orderId=$orderId") { resp -> resp.status shouldBe 200 } } // Verify in app's database postgresql(AppDb) { shouldQuery("SELECT * FROM orders WHERE id = ?", listOf(orderId)) { rows -> rows shouldHaveSize 1 } } // Verify analytics event landed postgresql(AnalyticsDb) { shouldQuery("SELECT * FROM events WHERE order_id = ?", listOf(orderId)) { events -> events.first().type shouldBe "ORDER_CREATED" } } } } ``` ## Supported Systems All multi-instance dependency systems support keyed registration: | Category | Systems | |----------|---------| | **Databases** | PostgreSQL, MySQL, MSSQL, MongoDB, Cassandra, Couchbase, Redis, Elasticsearch | | **Protocol clients** | HTTP Client, gRPC | | **Messaging** | Kafka | | **Mocking** | WireMock, gRPC Mock | Single-instance systems (Bridge, Tracing, Dashboard) and framework starters (`springBoot()`, `ktor()`) do **not** support keyed registration --- there is only one application under test. !!! info "Spring Kafka" The Spring Kafka starter (`stove-spring-kafka`) does not support keyed instances because it is tied to a single Spring application context. Use the standalone `stove-kafka` module if you need multiple Kafka instances. ## Default and Keyed Coexist Default (unkeyed) and keyed instances of the same type are independent: ```kotlin // Registration httpClient { HttpClientSystemOptions(baseUrl = "https://myapp.com") } // default httpClient(OrderService) { HttpClientSystemOptions(baseUrl = "https://order.internal.com") } // keyed // Validation http { /* hits myapp.com */ } // default http(OrderService) { /* hits order.internal.com */ } // keyed ``` ## Reporting Keyed systems produce distinguishable names in reports and traces: ``` HTTP > GET /orders # default HTTP [OrderService] > GET /api/orders/123 # keyed HTTP [PaymentService] > GET /api/payments # keyed PostgreSQL [AppDb] > shouldQuery > SELECT # keyed PostgreSQL [AnalyticsDb] > shouldQuery # keyed ``` ## Error Handling If you pass a key that wasn't registered, you get a clear runtime error: ``` SystemNotRegisteredException: HttpSystem was not registered. No HttpSystem registered with key 'OrderService' ``` ## Combining with Provided Application Keyed systems and `providedApplication()` are designed to work together for full black-box testing: ```kotlin hl_lines="2-4 7-14 17" Stove().with { // Your app's API httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } // Dependent services and infrastructure httpClient(OrderService) { HttpClientSystemOptions(baseUrl = "https://order.internal.com") } postgresql(AppDb) { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", host = "staging-db", port = 5432, configureExposedConfiguration = { emptyList() } ) } kafka { KafkaSystemOptions.provided( bootstrapServers = "staging-kafka:9092", configureExposedConfiguration = { emptyList() } ) } // App already running providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet(url = "https://staging.myapp.com/health") ) } }.run() ``` See also: [Provided Application](19-provided-application.md) for testing against deployed apps. ================================================ FILE: docs/Components/21-mcp.md ================================================ # MCP Stove CLI exposes a local MCP endpoint for AI agents. It lets agents inspect failed end-to-end tests through compact, structured tools instead of loading full logs into context. MCP starts automatically when you run `stove`: ```bash stove ``` The startup output includes the endpoint: ```text Stove CLI v0.23.0 running UI: http://localhost:4040 REST: http://localhost:4040/api/v1 MCP: http://localhost:4040/mcp gRPC: localhost:4041 ``` ## Discovery Agents and humans can discover Stove MCP from: - the `stove` startup banner - the dashboard UI - `GET http://localhost:4040/api/v1/meta` - project or agent instructions The metadata endpoint includes: ```json { "stove_cli_version": "0.23.0", "mcp": { "enabled": true, "transport": "streamable-http", "endpoint": "http://localhost:4040/mcp", "scope": "read-only-test-observability" } } ``` Most MCP clients need the endpoint URL explicitly. There is no guaranteed universal auto-discovery mechanism for local MCP servers, so the endpoint is advertised in the places above. ## Integration Stove MCP is served by `stove-cli`; application systems and test JVMs do not start or host MCP themselves. The integration path is: 1. Start `stove`. 2. Configure the MCP client or agent runtime to use the Streamable HTTP endpoint from the startup banner or `/api/v1/meta`. 3. Run the tests as usual. Stove records runs, entries, traces, and snapshots through its normal dashboard pipeline. 4. Let the agent query MCP for compact evidence after a failure. Generic MCP client configuration should point at the same HTTP port as the dashboard: ```json { "mcpServers": { "stove": { "transport": "streamable-http", "url": "http://localhost:4040/mcp" } } } ``` Exact configuration keys vary by agent runtime, but the important values are the transport and URL. If the dashboard runs on a custom port, use the endpoint printed by `stove` or returned from `/api/v1/meta`. Recommended agent instruction: ```text When Stove is running, prefer the local Stove MCP endpoint for failed-test triage. Start with stove_failures, then use the returned run_id + test_id with stove_failure_detail. Drill into stove_timeline, stove_trace, or stove_snapshot only when needed. If MCP is unavailable, ambiguous, or incomplete, fall back to normal test output, Stove reports, and logs. ``` Agents should not infer a test selector from names alone. A database can contain multiple apps, multiple runs per app, and duplicate test names. Use the exact `run_id + test_id` returned by MCP. ## Agent Workflow Use MCP as an optimization, not as a dependency: 1. Call `stove_failures`. 2. Pick a specific `run_id` and `test_id` from the result. 3. Call `stove_failure_detail` for the compact failure packet. 4. Drill into `stove_timeline`, `stove_trace`, or `stove_snapshot` only when needed. 5. If MCP is unavailable, ambiguous, or missing data, fall back to normal test output, Stove failure reports, and logs. Stove data is hierarchical: ```text database -> apps by app_name -> runs by run_id -> tests by test_id -> entries, spans, snapshots ``` `app_name` is a grouping label. A single database can contain multiple apps, and each app can have many runs. `run_id + test_id` is the exact test selector. ## Tools | Tool | Purpose | |------|---------| | `stove_apps` | Lists apps recorded in the dashboard database | | `stove_runs` | Lists runs, filterable by app and status | | `stove_failures` | Default entrypoint; failed tests grouped by app and run | | `stove_failure_detail` | Compact detail for one exact failed test | | `stove_timeline` | Ordered test actions, failure-focused by default | | `stove_trace` | Critical path and exception evidence from correlated spans | | `stove_snapshot` | System snapshot summaries and targeted JSON drill-down | | `stove_raw_evidence` | Capped raw lookup for one entry, span, or snapshot | Every failure result includes ready-to-use next tool calls, so agents do not need to guess scopes from names. ## Token Budgeting MCP tools default to compact output. Large payloads are truncated deterministically and include omitted counts or follow-up tool calls. Sensitive keys such as `authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, and `credential` are redacted before data is returned. Use `budget` when a client needs a different amount of detail: ```json { "budget": "tiny" } ``` Supported values are `tiny`, `compact`, and `full`. Tools that expose raw evidence also accept `max_chars`. ## Security The MCP endpoint is read-only and local-only. It does not expose tools to clear data, retry tests, delete runs, or mutate snapshots. `/mcp` accepts loopback clients and localhost `Host`/`Origin` headers. Requests from non-local hosts are rejected to reduce the risk of browser or DNS rebinding abuse. ## Troubleshooting If an agent cannot use MCP: - confirm `stove` is running - check the startup banner for the actual port - open `http://localhost:4040/api/v1/meta` and verify `mcp.enabled` is `true` - make sure the MCP client is configured with `http://localhost:4040/mcp` - fall back to normal test output and logs if the endpoint cannot be reached If MCP returns no failures, the latest recorded runs may have passed, the dashboard dependency may not be registered in the test config, or the test run may still be in progress. ================================================ FILE: docs/Components/22-container.md ================================================ # Container AUT (`stove-container`) `stove-container` runs the application under test as a Docker image. It works with **any language and any framework** — Go, Python, Node.js, Rust, .NET, JVM, anything that ships in a container — and gives you image-level parity with what you deploy to production. For a host-binary AUT (process mode), see [`stove-process`](../other-languages/index.md). For a Go-specific walkthrough that pairs `stove-container` with PostgreSQL + Kafka + tracing + coverage, see [Go Container Mode](../other-languages/go-container.md). ## What `stove-container` is responsible for - Pulling / locating the image, configuring it as a Testcontainers `GenericContainer` - Mapping Stove configurations to environment variables (`envMapper`) or CLI arguments (`argsMapper`) - Optional pre-start hook (`beforeStarted`) with resolved configurations - Container start, readiness check, log streaming - Graceful stop with configurable timeout, force-close fallback ## What `stove-container` is **not** responsible for - **Building the image.** That is the user's pipeline. Stove only needs an image reference. - **Choosing the image registry or auth.** Use Testcontainers / Docker config like you would for any other test. - **Owning the Dockerfile.** Show your existing production Dockerfile to Stove via a tag. ## Install ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$stoveVersion")) testImplementation("com.trendyol:stove-container") } ``` ## Image source patterns `containerApp(...)` only needs an image reference. Where it comes from is your choice: | Pattern | When to use | How | |---------|-------------|-----| | **CI artifact** | Most realistic CI path | CI publishes a tag (e.g. `ghcr.io/acme/app:sha-abc`); test reads it from a system property or env var | | **Registry pull** | Image already published; no local build needed | Just reference the tag — Testcontainers pulls lazily on first use | | **Local build** (optional) | Inner-loop convenience when iterating on the Dockerfile | Wire a Gradle `Exec` task running `docker build`; have a *separate* test task `dependsOn` it | The minimal Gradle wiring for the CI path: ```kotlin title="build.gradle.kts" val containerImage = providers.environmentVariable("APP_IMAGE") .orElse(providers.gradleProperty("app.image")) .orElse("my-app:local") // local fallback only tasks.register("e2eTest-container") { useJUnitPlatform() systemProperty("app.container.image", containerImage.get()) } ``` ```bash # CI APP_IMAGE=ghcr.io/acme/app:sha-abc123 ./gradlew e2eTest-container # or ./gradlew e2eTest-container -Papp.image=ghcr.io/acme/app:sha-abc123 ``` A separate optional task can wrap `docker build` for local convenience without coupling it to the main test task. ## DSL: `containerApp(...)` ```kotlin import com.trendyol.stove.container.ContainerTarget import com.trendyol.stove.container.containerApp import com.trendyol.stove.system.application.envMapper containerApp( image = System.getProperty("app.container.image"), target = ContainerTarget.Server( hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false // host network → no need to bind ), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") }, configureContainer = { withNetworkMode("host") }, beforeStarted = { configurations -> // optional async hook with resolved configs } ) ``` ### Parameters | Parameter | Type | Purpose | |-----------|------|---------| | `image` | `String` | Image reference. From CI tag, registry, or local build — Stove does not care | | `target` | `ContainerTarget` | `Server` (HTTP / gRPC / TCP) or `Worker` (consumers, jobs); carries the readiness strategy | | `registry` | `String` | Image registry override (defaults to `DEFAULT_REGISTRY`) | | `compatibleSubstitute` | `String?` | Substitute image for arch/OS compatibility (Apple Silicon / arm64) | | `command` | `List` | Override container command (gets `argsMapper` output appended) | | `envProvider` | `EnvProvider` | `envMapper { ... }` mapping Stove configs to env vars | | `argsProvider` | `ArgsProvider` | `argsMapper(prefix, separator) { ... }` for CLI-flag-driven apps | | `beforeStarted` | suspend lambda | Async hook with resolved configs, runs before container start | | `configureContainer` | `GenericContainer<*>.()` | Anything Testcontainers exposes — bind mounts, network mode, capabilities, log consumers | | `gracefulShutdownTimeout` | `Duration` | Defaults to 5 seconds; falls back to force-close on timeout | ### `ContainerTarget` variants | Variant | Use case | Default readiness | |---------|----------|-------------------| | `ContainerTarget.Server(hostPort, internalPort, portEnvVar, bindHostPort)` | HTTP / gRPC / TCP servers | HTTP GET `http://localhost:$hostPort/health` | | `ContainerTarget.Worker()` | Kafka consumers, batch jobs | 2-second fixed delay | `bindHostPort = false` is the right default when using `withNetworkMode("host")` — the container shares the host network namespace and binding the port again would conflict. ### Readiness strategies `ContainerTarget.Server` defaults to `ReadinessStrategy.HttpGet`. You can override: ```kotlin target = ContainerTarget.Server( hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", readiness = ReadinessStrategy.TcpPort(8090) // for raw TCP / gRPC w/o HTTP ) ``` | Strategy | Use case | |----------|----------| | `ReadinessStrategy.HttpGet(url, timeout, retries, retryDelay, expectedStatusCodes)` | REST APIs | | `ReadinessStrategy.TcpPort(port)` | gRPC / raw TCP (no HTTP) | | `ReadinessStrategy.Probe { ... }` | Custom (file, DB query, log scan, etc.) | | `ReadinessStrategy.FixedDelay(duration)` | Workers / no readiness signal | ## Networking strategies === "Host network (Linux only)" ```kotlin target = ContainerTarget.Server(hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false), configureContainer = { withNetworkMode("host") } ``` Container shares the host's network namespace. The app reaches PostgreSQL / Kafka on `localhost`. Does **not** work on Docker Desktop for macOS / Windows. === "Port binding (cross-platform)" ```kotlin target = ContainerTarget.Server(hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = true), configureContainer = { withNetwork(Network.SHARED) } ``` Stove binds `hostPort → internalPort`. The app reaches databases / brokers via shared network aliases or `host.docker.internal`. ## `configureContainer { ... }` Accepts a `GenericContainer<*>.()` block. Anything Testcontainers exposes is available: ```kotlin configureContainer = { withNetworkMode("host") withFileSystemBind(hostPath, "/inside/container") withLogConsumer(Slf4jLogConsumer(LoggerFactory.getLogger("app"))) withEnv("EXTRA_DEBUG", "1") withCreateContainerCmdModifier { cmd -> /* low-level docker-java */ } } ``` Use bind mounts for any data the container or the test needs to share with the host: coverage directories, fixture seeds, read-only configs. ## `beforeStarted { ... }` Async hook that runs after Stove resolves all configurations but before the container starts. Useful for prepping data the app expects on boot. ```kotlin beforeStarted = { configurations -> seedRedisCache(configurations["redis.host"]!!) } ``` ## Switching between process and container mode A single `StoveConfig.kt` can serve both starters by branching on a system property. Infrastructure systems and tests stay identical — only the AUT runner changes. ```kotlin when ((System.getProperty("aut.mode") ?: "process").lowercase()) { "process" -> processApp { ProcessApplicationOptions(/* ... */) } "container" -> containerApp(/* ... */) else -> error("Unsupported aut.mode") } ``` ```kotlin tasks.register("e2eTest") { systemProperty("aut.mode", "process") } tasks.register("e2eTest-container") { systemProperty("aut.mode", "container") } ``` A common pattern: `e2eTest` runs process mode locally for fast iteration; `e2eTest-container` runs container mode in CI against the image the build job just published. ## Common pitfalls | Symptom | Cause | Fix | |---------|-------|-----| | `connection refused` to Postgres / Kafka inside container | Container can't reach Testcontainers on `localhost` | `withNetworkMode("host")` (Linux) or shared network + aliases (cross-platform) | | Stove never sees `/health` | Wrong port / binding | Confirm `bindHostPort` matches network mode; verify app listens on `internalPort` | | `Failed to start container application` | Image missing or unauthorized pull | Verify the image exists locally / in the registry; check `docker images` and registry credentials | | Slow inner loop | Image build dominates iteration | Use [`stove-process`](../other-languages/index.md) for daily dev; container mode in CI | | App killed before clean shutdown | `gracefulShutdownTimeout` too short for the app | Bump `gracefulShutdownTimeout` on `containerApp(...)` | ## Reference - Module source: `starters/container/stove-container/` - DSL source: `starters/container/stove-container/src/main/kotlin/com/trendyol/stove/container/ContainerDsl.kt` - Go-specific recipe (process **and** container modes in one repo): [`recipes/process/golang/go-showcase`](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase) - Related docs: - [Go Container Mode](../other-languages/go-container.md) — Go-specific walkthrough that uses this module - [Other Languages & Stacks](../other-languages/index.md) — process vs. container overview - [Dashboard](18-dashboard.md) and [MCP](21-mcp.md) — observability for any AUT, including container ones - [Tracing](15-tracing.md) — distributed tracing across the test and the container ================================================ FILE: docs/Components/index.md ================================================ # Components Stove uses a pluggable architecture. Each physical dependency is a separate module you add only when the test actually needs it. By default, components use Testcontainers under the hood, but they can also connect to [provided instances](11-provided-instances.md) (existing infrastructure) when Docker is unavailable or undesirable. This section also includes observability and agent-facing tooling such as [Reporting](13-reporting.md), [Tracing](15-tracing.md), [Dashboard](18-dashboard.md), and [MCP](21-mcp.md). These do not replace a dependency system; they help you understand failures and share compact evidence with tools. If you have not picked an application starter yet, start with [Supported Frameworks](../frameworks/index.md) first and then come back here for the physical dependencies. Most teams start with 2 to 4 components, not the whole catalog. ## Start From The Workflow
- **REST service with a database** Start with [HTTP Client](05-http.md), one database such as [PostgreSQL](06-postgresql.md), and optionally [WireMock](04-wiremock.md) for external calls. - **Kafka-driven workflow** Start with [Kafka](02-kafka.md), your main database, and [Tracing](15-tracing.md) for better failure diagnosis. - **Service calling external dependencies** Start with [HTTP Client](05-http.md), [WireMock](04-wiremock.md), or [gRPC Mock](14-grpc-mock.md) depending on the remote protocol. - **Hard-to-debug end-to-end failures** Add [Tracing](15-tracing.md) and [Reporting](13-reporting.md) early so failures show execution context instead of only raw assertions. - **Visual test observability** Add [Dashboard](18-dashboard.md) for a real-time web dashboard that shows every test interaction, distributed trace, and system snapshot across runs. Agents can use [MCP](21-mcp.md) for compact failed-test evidence. - **Testing a deployed service (any language)** Use [Provided Application](19-provided-application.md) with [HTTP Client](05-http.md) and your databases. Add [Multiple Systems](20-multiple-systems.md) if you need to verify multiple dependent services.
## Common Starting Sets | You are testing | Start with | |-----------------|------------| | HTTP API backed by SQL | `stove-http` + `stove-postgres` or `stove-mysql` | | Event-driven service | `stove-kafka` + your database + `stove-tracing` | | External provider integration | `stove-http` + `stove-wiremock` | | gRPC service | `stove-grpc` + `stove-grpc-mock` | | Stateful service with caching | your database + `stove-redis` | | Deployed service (any language) | `stove-http` + your databases + `providedApplication()` | ## Available Components | Component | Module | Description | |-----------|--------|-------------| | [Kafka](02-kafka.md) | `stove-kafka` | Message broker for event-driven architectures | | [Couchbase](01-couchbase.md) | `stove-couchbase` | NoSQL document database | | [Elasticsearch](03-elasticsearch.md) | `stove-elasticsearch` | Search and analytics engine | | [PostgreSQL](06-postgresql.md) | `stove-postgres` | Relational database | | [MySQL](16-mysql.md) | `stove-mysql` | Relational database | | [MongoDB](07-mongodb.md) | `stove-mongodb` | NoSQL document database | | [MSSQL](08-mssql.md) | `stove-mssql` | Microsoft SQL Server | | [Redis](09-redis.md) | `stove-redis` | In-memory data store | | [Cassandra](17-cassandra.md) | `stove-cassandra` | Wide-column NoSQL database | | [WireMock](04-wiremock.md) | `stove-wiremock` | HTTP mock server for external services | | [gRPC Mock](14-grpc-mock.md) | `stove-grpc-mock` | gRPC mock server for external gRPC services | | [HTTP Client](05-http.md) | `stove-http` | HTTP client for testing your API | | [gRPC](12-grpc.md) | `stove-grpc` | gRPC client for testing gRPC services | | [Bridge](10-bridge.md) | Built-in | Access to application's DI container | | [Tracing](15-tracing.md) | `stove-tracing` | Execution tracing with OpenTelemetry for failure diagnostics | | [Provided Instances](11-provided-instances.md) | Built-in | Connect to existing infrastructure instead of containers | | [Provided Application](19-provided-application.md) | Built-in | Test against a remote, already-deployed application (any language) | | [Multiple Systems](20-multiple-systems.md) | Built-in | Register multiple instances of the same system type with typed keys | | [Reporting](13-reporting.md) | `stove-extensions-kotest` or `stove-extensions-junit` | Rich failure reports with execution context | | [Dashboard](18-dashboard.md) | `stove-dashboard` + [CLI](https://github.com/Trendyol/stove/tree/main/tools/stove-cli) | Real-time web dashboard for test observability | | [MCP](21-mcp.md) | `stove-cli` | Local read-only agent API for compact failed-test evidence | ## Quick Start Add one starter, then only the components your scenario needs: === "Gradle" ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove") // Pick one application starter testImplementation("com.trendyol:stove-spring") // Add only what the test touches testImplementation("com.trendyol:stove-http") testImplementation("com.trendyol:stove-postgres") testImplementation("com.trendyol:stove-wiremock") testImplementation("com.trendyol:stove-tracing") } ``` Swap in `stove-kafka`, `stove-redis`, `stove-grpc`, `stove-mongodb`, or other modules as your flow requires. ## Architecture Overview Each component follows a consistent pattern: 1. **Configuration** - Define how the component should be set up 2. **Container/Runtime** - Manages the testcontainer or provided instance 3. **DSL** - Fluent API for test assertions 4. **Cleanup** - Automatic resource management ```kotlin Stove() .with { // Each component is configured in the `with` block kafka { KafkaSystemOptions(...) } couchbase { CouchbaseSystemOptions(...) } http { HttpClientSystemOptions(...) } wiremock { WireMockSystemOptions(...) } tracing { enableSpanReceiver() } // Application under test springBoot(runner = { params -> myApp.run(params) }) } .run() // Starts all components and the application // Test your application stove { http { /* HTTP assertions */ } kafka { /* Kafka assertions */ } couchbase { /* Database assertions */ } } ``` ## Component Categories ### Databases | Type | Components | Use Case | |------|------------|----------| | Document | [Couchbase](01-couchbase.md), [MongoDB](07-mongodb.md), [Elasticsearch](03-elasticsearch.md) | JSON document storage, search | | Relational | [PostgreSQL](06-postgresql.md), [MySQL](16-mysql.md), [MSSQL](08-mssql.md) | Structured data, transactions | | Key-Value | [Redis](09-redis.md) | Caching, sessions, pub/sub | | Wide-Column | [Cassandra](17-cassandra.md) (`stove-cassandra`) | Time-series, IoT, large-scale writes | ### Messaging | Component | Use Case | |-----------|----------| | [Kafka](02-kafka.md) | Event streaming, message queues, pub/sub | ### Network | Component | Use Case | |-----------|----------| | [HTTP Client](05-http.md) | Testing your application's REST API | | [gRPC](12-grpc.md) | Testing your application's gRPC services | | [WireMock](04-wiremock.md) | Mocking external HTTP services | | [gRPC Mock](14-grpc-mock.md) | Mocking external gRPC services | ### Application Integration | Component | Use Case | |-----------|----------| | [Bridge](10-bridge.md) | Access application beans and services directly (supported by Spring, Ktor, and Micronaut starters) | | [Provided Application](19-provided-application.md) | Test against a remote app without starting it locally --- any language, any framework | | [Multiple Systems](20-multiple-systems.md) | Register multiple named instances of the same system type (e.g., two PostgreSQL databases) | | [Reporting](13-reporting.md) | Detailed execution reports and failure diagnostics | | [Tracing](15-tracing.md) | Execution tracing with full call chain visibility on failure | | [Dashboard](18-dashboard.md) | Real-time web dashboard for browsing test runs, traces, and system snapshots | | [MCP](21-mcp.md) | Local read-only agent API for compact failed-test evidence from the dashboard database | ## Common Configuration Pattern All components follow a similar configuration pattern: ```kotlin hl_lines="4-8 12-16" componentName { ComponentSystemOptions( // Container configuration container = ContainerOptions( registry = "docker.io", image = "component-image", tag = "version" ), // Expose configuration to your application configureExposedConfiguration = { cfg -> listOf( "app.component.host=${cfg.host}", "app.component.port=${cfg.port}" ) } ) } ``` ## Testcontainer vs Provided Instance Each component supports two modes: ### Container Mode (Default) Stove automatically manages testcontainers: ```kotlin kafka { KafkaSystemOptions( container = KafkaContainerOptions(tag = "latest"), configureExposedConfiguration = { cfg -> listOf(...) } ) } ``` ### Provided Instance Mode Connect to existing infrastructure (useful for CI/CD): ```kotlin kafka { KafkaSystemOptions.provided( bootstrapServers = "localhost:9092", configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } ``` See [Provided Instances](11-provided-instances.md) for detailed documentation. ## Migrations Support Database components support migrations: ```kotlin class CreateTablesMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute("CREATE TABLE ...") } } postgresql { PostgresqlOptions(...).migrations { register() } } ``` ## Cleanup Support All components support cleanup functions for data isolation: ```kotlin couchbase { CouchbaseSystemOptions( defaultBucket = "bucket", cleanup = { cluster -> cluster.query("DELETE FROM `bucket` WHERE type = 'test'") }, configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } ``` ## Best Practices 1. **Use random data** - Generate unique identifiers for each test to avoid conflicts 2. **Leverage cleanup functions** - Clean test data between runs 3. **Configure timeouts appropriately** - Set realistic timeouts for your environment 4. **Use the DSL consistently** - Leverage the fluent API for readable tests 5. **Combine components** - Test complete workflows across multiple systems ## Example: Full Stack Test ```kotlin hl_lines="7 12 19 26 31 36" test("should process order end-to-end") { stove { val orderId = UUID.randomUUID().toString() // Mock payment service wiremock { mockPost("/payments", statusCode = 200, responseBody = PaymentResult(success = true).some()) } // Create order via API http { postAndExpectBody("/orders", body = CreateOrderRequest(orderId).some()) { it.status shouldBe 201 } } // Verify stored in database couchbase { shouldGet("orders", orderId) { order -> order.status shouldBe "CREATED" } } // Verify event published kafka { shouldBePublished { actual.orderId == orderId } } // Verify indexed for search elasticsearch { shouldGet(index = "orders", key = orderId) { it.status shouldBe "CREATED" } } // Verify cached redis { client().connect().sync().get("order:$orderId") shouldNotBe null } } } ``` ## Detailed Documentation - [Couchbase](01-couchbase.md) - NoSQL document database with N1QL queries - [Kafka](02-kafka.md) - Message streaming with producer/consumer testing - [Elasticsearch](03-elasticsearch.md) - Search engine with query DSL support - [WireMock](04-wiremock.md) - Mock external HTTP dependencies - [gRPC Mock](14-grpc-mock.md) - Mock external gRPC services - [HTTP Client](05-http.md) - Test your REST API endpoints - [gRPC](12-grpc.md) - Test your gRPC services with Wire and grpc-kotlin - [PostgreSQL](06-postgresql.md) - Relational database with SQL support - [MongoDB](07-mongodb.md) - Document database with aggregation support - [MSSQL](08-mssql.md) - Microsoft SQL Server support - [Redis](09-redis.md) - In-memory data store for caching - [Cassandra](17-cassandra.md) - Wide-column NoSQL database with CQL support - [Bridge](10-bridge.md) - Direct access to application beans - [Provided Instances](11-provided-instances.md) - Use external infrastructure - [Provided Application](19-provided-application.md) - Test against a deployed app (any language) - [Multiple Systems](20-multiple-systems.md) - Multiple named instances of the same system type - [Reporting](13-reporting.md) - Detailed execution reports and failure diagnostics - [Tracing](15-tracing.md) - Execution tracing with OpenTelemetry for full call chain visibility - [Dashboard](18-dashboard.md) - Real-time web dashboard for browsing test runs, traces, and snapshots - [MCP](21-mcp.md) - Local read-only agent API for failed-test triage ================================================ FILE: docs/assets/rough-notation.iife.js ================================================ var RoughNotation=function(t){"use strict";const e="http://www.w3.org/2000/svg";class s{constructor(t){this.seed=t}next(){return this.seed?(2**31-1&(this.seed=Math.imul(48271,this.seed)))/2**31:Math.random()}}function i(t,e,s,i,n){return{type:"path",ops:u(t,e,s,i,n)}}function n(t,e,s){const n=(t||[]).length;if(n>2){const i=[];for(let e=0;e500?.4:-.0016668*u+1.233334;let l=n.maxRandomnessOffset||0;l*l*100>a&&(l=u/10);const g=l/2,d=.2+.2*h(n);let p=n.bowing*n.maxRandomnessOffset*(i-e)/200,_=n.bowing*n.maxRandomnessOffset*(t-s)/200;p=c(p,n,f),_=c(_,n,f);const m=[],w=()=>c(g,n,f),v=()=>c(l,n,f);return o&&(r?m.push({op:"move",data:[t+w(),e+w()]}):m.push({op:"move",data:[t+c(l,n,f),e+c(l,n,f)]})),r?m.push({op:"bcurveTo",data:[p+t+(s-t)*d+w(),_+e+(i-e)*d+w(),p+t+2*(s-t)*d+w(),_+e+2*(i-e)*d+w(),s+w(),i+w()]}):m.push({op:"bcurveTo",data:[p+t+(s-t)*d+v(),_+e+(i-e)*d+v(),p+t+2*(s-t)*d+v(),_+e+2*(i-e)*d+v(),s+v(),i+v()]}),m}function l(t,e,s){const i=t.length,n=[];if(i>3){const o=[],r=1-s.curveTightness;n.push({op:"move",data:[t[1][0],t[1][1]]});for(let e=1;e+2t.setAttribute(e,s);for(const a of s){const s=document.createElementNS(e,"path");if(r(s,"d",a),r(s,"fill","none"),r(s,"stroke",h.color||"currentColor"),r(s,"stroke-width",""+l),p){const t=s.getTotalLength();i.push(t),o+=t}t.appendChild(s),n.push(s)}if(p){let t=0;for(let e=0;e{this._resizing||(this._resizing=!0,setTimeout(()=>{this._resizing=!1,"showing"===this._state&&this.haveRectsChanged()&&this.show()},400))},this._e=t,this._config=JSON.parse(JSON.stringify(e)),this.attach()}get animate(){return this._config.animate}set animate(t){this._config.animate=t}get animationDuration(){return this._config.animationDuration}set animationDuration(t){this._config.animationDuration=t}get iterations(){return this._config.iterations}set iterations(t){this._config.iterations=t}get color(){return this._config.color}set color(t){this._config.color!==t&&(this._config.color=t,this.refresh())}get strokeWidth(){return this._config.strokeWidth}set strokeWidth(t){this._config.strokeWidth!==t&&(this._config.strokeWidth=t,this.refresh())}get padding(){return this._config.padding}set padding(t){this._config.padding!==t&&(this._config.padding=t,this.refresh())}attach(){if("unattached"===this._state&&this._e.parentElement){!function(){if(!window.__rno_kf_s){const t=window.__rno_kf_s=document.createElement("style");t.textContent="@keyframes rough-notation-dash { to { stroke-dashoffset: 0; } }",document.head.appendChild(t)}}();const t=this._svg=document.createElementNS(e,"svg");t.setAttribute("class","rough-annotation");const s=t.style;s.position="absolute",s.top="0",s.left="0",s.overflow="visible",s.pointerEvents="none",s.width="100px",s.height="100px";const i="highlight"===this._config.type;if(this._e.insertAdjacentElement(i?"beforebegin":"afterend",t),this._state="not-showing",i){const t=window.getComputedStyle(this._e).position;(!t||"static"===t)&&(this._e.style.position="relative")}this.attachListeners()}}detachListeners(){window.removeEventListener("resize",this._resizeListener),this._ro&&this._ro.unobserve(this._e)}attachListeners(){this.detachListeners(),window.addEventListener("resize",this._resizeListener,{passive:!0}),!this._ro&&"ResizeObserver"in window&&(this._ro=new window.ResizeObserver(t=>{for(const e of t)e.contentRect&&this._resizeListener()})),this._ro&&this._ro.observe(this._e)}haveRectsChanged(){if(this._lastSizes.length){const t=this.rects();if(t.length!==this._lastSizes.length)return!0;for(let e=0;eMath.round(t)===Math.round(e);return s(t.x,e.x)&&s(t.y,e.y)&&s(t.w,e.w)&&s(t.h,e.h)}isShowing(){return"not-showing"!==this._state}refresh(){this.isShowing()&&!this.pendingRefresh&&(this.pendingRefresh=Promise.resolve().then(()=>{this.isShowing()&&this.show(),delete this.pendingRefresh}))}show(){switch(this._state){case"unattached":break;case"showing":this.hide(),this._svg&&this.render(this._svg,!0);break;case"not-showing":this.attach(),this._svg&&this.render(this._svg,!1)}}hide(){if(this._svg)for(;this._svg.lastChild;)this._svg.removeChild(this._svg.lastChild);this._state="not-showing"}remove(){this._svg&&this._svg.parentElement&&this._svg.parentElement.removeChild(this._svg),this._svg=void 0,this._state="unattached",this.detachListeners()}render(t,e){let s=this._config;e&&(s=JSON.parse(JSON.stringify(this._config)),s.animate=!1);const i=this.rects();let n=0;i.forEach(t=>n+=t.w);const o=s.animationDuration||800;let r=0;for(let e=0;ee2e tests in the regular `src/test` folder, create a dedicated `src/test-e2e` source set. This provides better separation between unit/integration tests and e2e tests: ``` src/ ├── main/kotlin/ # Application code ├── test/kotlin/ # Unit tests └── test-e2e/kotlin/ # E2E tests with Stove ├── config/ │ └── TestConfig.kt # Contains Stove setup ├── features/ │ ├── OrderE2ETest.kt │ ├── UserE2ETest.kt │ └── ProductE2ETest.kt └── shared/ ├── TestData.kt └── Assertions.kt ``` ### Gradle Configuration Here's how to set up the `test-e2e` source set in your `build.gradle.kts`: ```kotlin sourceSets { @Suppress("LocalVariableName") val `test-e2e` by creating { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output } val testE2eImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) } // Register e2e test task tasks.register("e2eTest") { description = "Runs e2e tests." group = "verification" testClassesDirs = sourceSets["test-e2e"].output.classesDirs classpath = sourceSets["test-e2e"].runtimeClasspath useJUnitPlatform() reports { junitXml.required.set(true) html.required.set(true) } } // Configure IDEA to recognize test-e2e as test sources idea { module { testSources.from(sourceSets["test-e2e"].allSource.sourceDirectories) testResources.from(sourceSets["test-e2e"].resources.sourceDirectories) } } ``` ### Running E2E Tests ```bash # Run only e2e tests ./gradlew e2eTest # Run unit tests (doesn't include e2e) ./gradlew test # Run all tests ./gradlew test e2eTest ``` ### Benefits of Separate Source Set | Benefit | Description | |---------|-------------| | **Isolation** | E2E tests run independently from unit tests | | **CI Flexibility** | Run unit tests quickly, e2e tests separately or in parallel | | **Resource Management** | Different JVM settings for e2e tests (more memory, longer timeouts) | | **Clear Boundaries** | Developers know exactly where e2e tests live | !!! tip "See Examples" Check the [recipes](https://github.com/Trendyol/stove/tree/main/recipes) folder for complete working examples with this structure. ### Single Setup, Multiple Tests Configure Stove once for all tests: ```kotlin hl_lines="4 10 18" // ✅ Good: Single configuration for all tests class TestConfig : AbstractProjectConfig() { override suspend fun beforeProject() { Stove() .with { /* configuration */ } .run() } override suspend fun afterProject() { Stove.stop() } } // ❌ Bad: Configuration per test class class MyTest : FunSpec({ beforeSpec { Stove().with { /* */ }.run() // Don't do this! } }) ``` ## Test Data Management ### Use Unique Test Data Generate unique identifiers to prevent test interference: ```kotlin hl_lines="4 5 18" // ✅ Good: Unique data per test test("should create order") { val orderId = UUID.randomUUID().toString() val userId = "user-${UUID.randomUUID()}" stove { http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(id = orderId, userId = userId).some() ) { /* assertions */ } } } } // ❌ Bad: Hardcoded IDs that may conflict test("should create order") { val orderId = "order-123" // May conflict with other tests // ... } ``` ### Isolate Shared Infrastructure Resources When using provided instances (shared infrastructure in CI/CD), use **unique prefixes for all resources** to prevent parallel test runs from interfering with each other: ```kotlin object TestRunContext { val runId: String = System.getenv("CI_JOB_ID") ?: UUID.randomUUID().toString().take(8) val databaseName = "testdb_$runId" val topicPrefix = "test_${runId}_" val indexPrefix = "test_${runId}_" } // Use unique names in configuration Stove() .with { postgresql { PostgresqlOptions.provided( databaseName = TestRunContext.databaseName, // ... ) } springBoot( withParameters = listOf( "kafka.topic.orders=${TestRunContext.topicPrefix}orders", "elasticsearch.index.products=${TestRunContext.indexPrefix}products" ) ) } ``` !!! tip "Detailed Guide" See [Provided Instances - Test Isolation](Components/11-provided-instances.md#test-isolation-with-shared-infrastructure) for comprehensive examples for each system. ### Use Cleanup Functions Clean up test data to maintain isolation. The `cleanup` parameter is passed inside the options: ```kotlin Stove() .with { couchbase { CouchbaseSystemOptions( defaultBucket = "bucket", cleanup = { cluster -> // Clean test data after tests complete cluster.query("DELETE FROM `bucket` WHERE type = 'test'") }, configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } kafka { KafkaSystemOptions( cleanup = { admin -> // Delete test topics after tests complete val testTopics = admin.listTopics().names().get() .filter { it.startsWith("test-") } if (testTopics.isNotEmpty()) { admin.deleteTopics(testTopics).all().get() } }, configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptorClasses=${cfg.interceptorClass}" ) } ) } } .run() ``` ### Test Data Builders Create reusable test data builders: ```kotlin object TestData { fun createUser( id: String = UUID.randomUUID().toString(), name: String = "Test User", email: String = "test-${UUID.randomUUID()}@example.com" ) = User(id = id, name = name, email = email) fun createProduct( id: String = UUID.randomUUID().toString(), name: String = "Test Product", price: Double = 99.99 ) = Product(id = id, name = name, price = price) } // Usage in tests test("should create user") { val user = TestData.createUser(name = "John Doe") // ... } ``` ## Assertions ### Be Specific with Assertions Test specific behaviors, not just successful responses: ```kotlin // ✅ Good: Specific assertions stove { http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(id = orderId, amount = 99.99).some() ) { response -> response.status shouldBe 201 response.body().id shouldBe orderId response.body().amount shouldBe 99.99 response.body().status shouldBe "CREATED" response.body().createdAt shouldNotBe null } } } // ❌ Bad: Only checking status code stove { http { postAndExpectBodilessResponse("/orders", body = order.some()) { response -> response.status shouldBe 201 // Not enough! } } } ``` ### Verify Side Effects Test the complete flow including side effects: make the request, then verify database state, published events, search index, and cache. ```kotlin hl_lines="8 17 24 31 38" test("should process order completely") { val orderId = UUID.randomUUID().toString() stove { // 1. Make the request http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(id = orderId).some() ) { response -> response.status shouldBe 201 } } // 2. Verify database state couchbase { shouldGet("orders", orderId) { order -> order.status shouldBe "CREATED" } } // 3. Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.orderId == orderId } } // 4. Verify search index updated elasticsearch { shouldGet(index = "orders", key = orderId) { order -> order.status shouldBe "CREATED" } } // 5. Verify cache populated redis { client().connect().sync().get("order:$orderId") shouldNotBe null } } } ``` ## Performance ### Use keepDependenciesRunning for Development Speed up local development: ```kotlin Stove { keepDependenciesRunning() // Containers stay running between test runs }.with { // ... }.run() ``` !!! tip Disable `keepDependenciesRunning()` in CI/CD for clean environments. ### Configure Appropriate Timeouts Set realistic timeouts for your environment: ```kotlin // HTTP client timeout http { HttpClientSystemOptions( baseUrl = "http://localhost:8080", timeout = 30.seconds // Adjust based on your app's response times ) } // Kafka assertion timeout kafka { shouldBePublished(atLeastIn = 20.seconds) { // Allow enough time for async processing actual.orderId == orderId } } ``` ### Run Tests in Parallel (With Care) If running tests in parallel, ensure proper isolation: ```kotlin // Use unique data per test test("test 1") { val id = UUID.randomUUID().toString() // Unique per test // ... } test("test 2") { val id = UUID.randomUUID().toString() // Different ID // ... } ``` ## External Services ### Mock External Dependencies Use WireMock for external services: ```kotlin // ✅ Good: Mock external services stove { wiremock { mockPost( url = "/payments/charge", statusCode = 200, responseBody = PaymentResult(success = true, transactionId = "tx-123").some() ) } http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(amount = 99.99).some() ) { response -> response.body().paymentStatus shouldBe "PAID" } } } // ❌ Bad: Calling real external services in tests // - Tests become flaky // - Tests are slow // - May incur costs // - Can't test edge cases ``` ### Test Error Scenarios Test how your application handles failures: ```kotlin test("should handle payment failure gracefully") { stove { wiremock { mockPost( url = "/payments/charge", statusCode = 500, responseBody = ErrorResponse("Payment service unavailable").some() ) } http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(amount = 99.99).some() ) { response -> response.status shouldBe 503 response.body().status shouldBe "PAYMENT_FAILED" } } } } test("should retry on transient failures") { stove { wiremock { behaviourFor("/payments/charge", WireMock::post) { initially { aResponse().withStatus(503) } then { aResponse().withStatus(503) } then { aResponse() .withStatus(200) .withBody(it.serialize(PaymentResult(success = true))) } } } // Application should retry and eventually succeed http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest(amount = 99.99).some() ) { response -> response.status shouldBe 201 } } } } ``` ## Serialization ### Align Serializers Ensure Stove uses the same serialization as your application: ```kotlin // If your app uses custom Jackson configuration val customObjectMapper = ObjectMapper().apply { registerModule(JavaTimeModule()) disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) setSerializationInclusion(JsonInclude.Include.NON_NULL) } Stove() .with { http { HttpClientSystemOptions( baseUrl = "http://localhost:8080", contentConverter = JacksonConverter(customObjectMapper) ) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(customObjectMapper) ) { /* config */ } } wiremock { WireMockSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(customObjectMapper) ) } } .run() ``` ## Application Configuration ### Make Configuration Testable Your application should accept configuration from various sources: ```kotlin // ✅ Good: Configurable properties @Configuration class KafkaConfig( @Value("\${kafka.bootstrapServers}") private val bootstrapServers: String, @Value("\${kafka.offset:latest}") private val offset: String, @Value("\${kafka.autoCreateTopics:false}") private val autoCreate: Boolean ) { // Stove can override these via command line args } ``` ### External Service URLs Must Be Configurable When using WireMock, all external service URLs must point to WireMock's URL: ```kotlin hl_lines="4 5 16 17" // ✅ Good: External service URLs are configurable @Configuration class ExternalServicesConfig( @Value("\${payment.service.url}") val paymentUrl: String, @Value("\${inventory.service.url}") val inventoryUrl: String ) // In tests, pass WireMock URL for all external services Stove() .with { wiremock { WireMockSystemOptions(port = 9090) } springBoot( withParameters = listOf( "payment.service.url=http://localhost:9090", "inventory.service.url=http://localhost:9090" ) ) } ``` ```kotlin hl_lines="3" // ❌ Bad: Hardcoded URLs won't be intercepted by WireMock class PaymentClient { private val url = "http://payment-service.com" // WireMock can't intercept this! } ``` ```kotlin hl_lines="4" // ❌ Bad: Hardcoded values @Configuration class KafkaConfig { private val bootstrapServers = "localhost:9092" // Can't change in tests! } ``` ### Use Test Profiles Wisely Minimize differences between test and production: ```kotlin springBoot( runner = { params -> myApp.run(params) }, withParameters = listOf( "server.port=8080", "spring.profiles.active=default", // Use default profile when possible "logging.level.root=warn", // Override only what's necessary "kafka.bootstrapServers=${kafkaConfig.bootstrapServers}" ) ) ``` ## Debugging ### Enable Verbose Logging When Needed ```kotlin springBoot( runner = { params -> myApp.run(params) }, withParameters = listOf( "logging.level.root=debug", // For debugging "logging.level.org.springframework.web=trace" ) ) ``` ### Use Container Inspection Debug container issues: ```kotlin stove { mongodb { val info = inspect() println("Container ID: ${info?.containerId}") println("Network: ${info?.network}") println("IP: ${info?.ipAddress}") } } ``` ### Access Application Beans Debug by accessing application components: ```kotlin stove { using { val order = findById(orderId) println("Order state: $order") } using { orderService, paymentService -> // Debug complex scenarios } } ``` ## CI/CD Considerations ### Use Provided Instances in CI For faster CI builds, use pre-provisioned infrastructure: ```kotlin val isCI = System.getenv("CI") == "true" Stove() .with { kafka { if (isCI) { KafkaSystemOptions.provided( bootstrapServers = System.getenv("KAFKA_SERVERS"), configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } else { KafkaSystemOptions { listOf("kafka.bootstrapServers=${it.bootstrapServers}") } } } } .run() ``` ### Configure Docker Registry For corporate environments: ```kotlin // Set globally for all components DEFAULT_REGISTRY = System.getenv("DOCKER_REGISTRY") ?: "docker.io" ``` ### Handle Resource Constraints Configure for CI resource limits: ```kotlin Stove() .with { couchbase { CouchbaseSystemOptions( container = CouchbaseContainerOptions( containerFn = { container -> container.withCreateContainerCmdModifier { cmd -> cmd.hostConfig?.withMemory(512 * 1024 * 1024) // 512MB limit } } ) ) { /* config */ } } } .run() ``` ## Common Anti-Patterns ### ❌ Testing Implementation Details ```kotlin // Bad: Testing internal implementation using { save(order) } shouldGet(orderId) { /* verify */ } // Good: Test through the API http { postAndExpectBody("/orders", body = order.some()) { /* verify */ } } couchbase { shouldGet("orders", orderId) { /* verify */ } } ``` ### ❌ Sleeping Instead of Waiting ```kotlin hl_lines="4 9" // Bad: Fixed sleep http { post("/async-operation") } Thread.sleep(5000) // Fragile! kafka { shouldBeConsumed { true } } // Good: Poll with timeout kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.id == expectedId } } ``` ### ❌ Sharing State Between Tests ```kotlin hl_lines="2 5 9 14" // Bad: Shared mutable state var createdUserId: String? = null test("create user") { createdUserId = createUser() } test("get user") { getUser(createdUserId!!) // Depends on test order! } // Good: Independent tests test("create and get user") { val userId = createUser() getUser(userId) } ``` ### ❌ Overly Broad Assertions ```kotlin // Bad: Too vague response.status shouldBe 200 // Good: Specific assertions response.status shouldBe 200 response.body().id shouldBe expectedId response.body().status shouldBe "ACTIVE" response.body().createdAt shouldNotBe null ``` ## Summary | Do | Don't | |----|-------| | Use unique test data | Use hardcoded IDs | | Test through public APIs | Test implementation details | | Mock external services | Call real external services | | Use appropriate timeouts | Use fixed sleeps | | Clean up test data | Leave test artifacts | | Keep tests independent | Share state between tests | | Be specific in assertions | Use vague assertions | | Test error scenarios | Only test happy paths | ================================================ FILE: docs/blog/dashboard-0.23.0.md ================================================ # Stove Dashboard in 0.23.0: See Your E2E Runs Live End-to-end tests usually answer one question: pass or fail. When they fail, you jump between logs, traces, and broker/db tools to understand what happened. As of **Stove 0.23.0**, you can use **Stove Dashboard** and the **`stove` CLI** to watch test execution in a local dashboard while tests are running. Dashboard gives you: - a real-time timeline of test actions - distributed trace trees linked to tests - state snapshots across systems - persistent run history in SQLite Instead of treating failures as black boxes, you can inspect the full story in one place.

## Quick Setup (5 Minutes) ### 1) Install and start the CLI ```bash brew install Trendyol/trendyol-tap/stove stove ``` By default, the dashboard is at `http://localhost:4040` and gRPC receiver is at `localhost:4041`. ### 2) Add the test dependency and tracing plugin ```kotlin // build.gradle.kts plugins { id("com.trendyol.stove.tracing") version "$stoveVersion" } dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-dashboard") testImplementation("com.trendyol:stove-tracing") } stoveTracing { serviceName.set("product-api") } ``` The tracing Gradle plugin attaches the OpenTelemetry agent to your test tasks, which is required for the dashboard's trace view. ### 3) Register Dashboard in Stove config ```kotlin Stove() .with { dashboard { DashboardSystemOptions(appName = "product-api") } tracing { enableSpanReceiver() } // recommended for trace view // other systems: http, kafka, postgresql, wiremock... }.run() ``` ### 4) Run tests and open dashboard ```bash ./gradlew test ``` Open `http://localhost:4040` and inspect runs as they stream in. ## How to Use Dashboard During Debugging When a test fails (or behaves unexpectedly), this sequence is usually the fastest: 1. **Timeline:** find the first failed action and inspect its input/output and expected/actual values. 2. **Trace:** jump to the span tree to locate the failure point inside app call flow. 3. **Snapshots:** confirm system state around the failure boundary. 4. **Kafka Explorer:** verify published/consumed message counts and payloads. This gives you both sides of the picture: test-level assertions and application-level execution details. ## Daily Workflow That Works Well Use Dashboard as a local companion while iterating: 1. Start CLI once: `stove` 2. Keep it running in a separate terminal 3. Run focused tests repeatedly (class or test-level) 4. Inspect changes immediately in Timeline/Trace views 5. Use Reporting + Tracing in CI; use Dashboard primarily for local debugging speed Dashboard is fault-tolerant by design. If CLI is not running, tests continue normally and Dashboard emission auto-degrades without breaking test execution. ## Minimal End-to-End Example ```kotlin class StoveConfig : AbstractProjectConfig() { override suspend fun beforeProject() = Stove() .with { dashboard { DashboardSystemOptions(appName = "spring-example") } tracing { enableSpanReceiver() } // other systems... }.run() override suspend fun afterProject() = Stove.stop() } ``` You keep writing tests exactly as before; Dashboard captures entries/spans/snapshots automatically. ## Troubleshooting Quick Checks - **UI stuck at waiting state:** ensure `stove` is running before tests. - **No events appear:** verify `stove-dashboard` dependency and `dashboard {}` registration. - **Port mismatch:** align `DashboardSystemOptions(cliPort = ...)` with CLI `--grpc-port`. - **Too much historical data:** run `stove --clear`. ## Links - [Dashboard component docs](../Components/18-dashboard.md) - [0.23.0 release notes](../release-notes/0.23.0.md) - [Tracing component docs](../Components/15-tracing.md) - [Getting started](../getting-started.md) ================================================ FILE: docs/blog/polyglot-0.24.0.md ================================================ # Stove 0.24.0 — Going Polyglot, and an MCP for AI Triage Stove started as a JVM end-to-end testing framework. Spring Boot, Ktor, Quarkus, Micronaut — spin them up with real PostgreSQL, real Kafka, real WireMock, then assert the whole flow with one Kotlin DSL. That core hasn't changed. What 0.24.0 changes is who gets to play. This release pushes on five things at once: Go becomes a first-class application under test, any container image can be the AUT, the framework starter is no longer required (test against an already-deployed app), one system type can have many keyed instances (verify across services), and the `stove` CLI grows an MCP endpoint so AI agents can triage failed runs without scraping logs. ## Why polyglot, why now Microservice fleets are not monolingual. The order service might be Spring Boot, the inventory service Go, the recommender Python, the edge router Rust. If your e2e framework only covers the JVM, every non-JVM service either gets its own bespoke harness or doesn't get end-to-end tested at all. Both are bad outcomes. The interesting question isn't "how do we add a Go runner?" It's "what's actually language-specific about an end-to-end test?" The answer turns out to be: very little. The Stove DSL — `http {}`, `postgresql {}`, `kafka {}`, `tracing {}`, `dashboard {}` — is about the *contract*: what went over the wire, what's in the database, what spans appeared. The language of the application under test is an implementation detail. So 0.24.0 splits AUT lifecycle from test logic. Two new starters, the test surface unchanged: - **`stove-process`** runs your app as a host binary. Fast iteration, easy debugging. - **`stove-container`** runs your app as a Docker image. CI parity with the artifact you ship. Both work for any language. Both pass infrastructure config the same way (`envMapper` / `argsMapper`). Both ride the same readiness model. The Kotlin tests don't care which one is in play. ## A tour: Go on Stove Go gets the deepest treatment because it's the showcase language. The [`go-showcase`](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase) recipe is an HTTP + PostgreSQL + Kafka service. Same `StoveConfig.kt` runs the binary directly *or* runs it inside a Docker container, branched on a single system property. ### The Go side The Go application stays small. All tracing is in the infrastructure layer — `otelhttp` wraps the mux, `otelsql` wraps the DB driver. Business handlers stay clean: ```go title="handlers.go" func handleCreateProduct(db *sql.DB, producer KafkaProducer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req createProductRequest json.NewDecoder(r.Body).Decode(&req) product := Product{ID: uuid.New().String(), Name: req.Name, Price: req.Price} insertProduct(r.Context(), db, product) // otelsql traces this automatically if producer != nil { event := ProductCreatedEvent{ID: product.ID, Name: product.Name, Price: product.Price} eventBytes, _ := json.Marshal(event) producer.SendMessage("product.created", product.ID, eventBytes) } writeJSON(w, http.StatusCreated, product) } } ``` The Stove HTTP client sends a `traceparent` header. `otelhttp` extracts it. Spans created in the Go app share the originating test's trace ID. No glue code, no manual correlation. ### The Kotlin side ```kotlin test("create product, verify HTTP, DB, Kafka, traces") { stove { var productId: String? = null http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Test", price = 42.99).some() ) { actual -> actual.status shouldBe 201 productId = actual.body().id } } postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = productRowMapper ) { rows -> rows.size shouldBe 1 } } kafka { shouldBePublished(10.seconds) { actual.name == "Test" } } tracing { shouldContainSpan("http.request") shouldNotHaveFailedSpans() } } } ``` If you removed the file path, you couldn't tell from this test that the AUT is in Go. That's the point. ### Kafka, in three flavors `shouldBePublished` and `shouldBeConsumed` need an observation point on the broker side. For JVM apps, Stove uses Kafka client interceptors. For Go, 0.24.0 ships [`stove-kafka`](https://github.com/trendyol/stove/tree/main/go/stove-kafka), a small Go library that forwards produced/consumed/committed messages over gRPC to Stove's observer. The bridge is library-agnostic at its core. First-party integrations exist for the three Go Kafka clients people actually use: - [IBM/sarama](https://github.com/IBM/sarama) via `ProducerInterceptor` / `ConsumerInterceptor` - [twmb/franz-go](https://github.com/twmb/franz-go) via `kgo.WithHooks(...)` - [segmentio/kafka-go](https://github.com/segmentio/kafka-go) via tiny `ReportWritten` / `ReportRead` helpers Want confluent-kafka-go or something else? Skip the subpackages, use the core API: ```go bridge.ReportPublished(ctx, &stovekafka.PublishedMessage{...}) bridge.ReportConsumed(ctx, &stovekafka.ConsumedMessage{...}) bridge.ReportCommitted(ctx, topic, partition, offset+1) ``` In production, `STOVE_KAFKA_BRIDGE_PORT` is unset, `NewBridgeFromEnv()` returns nil, and every method becomes a no-op. **Zero overhead in prod, full assertion fidelity in tests.** ### Coverage from black-box tests A nice side-effect of standardizing on `stove-process` and `stove-container`: Go 1.20+ integration coverage just works. Build with `go build -cover`, set `GOCOVERDIR`, and Go writes coverage data on graceful shutdown. Stove already sends SIGTERM and waits for clean exit — exactly the lifecycle Go's coverage tooling expects. ```bash ./gradlew e2eTestWithCoverage -Pgo.coverage=true ./gradlew e2eTest-containerWithCoverage -Pgo.coverage=true ``` Per-function summary, HTML report. One catch worth a paragraph: when Go runs under Java's `ProcessBuilder`, the stdout pipe can close before the process exits. Log writes to that closed pipe trigger SIGPIPE — Go dies before flushing coverage. The fix is one line in `main()`: ```go signal.Ignore(syscall.SIGPIPE) ``` That's it. No framework changes were needed for coverage; it's a Gradle concern, an env var, and an existing graceful-shutdown signal. ## Container mode is not just for Go `stove-container` is **language-agnostic**. Anything that ships in an image works — Go, Python, Node.js, Rust, .NET, even your existing JVM artifact when you want to test the actual deployed binary instead of the in-process bean graph. One thing worth being explicit about: building the image is not Stove's job. `containerApp(...)` only needs an image reference. Where it comes from is your call: - A tag your CI just produced (`-Papp.image=ghcr.io/acme/app:sha-abc`) - A pull from a registry, lazy on first use - An optional Gradle `Exec` task that runs `docker build` for local iteration Most teams already have a perfectly good image-build pipeline. Stove doesn't try to own it. ```kotlin containerApp( image = System.getProperty("app.container.image"), target = ContainerTarget.Server( hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false ), envProvider = envMapper { "database.host" to "DB_HOST" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") }, configureContainer = { withNetworkMode("host") } ) ``` `configureContainer { ... }` exposes the underlying Testcontainers `GenericContainer`, so anything Testcontainers can do — bind mounts, network mode, log consumers, capabilities — is available without bespoke API surface. A common pattern: `e2eTest` runs process mode for daily local development; `e2eTest-container` runs container mode in CI against the image the build job just published. Same StoveConfig, same tests, branched on a system property. ## Black-box mode: testing apps Stove didn't start Polyglot AUT is one half of "Stove doesn't have to own the app." The other half is `providedApplication()` — telling Stove the application is already running somewhere, and you just want to run your tests against it. ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", host = "staging-db", port = 5432, configureExposedConfiguration = { emptyList() } ) } providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet(url = "https://staging.myapp.com/health") ) } }.run() ``` Same Stove DSL. Same assertions. No `springBoot()` / `ktor()` / `goApp()` block. Stove waits for the deployed health check, then runs your tests against the live URL — and verifies side effects in the actual database / Kafka / Redis the deployed app uses (via `*.provided(...)` factories on each system). The use case is post-deployment smoke testing: the e2e tests you already wrote can double as a CI/CD gate that hits staging immediately after a release. Same code, same intent, different infrastructure. ## Multiple instances of the same system, with keys Microservice integration tests usually need to talk to more than one downstream service, or verify state in more than one database. 0.24.0 adds **keyed system registration** for that: ```kotlin object OrderService : SystemKey object PaymentService : SystemKey object AppDb : SystemKey object AnalyticsDb : SystemKey Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://myapp.com") } // default httpClient(OrderService) { HttpClientSystemOptions(baseUrl = "https://order.internal") } httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "https://pay.internal") } postgresql(AppDb) { /* ... */ } postgresql(AnalyticsDb) { /* ... */ } }.run() ``` In tests, the same key drives the validation DSL: ```kotlin http(OrderService) { getResponse("/api/orders/$orderId") { /* ... */ } } postgresql(AnalyticsDb) { shouldQuery(/* ... */) { /* ... */ } } ``` Keys are Kotlin `object`s — compile-time-safe, IDE-autocompleted, refactor-safe. Default and keyed instances of the same type coexist independently. Reports and traces label keyed calls (`HTTP [OrderService] > GET /api/orders/123`) so it's clear which service did what. This pairs naturally with `providedApplication()`. A single Stove config can wire your app's API, three downstream services, two shared databases, and a Kafka cluster — all already running in staging — and a single Kotlin test asserts behaviour across all of them. ## MCP — failure triage for AI agents The other big addition in 0.24.0 has nothing to do with non-JVM apps and everything to do with how people debug failed e2e runs in 2026. If you're using an AI agent in your editor or CI bot, you've probably watched it try to triage a failed test by reading the entire stdout, then the entire stderr, then `tail`-ing logs, then guessing at trace IDs. It works, but it burns tokens proportional to log size, and it hallucinates when names are ambiguous. The Stove dashboard already records every run — timeline, traces, snapshots, Kafka message counts — in a local SQLite database. 0.24.0 adds a [Model Context Protocol](https://modelcontextprotocol.io/) endpoint on the same `stove` CLI that exposes that data as structured tools: ```text $ stove Stove CLI v0.24.0 running UI: http://localhost:4040 REST: http://localhost:4040/api/v1 MCP: http://localhost:4040/mcp gRPC: localhost:4041 ``` Wire any MCP-capable agent at `http://localhost:4040/mcp`. Then the conversation looks like: ```text Agent: stove_failures() → 2 failed runs across go-showcase and order-service Agent: stove_failure_detail(run_id="...", test_id="...") → compact failure packet: assertion, expected vs actual, timeline of last 5 actions, exception class Agent: stove_trace(run_id="...", test_id="...") → critical path: 4 spans, exception in PostgresOrderRepository.save ``` Eight tools total, all read-only, all local-only. Defaults are token-aware: payloads are truncated deterministically with omitted-counts, sensitive keys (`authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, `credential`) are redacted before return, and a `budget: tiny|compact|full` knob lets the agent dial detail when needed. Two design decisions worth calling out: 1. **`run_id + test_id` is the only authoritative test selector.** Apps and runs can contain duplicate test names; an agent inferring "OrderTest::should create order" from a phrase will eventually hit the wrong run. Every tool result includes the next call's exact arguments — agents follow links, they don't construct queries. 2. **Loopback only.** The `/mcp` endpoint accepts only localhost `Host`/`Origin` headers and rejects anything else. This blocks DNS rebinding from a malicious page in your browser. Safe to leave running on a dev machine; not exposed externally. If MCP is unavailable, agents fall back to normal test output and logs — it's an optimization, not a dependency. ## Putting it together Stove 0.24.0 is one consistent picture, even though the changes touch four different surfaces: - A test that drives a Go service through HTTP, asserts on PostgreSQL state, validates Kafka messages, and traces the call chain — using the exact same DSL that drives the Spring Boot service next door. - The same test running against a host binary in your IDE for fast feedback, then against a real Docker image in CI for production parity, with one `-Daut.mode` flip. - When something fails, the dashboard shows you what happened. When the agent in your editor wants to help, it asks the dashboard via MCP instead of inhaling logs. Three integrations, one feedback loop. That's the release. --- ## Getting started Upgrade the CLI: ```bash brew upgrade stove ``` Add the modules you need to your test classpath: ```kotlin testImplementation(platform("com.trendyol:stove-bom:0.24.0")) testImplementation("com.trendyol:stove-process") // host binary testImplementation("com.trendyol:stove-container") // Docker image testImplementation("com.trendyol:stove-dashboard") // dashboard streaming testImplementation("com.trendyol:stove-tracing") // distributed tracing testImplementation("com.trendyol:stove-kafka") // Kafka assertions ``` For Go Kafka assertions: ```bash go get github.com/trendyol/stove/go/stove-kafka ``` ## Links - [Full 0.24.0 release notes](../release-notes/0.24.0.md) - [Other Languages & Stacks overview](../other-languages/index.md) - [Go Process Mode](../other-languages/go-process.md) - [Go Container Mode](../other-languages/go-container.md) - [MCP component docs](../Components/21-mcp.md) - [Dashboard component docs](../Components/18-dashboard.md) - [`go-showcase` recipe](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase) — process *and* container modes in one repo - [`stove-kafka` Go bridge](https://github.com/Trendyol/stove/tree/main/go/stove-kafka) ================================================ FILE: docs/blog/tracing-0.21.0.md ================================================ # Execution Tracing in Stove 0.21.0 If you've spent any time debugging e2e test failures, you know the routine. The test says "expected 201 but was 500" and you're left reverse-engineering what actually happened. Did the request reach the controller? Did the database reject the write? Did a downstream service return something unexpected? You open the logs, grep for request IDs, cross-reference timestamps, and eventually piece together the story. Twenty minutes later, you have an answer. The fundamental problem is that e2e tests treat the application as a black box. They can tell you the output was wrong, but they have no visibility into the execution path that produced it. For simple flows that's fine. For a request that touches a gRPC service, two REST APIs, a database, and a Kafka topic before returning a response, it's a real productivity drain. In a microservice architecture with multiple integration points, this kind of failure can easily take 30 minutes to diagnose. Multiply that by every flaky test in your CI pipeline, and the cost adds up fast. Stove 0.21.0 introduces execution tracing to address this. When a test fails, you get the entire call chain of your application: every controller method, every database query, every Kafka message, every HTTP call, with timing and the exact point of failure. The bug might be buried deep in the persistence layer, but the trace pinpoints it without a single grep. ## Stove in 30 Seconds For those new to [Stove](https://github.com/Trendyol/stove): it's an end-to-end testing framework for the JVM. It spins up your **real application** with **real dependencies** (PostgreSQL, Kafka, MongoDB, Redis, etc. via Testcontainers) and gives you a unified Kotlin DSL for assertions across all of them. It works with Spring Boot, Ktor, Micronaut, and Quarkus. Tests can be written in Kotlin, Java, or Scala. The key idea: test your entire application stack as it runs in production, not a stripped-down mock version. ## A Real Application: The Spring Showcase To demonstrate tracing, let's walk through a realistic application. The [spring-showcase](https://github.com/Trendyol/stove/tree/main/recipes/jvm/kotlin-recipes/spring-showcase) recipe is an order service that touches six different integration points during a single request: ```mermaid flowchart LR A["HTTP POST /api/orders"] --> B[OrderService] B --> C["Fraud Detection (gRPC)"] B --> D["Inventory Check (REST)"] B --> E["Payment (REST)"] B --> F["PostgreSQL - Save Order"] B --> G["Kafka - Publish Events"] B --> H["db-scheduler - Schedule Email"] ``` Here's the service code. Each method is annotated with `@WithSpan` so the OpenTelemetry agent captures it: ```kotlin hl_lines="11" @Service class OrderService( private val orderRepository: OrderRepository, private val inventoryClient: InventoryClient, private val paymentClient: PaymentClient, private val fraudDetectionClient: FraudDetectionClient, private val eventPublisher: OrderEventPublisher, private val emailSchedulerService: EmailSchedulerService ) { @WithSpan("OrderService.createOrder") suspend fun createOrder(userId: String, productId: String, amount: Double): Order { // Step 1: Check fraud via gRPC checkFraudViaGrpc(orderId, userId, amount, productId) // Step 2: Check inventory via REST checkInventoryViaRest(productId) // Step 3: Process payment via REST val payment = processPaymentViaRest(userId, amount) // Step 4: Save to database val savedOrder = saveOrderToDatabase(orderId, userId, productId, amount, payment.transactionId!!) // Step 5: Publish events to Kafka publishEventsToKafka(savedOrder, payment.transactionId) // Step 6: Schedule confirmation email scheduleConfirmationEmail(savedOrder) return savedOrder } } ``` And here's how the Stove test covers the entire flow in a single test: ```kotlin hl_lines="2 4 5 16 25 26 34 46 56 64 72" test("The Complete Order Flow - Every Feature in One Test") { stove { // 1. Mock the external gRPC service (Fraud Detection) grpcMock { mockUnary( serviceName = "frauddetection.FraudDetectionService", methodName = "CheckFraud", response = CheckFraudResponse.newBuilder() .setIsFraudulent(false) .setRiskScore(0.15) .build() ) } // 2. Mock the external REST APIs (Inventory + Payment) wiremock { mockGet(url = "/inventory/$productId", statusCode = 200, responseBody = InventoryResponse(productId, available = true, quantity = 10).some()) mockPost(url = "/payments/charge", statusCode = 200, responseBody = PaymentResult(success = true, transactionId = "txn-123", amount = amount).some()) } // 3. Call our API http { postAndExpectBody(uri = "/api/orders", body = CreateOrderRequest(userId, productId, amount).some() ) { response -> response.status shouldBe 201 response.body().status shouldBe "CONFIRMED" } } // 4. Verify database state postgresql { shouldQuery( query = "SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row -> OrderRow(/* ... */) } ) { orders -> orders.size shouldBe 1 orders.first().status shouldBe "CONFIRMED" } } // 5. Verify Kafka events kafka { shouldBePublished { actual.userId == userId && actual.productId == productId } shouldBePublished { actual.amount == amount && actual.success } } // 6. Verify the consumer updated the read model (CQRS) kafka { shouldBeConsumed { actual.userId == userId } } // 7. Test our gRPC server grpc { channel { val order = getOrder(GetOrderRequest.newBuilder().setOrderId(orderId!!).build()) order.found shouldBe true } } // 8. Verify scheduled tasks tasks { shouldBeExecuted { this.orderId == orderId && this.userId == userId } } } } ``` One test covering eight integration points against real infrastructure. ## Setting Up Tracing Tracing takes two configuration steps. ### Step 1: Enable in your Stove config ```kotlin hl_lines="3-4" Stove() .with { tracing { enableSpanReceiver() } // ... your other systems (http, kafka, postgresql, etc.) } .run() ``` ### Step 2: Attach the OpenTelemetry agent in your build Copy [`StoveTracingConfiguration.kt`](https://github.com/Trendyol/stove/blob/main/buildSrc/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingConfiguration.kt) to your project's `buildSrc/src/main/kotlin/` directory, then add to your `build.gradle.kts`: ```kotlin hl_lines="3-5" import com.trendyol.stove.gradle.stoveTracing stoveTracing { serviceName = "my-service" testTaskNames = listOf("e2eTest") // optional: scope to specific test tasks } ``` This handles downloading the OpenTelemetry Java Agent, configuring JVM arguments, attaching the agent to your test tasks, and dynamically assigning ports so parallel test runs don't conflict. !!! tip "Gradle Plugin available since 0.21.2" Starting with [0.21.2](../release-notes/0.21.2.md), a standalone Gradle plugin is available that eliminates the need to copy this file. See the [0.21.2 release notes](../release-notes/0.21.2.md) for details. No code changes to your application are needed. The OpenTelemetry agent instruments 100+ libraries (Spring, JDBC, Kafka, gRPC, HTTP clients, Redis, MongoDB, and more) automatically. The `@WithSpan` annotations are optional. They add your own method-level spans on top of what the agent already captures. ## What Happens When a Test Fails To see this in practice, we ran the spring-showcase with a bug deliberately injected in the persistence layer: a validation that rejects orders over $1000. The test output included the full execution report:
╔═════════════════════════════════════════════════════════════════════════════
                        STOVE TEST EXECUTION REPORT

 Test: The Complete Order Flow - Every Feature in One Test
 ID:   TheShowcase::The Complete Order Flow - Every Feature in One Test
 Status: FAILED
╠═════════════════════════════════════════════════════════════════════════════

 TIMELINE
 ────────

 17:27:22.298 ✓ PASSED [gRPC Mock] Register unary stub: FraudDetectionService/CheckFraud
     Output: risk_score: 0.15 reason: "low_risk_user"

 17:27:22.335 ✓ PASSED [WireMock] Register stub: GET /inventory/macbook-pro-16
     Metadata: {statusCode=200}

 17:27:22.341 ✓ PASSED [WireMock] Register stub: POST /payments/charge
     Metadata: {statusCode=200}

 17:27:25.092 ✗ FAILED [HTTP] POST /api/orders
     Input:  CreateOrderRequest(userId=user-4b9bb522, productId=macbook-pro-16, amount=2499.99)
     Output: {"message":"Internal server error","errorCode":"INTERNAL_ERROR"}
     Metadata: {status=500}
     Expected: Response<OrderResponse> matching expectation
     Error: expected:<201> but was:<500>

╠═════════════════════════════════════════════════════════════════════════════

 SYSTEM SNAPSHOTS
 ────────────────

 ┌─ GRPC MOCK ────────────────────────────
   Registered stubs: 1
   Received requests: 1
   Matched requests: 1

 ┌─ WIREMOCK ─────────────────────────────
   Registered stubs (this test): 2
   Served requests (this test): 2 (matched: 2)

 ┌─ KAFKA ────────────────────────────────
   Consumed: 0
   Published: 0
   Failed: 0

╚═════════════════════════════════════════════════════════════════════════════
The report is structured in two parts. First, a timeline of every test step showing what passed and what failed. Then, a snapshot of each system's state at the moment of failure. You can already read the situation: the gRPC mock matched its request, WireMock served both stubs successfully, but Kafka has zero messages. The application crashed before it could publish any events. Below the report, the **execution trace** shows what happened inside the application:
═══════════════════════════════════════════════════════════════
EXECUTION TRACE (Call Chain)
═══════════════════════════════════════════════════════════════

✓ POST /api/orders [250ms]
├── ✓ OrderService.createOrder [245ms]
│   ├── ✓ OrderService.checkFraudViaGrpc [30ms]
│   │   └── ✓ FraudDetectionClient.checkFraud [25ms]
│   ├── ✓ OrderService.checkInventoryViaRest [40ms]
│   │   └── http.url: http://localhost:54648/inventory/macbook-pro-16
│   ├── ✓ OrderService.processPaymentViaRest [35ms]
│   │   └── http.url: http://localhost:54648/payments/charge
│   ├── ✗ OrderService.saveOrderToDatabase [8ms]  ◄── FAILURE POINT
│   │   └── ✗ PostgresOrderRepository.save [5ms]
│   │       │  Error: OrderPersistenceException
│   │       │  Message: Failed to persist order: amount exceeds internal threshold
│   │       │    at PostgresOrderRepository.validateOrderAmount(PostgresOrderRepository.kt:102)
│   │       └── db.system: postgresql
The fraud, inventory, and payment steps all passed. The failure happened in `OrderService.saveOrderToDatabase`, specifically in `PostgresOrderRepository.save`, with the exception type, message, and stack trace right there. Without tracing, this would have been a 500 error with no context. With tracing, the root cause is immediately visible. ## Automatic Trace Propagation Stove injects trace headers into every outgoing interaction without any test code changes: - **HTTP requests** get a `traceparent` header - **Kafka messages** get trace headers - **gRPC calls** get trace metadata This is visible in the actual test output. The HTTP request sent by Stove: ``` REQUEST: http://localhost:8024/api/orders METHOD: POST HEADERS: Accept: application/json X-Stove-Test-Id: TheShowcase::The Complete Order Flow - Every Feature in One Test traceparent: 00-475e686523af0b4ee0433f91a69a6b55-81edd5ba7e4dec42-01 ``` And the WireMock request log confirming the propagation reached the downstream call: ``` Request received: 127.0.0.1 - GET /inventory/macbook-pro-16 traceparent: [00-475e686523af0b4ee0433f91a69a6b55-e3f138ac02509a0b-01] ``` Same trace ID (`475e686523af0b4ee0433f91a69a6b55`), different span ID. The entire call chain is correlated. ## Per-Test Trace Isolation A critical detail: every test gets its own trace. Stove generates a unique trace ID at the start of each test and injects it into every outgoing interaction. All spans collected during that test are correlated back to that trace ID and that test alone. This means traces from concurrent or sequential tests never bleed into each other. When a test fails, the execution trace shows *only* what happened during that specific test, not spans from a previous test that happened to use the same Kafka topic or a background job triggered by an earlier request. This is not something you get for free with OpenTelemetry. In production, a trace starts when a request enters the system. In testing, there's no natural entry point. Stove creates one. It manages the W3C trace context lifecycle (start, propagate, end) per test, ties it to the test identity (`X-Stove-Test-Id` header), and ensures the OTLP receiver maps incoming spans to the correct test. The result is that tracing in Stove is deterministic and test-scoped, not a sampling-based best-effort like production tracing. ## Trace Validation DSL Beyond automatic failure reports, you can actively assert on the execution flow using the `tracing { }` DSL. This is useful when you want to verify *how* your application handled a request, not just *that* it produced the right output: ```kotlin hl_lines="11 12 13 14 17 20 23" test("order processing should call all expected services") { stove { http { postAndExpectBody("/api/orders", request.some()) { response -> response.status shouldBe 201 } } tracing { // Verify which operations happened shouldContainSpan("OrderService.createOrder") shouldContainSpan("OrderService.checkFraudViaGrpc") shouldContainSpan("OrderService.checkInventoryViaRest") shouldContainSpan("PostgresOrderRepository.save") // Verify no operations failed shouldNotHaveFailedSpans() // Performance assertions executionTimeShouldBeLessThan(500.milliseconds) // Attribute assertions shouldHaveSpanWithAttribute("db.system", "postgresql") // Debugging helpers println(renderTree()) // Print the hierarchical tree println(renderSummary()) // Print compact summary } } } ``` The DSL supports: - **Span assertions**: `shouldContainSpan()`, `shouldNotContainSpan()`, `shouldContainSpanMatching()` - **Failure assertions**: `shouldNotHaveFailedSpans()`, `shouldHaveFailedSpan()` - **Performance assertions**: `executionTimeShouldBeLessThan()`, `spanCountShouldBeAtLeast()` - **Attribute assertions**: `shouldHaveSpanWithAttribute()`, `shouldHaveSpanWithAttributeContaining()` - **Query methods**: `findSpanByName()`, `getFailedSpans()`, `getTotalDuration()` - **Async support**: `waitForSpans(expectedCount, timeoutMs)` for async flows ## How It Works ```mermaid sequenceDiagram participant Test as Stove Test participant App as Application participant OTel as OTel Agent participant Receiver as OTLP Receiver participant Report as Report Builder Test->>App: HTTP POST with traceparent OTel->>OTel: Instrument libraries App->>App: Process request OTel->>Receiver: Export spans via OTLP gRPC Receiver->>Receiver: Correlate spans by trace ID alt Test passes Test->>Test: Traces available via DSL else Test fails Report->>Receiver: Query spans for this test Report->>Report: Build report + trace tree Report->>Test: Display combined report end ``` The architecture: 1. **OpenTelemetry Java Agent** attaches to your application process (configured via Gradle) and instruments 100+ libraries without code changes 2. **Stove starts an OTLP gRPC receiver** on a dynamically assigned port that collects spans exported by the agent 3. **W3C `traceparent` headers** are injected into every HTTP, Kafka, and gRPC interaction, correlating all spans back to the originating test 4. **On test failure**, the report builder queries the collected spans, builds a hierarchical tree, and renders it alongside the execution report 5. **Ports are dynamically assigned** so parallel test runs on CI don't conflict Worth noting: the OTel agent does add some startup overhead to the test JVM (a few seconds). For most e2e test suites that spin up Testcontainers, this is negligible relative to container startup time. If it matters, tracing can be toggled off with `enabled = false` in the Gradle config. ## Practical Advice 1. **Enable tracing by default.** The overhead is minimal compared to container startup, and the diagnostic value on failure is significant. 2. **Use `tracing { }` sparingly.** The automatic failure reports cover most debugging needs. Reserve the DSL for cases where you want to assert on the execution flow itself, for example verifying that a cache was hit instead of the database. 3. **Start with `shouldNotHaveFailedSpans()`.** The simplest assertion that catches unexpected errors anywhere in the call chain. 4. **Filter noisy instrumentations.** Some libraries generate a lot of spans. Tune with `disabledInstrumentations`: ```kotlin hl_lines="3-4" stoveTracing { serviceName = "my-service" disabledInstrumentations = listOf("jdbc", "hibernate", "spring-scheduling") } ``` ## Getting Started Add the dependencies: ```kotlin hl_lines="6 8 9" dependencies { testImplementation(platform("com.trendyol:stove-bom:0.21.0")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") // or stove-ktor, stove-micronaut testImplementation("com.trendyol:stove-tracing") testImplementation("com.trendyol:stove-extensions-kotest") // or stove-extensions-junit // Add components as needed: stove-postgres, stove-kafka, stove-http, etc. } ``` Enable tracing in two steps: ```kotlin hl_lines="3 6" // build.gradle.kts import com.trendyol.stove.gradle.stoveTracing stoveTracing { serviceName = "my-service" } // Stove config tracing { enableSpanReceiver() } ``` For a complete working example, see the [spring-showcase recipe](https://github.com/Trendyol/stove/tree/main/recipes/jvm/kotlin-recipes/spring-showcase). It demonstrates all Stove features together (HTTP, gRPC, Kafka, PostgreSQL, WireMock, db-scheduler, and tracing) in a realistic Spring Boot application. --- **Links:** - [Stove on GitHub](https://github.com/Trendyol/stove) - [Tracing documentation](../Components/15-tracing.md) - [Spring Showcase recipe](https://github.com/Trendyol/stove/tree/main/recipes/jvm/kotlin-recipes/spring-showcase) - [Full 0.21.0 release notes](../release-notes/0.21.0.md) - [Getting started guide](../getting-started.md) ================================================ FILE: docs/css/custom.css ================================================ /* Wider content area */ .md-grid { max-width: 1400px; margin-top: 0; } /* Responsive images */ img { width: 100%; } /* Social link styling */ .md-social__link { width: max-content; } /* Code block refinements */ .highlight code { font-size: 0.68rem; line-height: 1.45; } /* Slightly tighter line height in code for density */ code { font-size: 0.78em; } /* Admonition title font weight */ .md-typeset .admonition-title { font-weight: 600; } /* Code block hl_lines: override default background for RoughNotation */ .highlight pre .hll { background-color: transparent !important; } .highlight pre { position: relative; } /* Clip RoughNotation SVGs that extend beyond code block edges */ .highlight { overflow: hidden; } /* Contain annotation SVGs within the code block */ .highlight pre > svg, .highlight code > svg { pointer-events: none; } /* Stove annotated report blocks (RoughNotation) */ .stove-report pre { background: var(--md-code-bg-color); color: var(--md-code-fg-color); font-size: .68rem; line-height: 1.45; padding: 1em 1.2em; border-radius: .1rem; position: relative; overflow-x: auto; } .stove-report pre code { font-family: var(--md-code-font-family, "Roboto Mono", monospace); background: transparent; padding: 0; font-size: inherit; color: inherit; } ================================================ FILE: docs/frameworks/index.md ================================================ # Supported Frameworks Stove keeps the testing model consistent across frameworks, but application startup is framework-specific. Pick the starter that matches your runtime, then keep the rest of the test DSL the same. ## Pick Your Starter
- :material-sprout: **Spring Boot** For applications built on Spring Boot. Supports `bridge()` for direct bean access. [Open the Spring Boot guide](spring-boot.md) - :material-lightning-bolt: **Ktor** For applications built on Ktor. Supports `bridge()` for direct bean access. [Open the Ktor guide](ktor.md) - :material-hexagon-outline: **Micronaut** For applications built on Micronaut. Supports `bridge()` for direct bean access. [Open the Micronaut guide](micronaut.md) - :material-fire: **Quarkus** For applications built on Quarkus. `bridge()` is not available yet. [Open the Quarkus guide](quarkus.md)
## How to Choose Pick the starter that matches your application's framework — that's it. The test DSL, components, and assertions work the same way regardless of which starter you use. - **`bridge()` support**: available in Spring Boot, Ktor, and Micronaut starters. Not yet available in Quarkus. ## At A Glance | Framework | Starter | Entrypoint style | Bridge | Example | |-----------|---------|------------------|--------|---------| | Spring Boot | `stove-spring` | `runApplication(...)` wrapped in `run(args)` | Yes | [spring-example](https://github.com/Trendyol/stove/tree/main/examples/spring-example) | | Ktor | `stove-ktor` | `embeddedServer(...)` wrapped in `run(args)` | Yes | [ktor-example](https://github.com/Trendyol/stove/tree/main/examples/ktor-example) | | Micronaut | `stove-micronaut` | `ApplicationContext` startup wrapped in `run(args)` | Yes | [micronaut-example](https://github.com/Trendyol/stove/tree/main/examples/micronaut-example) | | Quarkus | `stove-quarkus` | `@QuarkusMain` entrypoint plus `Quarkus.run(*args)` | Not yet | [quarkus-example](https://github.com/Trendyol/stove/tree/main/examples/quarkus-example) | ## What Stays The Same No matter which starter you pick: - Stove starts your physical dependencies first - component configuration still comes from the same `Stove().with { ... }` DSL - reporting and tracing still integrate the same way - you can mix Kafka, PostgreSQL, WireMock, HTTP, gRPC, Redis, and other components If you are new to Stove, start with [Getting Started](../getting-started.md) first, then come back here to pick the framework-specific setup. ================================================ FILE: docs/frameworks/ktor.md ================================================ # Ktor `stove-ktor` is the starter for applications built on Ktor. Stove starts the real Ktor application and keeps the test setup in one place. ## Dependency ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-ktor") } ``` ## Application Entrypoint Expose a reusable `run` function and return the started `Application`. The exact shape depends on your DI framework: === "Koin" ```kotlin fun main(args: Array) { run(args, shouldWait = true) } fun run( args: Array, shouldWait: Boolean = false, testModules: List = emptyList() ): Application { val config = loadConfiguration(args) val applicationEngine = embeddedServer(Netty, port = config.port, host = "localhost") { install(Koin) { modules(appModule, *testModules.toTypedArray()) } configureRouting() } applicationEngine.start(wait = shouldWait) return applicationEngine.application } ``` === "Ktor-DI" ```kotlin fun main(args: Array) { run(args, shouldWait = true) } fun run( args: Array, shouldWait: Boolean = false, testDependencies: (DependencyRegistrar.() -> Unit)? = null ): Application { val config = loadConfiguration(args) val applicationEngine = embeddedServer(Netty, port = config.port, host = "localhost") { install(DI) { dependencies { provide { MyServiceImpl() } testDependencies?.invoke(this) } } configureRouting() } applicationEngine.start(wait = shouldWait) return applicationEngine.application } ``` ## Minimal Stove Setup ```kotlin Stove() .with { ktor( runner = { params -> run(params, shouldWait = false) }, withParameters = listOf("port=8080") ) } .run() ``` ## What You Get - real Ktor startup from your own server bootstrap - `bridge()` support with automatic DI detection - easy composition with Kafka, databases, WireMock, tracing, and HTTP assertions ## Bridge and DI Support Ktor Bridge automatically detects which DI framework your application uses at runtime: | DI Framework | Detection | Priority | |-------------|-----------|----------| | **Ktor-DI** | `dependencies { ... }` is active in the application | Preferred when both are present | | **Koin** | `install(Koin) { ... }` is active | Used when Ktor-DI is not active | | **Custom** | Manual resolver provided via `bridge { app, type -> ... }` | Explicit override | ### Registering Test Dependencies === "Koin" Pass test modules that override production beans: ```kotlin Stove() .with { bridge() ktor( runner = { params -> run( params, shouldWait = false, testModules = listOf( module { single(override = true) { FixedTimeProvider() } } ) ) } ) } .run() ``` === "Ktor-DI" Pass a lambda that registers test overrides (later `provide` calls override earlier ones): ```kotlin Stove() .with { bridge() ktor( runner = { params -> run(params, shouldWait = false) { provide { FixedTimeProvider() } } } ) } .run() ``` === "Custom Resolver" For other DI frameworks (Kodein, Dagger, etc.), provide a custom resolver: ```kotlin Stove() .with { bridge { application, type -> myDiContainer.resolve(type) } ktor(runner = { params -> run(params, shouldWait = false) }) } .run() ``` ### Using Bridge in Tests ```kotlin stove { using { val user = findById(123) user.name shouldBe "John" } using> { forEach { service -> service.validate() } } } ``` See the [Bridge documentation](../Components/10-bridge.md) for complete usage patterns including multi-bean access, value capture, and generic type resolution. ## Example - [ktor-example](https://github.com/Trendyol/stove/tree/main/examples/ktor-example) ================================================ FILE: docs/frameworks/micronaut.md ================================================ # Micronaut `stove-micronaut` is the starter for applications built on Micronaut. It uses the same Stove DSL as the other starters. ## Dependency ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-micronaut") } ``` ## Application Entrypoint Expose a reusable `run` function that returns the started `ApplicationContext`: ```kotlin fun main(args: Array) { run(args) } fun run( args: Array, init: ApplicationContext.() -> Unit = {} ): ApplicationContext { val context = ApplicationContext .builder() .args(*args) .build() .also(init) .start() context.findBean(EmbeddedApplication::class.java).ifPresent { app -> if (!app.isRunning) { app.start() } } return context } ``` ## Minimal Stove Setup ```kotlin Stove() .with { micronaut( runner = { params -> run(params) }, withParameters = listOf("micronaut.server.port=8080") ) } .run() ``` ## What You Get - Micronaut startup through the real app context - `bridge()` support - clean integration with PostgreSQL, WireMock, Kafka, HTTP, and tracing ## Example - [micronaut-example](https://github.com/Trendyol/stove/tree/main/examples/micronaut-example) ================================================ FILE: docs/frameworks/quarkus.md ================================================ # Quarkus `stove-quarkus` lets Stove start a Quarkus application in the same JVM as your test run, so reporting and `stove-tracing` continue to work with the normal Quarkus `main` entrypoint. !!! warning "Bridge support" `bridge()` is not available in `stove-quarkus` yet. ## Dependency ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-quarkus") testImplementation("com.trendyol:stove-extensions-kotest") testImplementation("com.trendyol:stove-http") testImplementation("com.trendyol:stove-postgres") testImplementation("com.trendyol:stove-kafka") testImplementation("com.trendyol:stove-wiremock") testImplementation("com.trendyol:stove-tracing") } ``` ## Application Entrypoint Keep a normal Quarkus entrypoint and let Stove call it from tests: ```kotlin @QuarkusMain object QuarkusMainApp { @JvmStatic fun main(args: Array) { Quarkus.run(*args) } } ``` If your application does not expose an HTTP endpoint, publish an explicit startup signal so Stove can detect readiness: ```kotlin @ApplicationScoped class StoveStartupSignal { fun onStart(@Observes event: StartupEvent) { System.setProperty("stove.quarkus.ready", "true") } fun onStop(@Observes event: ShutdownEvent) { System.clearProperty("stove.quarkus.ready") } } ``` ## Minimal Stove Setup ```kotlin Stove() .with { tracing { enableSpanReceiver() } quarkus( runner = { params -> QuarkusMainApp.main(params) }, withParameters = listOf("quarkus.http.port=8080") ) } .run() ``` ## Kafka Note If you use `stove-kafka` with Quarkus Kafka clients, add this to `application.properties`: ```properties quarkus.class-loading.parent-first-artifacts=org.apache.kafka:kafka-clients ``` This keeps the Kafka client classes shared so Stove's Kafka interceptor bridge can attach correctly. ## Example - [quarkus-example](https://github.com/Trendyol/stove/tree/main/examples/quarkus-example) ================================================ FILE: docs/frameworks/spring-boot.md ================================================ # Spring Boot `stove-spring` is the starter for applications built on Spring Boot. It supports `bridge()` for direct bean access in tests. ## Dependency ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") } ``` ## Application Entrypoint Expose a reusable `run(args, init)` function so Stove can call the real app entrypoint: ```kotlin @SpringBootApplication class ExampleApp fun main(args: Array) { run(args) } fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext = runApplication(*args, init = init) ``` ## Minimal Stove Setup ```kotlin Stove() .with { springBoot( runner = { params -> run(params) }, withParameters = listOf("server.port=8080") ) } .run() ``` ## What You Get - Spring Boot startup through the real application entrypoint - `bridge()` support for bean access - full access to Stove components such as PostgreSQL, Kafka, WireMock, HTTP, and tracing ## Spring Boot 4.x If your application uses Spring Boot 4.x, use `addTestDependencies4x` instead of `addTestDependencies` when registering test beans: ```kotlin import com.trendyol.stove.addTestDependencies4x springBoot( runner = { params -> runApplication(*params) { addTestDependencies4x { registerBean>(primary = true) registerBean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } } } } ) ``` See the [Kafka](../Components/02-kafka.md) and [Bridge](../Components/10-bridge.md) docs for full Spring Boot 4.x bean registration details. ## Examples - [spring-example](https://github.com/Trendyol/stove/tree/main/examples/spring-example) - [spring-standalone-example](https://github.com/Trendyol/stove/tree/main/examples/spring-standalone-example) - [spring-streams-example](https://github.com/Trendyol/stove/tree/main/examples/spring-streams-example) - [spring-4x-example](https://github.com/Trendyol/stove/tree/main/examples/spring-4x-example) ================================================ FILE: docs/getting-started.md ================================================ # Getting Started Get Stove running in your project in just a few minutes. Stove helps you write end-to-end tests by spinning up your application and all its dependencies (databases, message queues, etc.) together, so you can test the real thing instead of mocks. If you already know your application framework, the quickest route is [Supported Frameworks](frameworks/index.md). This guide focuses on the shared setup that applies across all starters. ## What You'll Need Make sure you have these installed: - **JDK 17+** - Stove needs Java 17 or higher - **Docker** - Required when using container mode (the default). Not needed if you use [provided instances](Components/11-provided-instances.md) to connect to existing infrastructure - **Kotlin 1.8+** - For writing your tests - **Gradle or Maven** - We use Gradle in all examples, but Maven works too !!! tip "IDE Setup" If you're using IntelliJ IDEA, grab the Kotest plugin. It adds run buttons and makes test discovery much smoother. ## Fastest Path If you want the smallest useful Stove setup, do this first: 1. Add `stove`, one starter, one test extension, and `stove-http`. 2. Expose a reusable application entrypoint that Stove can call. 3. Start Stove once for the suite. 4. Make one real HTTP request and assert the result. Everything else can be added incrementally. ## Step 1: Add The Minimum Dependencies Start with the smallest set that proves the flow works: ```kotlin repositories { mavenCentral() } dependencies { testImplementation(platform("com.trendyol:stove-bom:$stoveVersion")) testImplementation("com.trendyol:stove") // Pick one starter testImplementation("com.trendyol:stove-spring") // Pick one test framework extension testImplementation("com.trendyol:stove-extensions-kotest") // Start with one public surface testImplementation("com.trendyol:stove-http") } ``` !!! info "Latest Version" Check the [Releases](https://github.com/Trendyol/stove/releases) page for the latest version. !!! tip "Version Alignment" Keep the Stove BOM and all Stove test dependencies on the same version. If you use the dashboard, keep `stove-cli` on that same version too. Replace `stove-spring` with the starter that matches your runtime: - Spring Boot: `stove-spring` - Ktor: `stove-ktor` - Micronaut: `stove-micronaut` - Quarkus: `stove-quarkus` Then add only the components you actually need: - `stove-http` for REST APIs - `stove-kafka` for event flows - `stove-postgres`, `stove-mysql`, `stove-mongodb`, or `stove-redis` for persistence - `stove-wiremock` or `stove-grpc-mock` for external dependencies - `stove-tracing` for richer diagnostics If you are using Ktor, also add your preferred DI support. See the [Ktor guide](frameworks/ktor.md) for the exact setup. ## Step 2: Prepare Your Application Stove needs to start your application from tests, which means your app needs a reusable entrypoint. The shared pattern is: 1. keep the normal `main` 2. move the actual startup logic into a reusable `run(args)` style function 3. pass that function to Stove as the `runner` You only need the version for your framework: === "Spring Boot" ```kotlin hl_lines="13 15 16 17 18" // Before @SpringBootApplication class MyApplication fun main(args: Array) { runApplication(*args) } // After @SpringBootApplication class MyApplication fun main(args: Array) = run(args) fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { return runApplication(*args, init = init) } ``` === "Ktor with Koin" ```kotlin hl_lines="10 12 14 15 21" // Before fun main() { embeddedServer(Netty, port = 8080) { install(Koin) { modules(appModule) } configureRouting() }.start(wait = true) } // After - Accept test modules for overriding beans object MyApp { @JvmStatic fun main(args: Array) = run(args) fun run( args: Array, wait: Boolean = true, testModules: List = emptyList() ): Application { return embeddedServer(Netty, port = args.getPort()) { install(Koin) { modules(appModule, *testModules.toTypedArray()) } configureRouting() }.start(wait = wait).application } } ``` === "Ktor with Ktor-DI" ```kotlin hl_lines="10 12 14 15 22" // Before fun main() { embeddedServer(Netty, port = 8080) { install(DI) { dependencies { provide { MyServiceImpl() } } } configureRouting() }.start(wait = true) } // After - Accept test dependency overrides object MyApp { @JvmStatic fun main(args: Array) = run(args) fun run( args: Array, wait: Boolean = true, testDependencies: (DependencyRegistrar.() -> Unit)? = null ): Application { return embeddedServer(Netty, port = args.getPort()) { install(DI) { dependencies { provide { MyServiceImpl() } testDependencies?.invoke(this) // Apply test overrides } } configureRouting() }.start(wait = wait).application } } ``` === "Micronaut" ```kotlin fun main(args: Array) { run(args) } fun run( args: Array, init: ApplicationContext.() -> Unit = {} ): ApplicationContext { val context = ApplicationContext .builder() .args(*args) .build() .also(init) .start() context.findBean(EmbeddedApplication::class.java).ifPresent { app -> if (!app.isRunning) { app.start() } } return context } ``` === "Quarkus" ```kotlin package com.example import io.quarkus.runtime.Quarkus import io.quarkus.runtime.ShutdownEvent import io.quarkus.runtime.StartupEvent import io.quarkus.runtime.annotations.QuarkusMain import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.event.Observes @QuarkusMain object QuarkusMainApp { @JvmStatic fun main(args: Array) { Quarkus.run(*args) } } @ApplicationScoped class StoveStartupSignal { fun onStart(@Observes event: StartupEvent) { System.setProperty("stove.quarkus.ready", "true") } fun onStop(@Observes event: ShutdownEvent) { System.clearProperty("stove.quarkus.ready") } } ``` Stove calls your Quarkus `main` function directly. If your app has no HTTP endpoint, publish a startup signal like the one above so Stove can detect readiness. See the [Quarkus guide](frameworks/quarkus.md) for the full setup, including Kafka and tracing notes. ## Step 3: Create Test Configuration Set up Stove once for your entire test suite. This configuration runs before all your tests and shuts down after they're done. Use Stove() and .with { } to configure your test environment. If you are aiming for the fastest first success, start with one starter plus `stove-http`, confirm the app boots and responds, and only then add Kafka, databases, tracing, or mocks. We recommend putting e2e tests in a separate `src/test-e2e` source set to keep them separate from unit tests (see [Best Practices](best-practices.md#use-dedicated-source-set-for-e2e-tests) for the Gradle setup). !!! info "Test Framework Extensions" `StoveKotestExtension` and `StoveJUnitExtension` are separate packages that must be on your classpath: ```kotlin testImplementation("com.trendyol:stove-extensions-kotest") // For Kotest // or testImplementation("com.trendyol:stove-extensions-junit") // For JUnit ``` **Kotest** requires **6.1.3** or later. **JUnit** requires **Jupiter 6.x** if possible. In Kotest 6.x, `AbstractProjectConfig` is no longer auto-scanned. Create a `kotest.properties` file in your test resources (e.g. `src/test-e2e/resources/kotest.properties`): ```properties kotest.framework.config.fqn=com.myapp.e2e.TestConfig ``` Set the value to the fully qualified name of your `AbstractProjectConfig` class. If you are testing a Quarkus application, see the [Quarkus guide](frameworks/quarkus.md) for the starter-specific setup and limitations. === "Kotest" ```kotlin hl_lines="10 12 13 16 31" // src/test-e2e/kotlin/io/kotest/provided/ProjectConfig.kt import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import com.trendyol.stove.http.* import com.trendyol.stove.spring.springBoot class TestConfig : AbstractProjectConfig() { // Optional: Add this for detailed failure reports with execution context override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080" ) } // Replace `springBoot` with `ktor`, `micronaut`, or `quarkus` as needed springBoot( runner = { params -> com.myapp.run(params) }, withParameters = listOf( "server.port=8080", "logging.level.root=warn" ) ) } .run() } override suspend fun afterProject() { Stove.stop() } } ``` === "JUnit" ```kotlin // src/test-e2e/kotlin/e2e/TestConfig.kt import com.trendyol.stove.extensions.junit.StoveJUnitExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.http.* import com.trendyol.stove.spring.springBoot import org.junit.jupiter.api.extension.ExtendWith // Optional: Add this annotation for detailed failure reports @ExtendWith(StoveJUnitExtension::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class BaseE2ETest { companion object { @JvmStatic @BeforeAll fun setup() = runBlocking { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080" ) } // Replace `springBoot` with `ktor`, `micronaut`, or `quarkus` as needed springBoot( runner = { params -> com.myapp.run(params) }, withParameters = listOf( "server.port=8080", "logging.level.root=warn" ) ) } .run() } @JvmStatic @AfterAll fun teardown() = runBlocking { Stove.stop() } } } ``` ## Step 4: Write Your First Test === "Kotest" ```kotlin import com.trendyol.stove.system.stove class MyFirstE2ETest : FunSpec({ test("should return hello world") { stove { http { get("/hello") { response -> response shouldBe "Hello, World!" } } } } test("should create a user") { stove { http { postAndExpectBody( uri = "/users", body = CreateUserRequest(name = "John", email = "john@example.com").some() ) { response -> response.status shouldBe 201 response.body().name shouldBe "John" } } } } }) ``` === "JUnit" ```kotlin import com.trendyol.stove.system.stove class MyFirstE2ETest : BaseE2ETest() { @Test fun `should return hello world`() = runBlocking { stove { http { get("/hello") { response -> response shouldBe "Hello, World!" } } } } @Test fun `should create a user`() = runBlocking { stove { http { postAndExpectBody( uri = "/users", body = CreateUserRequest(name = "John", email = "john@example.com").some() ) { response -> response.status shouldBe 201 response.body().name shouldBe "John" } } } } } ``` ## Step 5: Add More Components Once you've got the basics working, you'll probably want to add more components. Here's how you'd set up a typical stack: ```kotlin hl_lines="9 19 33 37 40" Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } // Add Kafka for event-driven tests kafka { KafkaSystemOptions { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.interceptorClasses=${it.interceptorClass}" ) } } // Add Couchbase for database tests couchbase { CouchbaseSystemOptions( defaultBucket = "myBucket", configureExposedConfiguration = { cfg -> listOf( "couchbase.hosts=${cfg.hostsWithPort}", "couchbase.username=${cfg.username}", "couchbase.password=${cfg.password}" ) } ) } // Add WireMock for external service mocking wiremock { WireMockSystemOptions(port = 9090) } // Add bridge for DI container access bridge() springBoot( runner = { params -> com.myapp.run(params) }, withParameters = listOf( "server.port=8080", "external.service.url=http://localhost:9090" ) ) } .run() ``` ## Step 6: Write Tests That Span Multiple Systems Here's where Stove really shines. You can write tests that touch multiple systems and verify everything works together: ```kotlin hl_lines="4 8 17 31 38 46" import com.trendyol.stove.system.stove test("should create order and publish event") { stove { val orderId = UUID.randomUUID().toString() // Mock external payment service wiremock { mockPost( url = "/payments", statusCode = 200, responseBody = PaymentResult(success = true).some() ) } // Create order via API http { postAndExpectBody( uri = "/orders", body = CreateOrderRequest( id = orderId, items = listOf("item1", "item2"), amount = 99.99 ).some() ) { response -> response.status shouldBe 201 } } // Verify order stored in database couchbase { shouldGet("orders", orderId) { order -> order.status shouldBe "CREATED" order.amount shouldBe 99.99 } } // Verify event was published kafka { shouldBePublished(atLeastIn = 10.seconds) { actual.orderId == orderId && actual.amount == 99.99 } } // Access application beans directly using { val order = getOrder(orderId) order.status shouldBe "CREATED" } } } ``` Stove starts your application with its dependencies, runs your tests, and shuts everything down when done. ## Running Tests Run all your tests: ```bash ./gradlew test ``` Or run a specific test class: ```bash ./gradlew test --tests "com.myapp.e2e.OrderE2ETest" ``` If you're using the `test-e2e` source set, you might have a separate task: ```bash ./gradlew e2eTest ``` ## Next Steps Now that you're up and running, here's what to explore next: - **Components** - Check out the [Components documentation](Components/index.md) to see what's available - **Quarkus** - If your application uses Quarkus, follow the [Quarkus guide](frameworks/quarkus.md) - **Tracing** - Enable [Tracing](Components/15-tracing.md) to see exactly what happened inside your application when a test fails - **Reporting** - Set up [Reporting](Components/13-reporting.md) to get detailed failure diagnostics - **Dashboard** - Start the [local dashboard](Components/18-dashboard.md) when you want live timelines, traces, snapshots, and the REST API - **MCP** - Let AI agents inspect failed tests through the local [Stove MCP endpoint](Components/21-mcp.md) served by `stove` - **gRPC Mocking** - Mock external gRPC services with [gRPC Mocking](Components/14-grpc-mock.md) - **Best Practices** - Read the [Best Practices guide](best-practices.md) for tips on writing effective e2e tests - **Troubleshooting** - Hit an issue? Check the [Troubleshooting guide](troubleshooting.md) - **Examples** - Browse the [Examples](https://github.com/Trendyol/stove/tree/main/examples) and [Recipes](https://github.com/Trendyol/stove/tree/main/recipes) for complete working projects ## Common Patterns ### Keep Containers Running Between Test Runs Starting containers takes time. During development, you can keep them running between test runs to speed things up: ```kotlin hl_lines="2" Stove { keepDependenciesRunning() }.with { // Your configuration }.run() ``` ### Using a Custom Container Registry If you're behind a corporate firewall or need to use a private registry: ```kotlin // Set globally DEFAULT_REGISTRY = "your.registry.com" // Or per component kafka { KafkaSystemOptions( container = KafkaContainerOptions( registry = "your.registry.com" ) ) } ``` ### Use Unique Test Data To avoid test conflicts, generate unique data for each test run: ```kotlin test("should create user") { val userId = UUID.randomUUID().toString() val email = "test-${UUID.randomUUID()}@example.com" stove { // Use unique data to avoid conflicts } } ``` ## Troubleshooting Quick Tips | Problem | Solution | |---------|----------| | Docker not found | Ensure Docker is running and accessible | | Port conflicts | Use dynamic ports or ensure no conflicts | | Slow startup | Enable `keepDependenciesRunning()` for development | | Serialization errors | Configure `StoveSerde` to match your app's serializer | | Test isolation issues | Use unique test data and cleanup functions | For more help, see the [Troubleshooting Guide](troubleshooting.md). ================================================ FILE: docs/index.md ================================================ # Stove Stove is an end-to-end testing framework for JVM applications. It boots your real application together with the dependencies it actually uses, so your tests exercise the real runtime flow instead of a hand-built harness full of mocks. If your service talks to HTTP APIs, Kafka, databases, Redis, gRPC services, or external providers, Stove lets you bring those pieces into one test setup and assert the full behavior in one place. Since JVM languages interoperate, your application and tests do not need to use the same language. Write the app in Java, Kotlin, or Scala, and keep the tests consistent on the Stove side. When running in container mode (the default), Stove uses [Testcontainers](https://github.com/testcontainers/testcontainers-java) under the hood, so Docker must be installed. If you use [provided instances](Components/11-provided-instances.md) to connect to existing infrastructure, Docker is not required. !!! note "Not a Replacement for Unit Tests" Stove is for end-to-end and component tests, not unit tests. Keep unit tests for fast feedback on isolated logic. ## See It Quickly The core idea is small: ```kotlin Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } kafka { KafkaSystemOptions(...) } springBoot( runner = { params -> run(params) }, withParameters = listOf("server.port=8080") ) } .run() stove { http { get("/hello") { body -> body shouldContain "hello" } } kafka { shouldBePublished { it.contains("created") } } } ``` You start the real app, bring up only the dependencies you need, and assert through the surfaces that matter. ## Choose Your Path
- **New to Stove** Start with the shared setup model and learn the basic DSL once. [Getting Started](getting-started.md) - **Already know your framework** Pick the starter that matches your application runtime. [Supported Frameworks](frameworks/index.md) - **Already know your dependencies** Add Kafka, PostgreSQL, WireMock, HTTP, tracing, and other components as needed. [Components](Components/index.md) - **Want a working project** Open a complete example and adapt it instead of starting from scratch. [Examples on GitHub](https://github.com/Trendyol/stove/tree/main/examples) - **Running without Docker or in CI/CD** Use provided instances to connect to existing infrastructure instead of spinning up containers. [Provided Instances](Components/11-provided-instances.md) - **Debugging failures or using AI agents** Add the local dashboard, tracing, and MCP so failures come with timelines, spans, snapshots, and compact agent-readable evidence. [Dashboard & MCP](Components/18-dashboard.md)
## Why Stove The JVM ecosystem has strong application frameworks, but e2e setup is usually framework-specific and repetitive. Teams end up rebuilding the same boilerplate around containers, startup wiring, ports, config injection, test cleanup, and diagnostics. Stove standardizes that workflow: - start physical dependencies first (via containers or [provided instances](Components/11-provided-instances.md)) - boot the real application through its actual entrypoint - inject container/runtime configuration into the app - assert through HTTP, Kafka, gRPC, databases, and tracing - keep the same test DSL across frameworks One testing model, multiple JVM stacks. ## Supported Frameworks Stove currently ships starters for: - [Spring Boot](frameworks/spring-boot.md) - [Ktor](frameworks/ktor.md) - [Micronaut](frameworks/micronaut.md) - [Quarkus](frameworks/quarkus.md) See the full overview in [Supported Frameworks](frameworks/index.md), including `bridge()` availability and example links. ## What You Can Test Stove composes framework starters with pluggable components, so you can match your test environment to your production architecture. - APIs through [HTTP](Components/05-http.md) or [gRPC](Components/12-grpc.md) - event flows through [Kafka](Components/02-kafka.md) - persistence through [PostgreSQL](Components/06-postgresql.md), [MySQL](Components/16-mysql.md), [MongoDB](Components/07-mongodb.md), [Cassandra](Components/17-cassandra.md), [Redis](Components/09-redis.md), and more - external integrations through [WireMock](Components/04-wiremock.md) and [gRPC Mock](Components/14-grpc-mock.md) - execution diagnostics through [Reporting](Components/13-reporting.md), [Tracing](Components/15-tracing.md), the [Dashboard](Components/18-dashboard.md), and [MCP](Components/21-mcp.md) ## High Level Architecture ![Stove architecture](./assets/stove_architecture.svg) ## Start Here 1. Read [Getting Started](getting-started.md) for the shared setup. 2. Open your starter guide under [Supported Frameworks](frameworks/index.md). 3. Add the components you need from [Components](Components/index.md). 4. Add [Dashboard](Components/18-dashboard.md) and [MCP](Components/21-mcp.md) when you want local observability or AI-agent triage. 5. Compare against a real project in [examples](https://github.com/Trendyol/stove/tree/main/examples). ## Building From Source To build Stove locally you need: - JDK 17+ - Docker Then run: ```shell ./gradlew build ``` Want the background and motivation? Read the original [Medium article](https://medium.com/trendyol-tech/a-new-approach-to-the-api-end-to-end-testing-in-kotlin-f743fd1901f5). ================================================ FILE: docs/js/rough-notation-mkdocs.js ================================================ /** * Declarative RoughNotation for MkDocs * * Standalone annotation (animates when scrolled into view): * text * * Grouped annotations (animate sequentially when parent scrolls into view): *
* first * second *
* * Supported attributes: * data-rn - type: highlight, box, underline, circle, * strike-through, crossed-off, bracket * data-rn-color - color (default: current theme accent) * data-rn-stroke - strokeWidth (default: 2) * data-rn-padding - padding in px * data-rn-duration - animation duration in ms (default: 600) * data-rn-group - place on a parent element to group child [data-rn] spans */ (function () { 'use strict'; var DEFAULTS = { color: '#009688', strokeWidth: 2, animationDuration: 600, multiline: true }; var CODE_DEFAULTS = { type: 'highlight', color: '#00968830', strokeWidth: 1, animationDuration: 400, multiline: true, padding: 0 }; function parseOpts(el) { var opts = { type: el.dataset.rn, color: el.dataset.rnColor || DEFAULTS.color, strokeWidth: parseInt(el.dataset.rnStroke) || DEFAULTS.strokeWidth, animationDuration: parseInt(el.dataset.rnDuration) || DEFAULTS.animationDuration, multiline: DEFAULTS.multiline }; if (el.dataset.rnPadding !== undefined) { opts.padding = parseInt(el.dataset.rnPadding); } return opts; } function observe(target, threshold, callback) { var observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { callback(); observer.disconnect(); } }); }, { threshold: threshold }); observer.observe(target); } function init() { var RN = window.RoughNotation; if (!RN) return; // Grouped annotations: animate sequentially when parent scrolls into view document.querySelectorAll('[data-rn-group]').forEach(function (groupEl) { if (groupEl.dataset.rnInit) return; groupEl.dataset.rnInit = '1'; var anns = []; groupEl.querySelectorAll('[data-rn]').forEach(function (el) { if (el.dataset.rnInit) return; el.dataset.rnInit = '1'; anns.push(RN.annotate(el, parseOpts(el))); }); if (!anns.length) return; var group = RN.annotationGroup(anns); observe(groupEl, 0.2, function () { group.show(); }); }); // Standalone annotations: animate individually on scroll document.querySelectorAll('[data-rn]').forEach(function (el) { if (el.dataset.rnInit) return; el.dataset.rnInit = '1'; var ann = RN.annotate(el, parseOpts(el)); observe(el, 0.5, function () { ann.show(); }); }); // Code block hl_lines -> RoughNotation highlights document.querySelectorAll('.highlight pre, pre').forEach(function (pre) { if (pre.dataset.rnCodeInit) return; var hlls = pre.querySelectorAll('.hll'); if (!hlls.length) return; pre.dataset.rnCodeInit = '1'; var anns = []; hlls.forEach(function (hll) { hll.style.backgroundColor = 'transparent'; anns.push(RN.annotate(hll, { type: CODE_DEFAULTS.type, color: CODE_DEFAULTS.color, strokeWidth: CODE_DEFAULTS.strokeWidth, animationDuration: CODE_DEFAULTS.animationDuration, multiline: CODE_DEFAULTS.multiline, padding: CODE_DEFAULTS.padding })); }); if (anns.length) { var group = RN.annotationGroup(anns); observe(pre, 0.1, function () { group.show(); }); } }); } // MkDocs Material instant loading support if (typeof document$ !== 'undefined') { document$.subscribe(function () { init(); }); } // Initial page load if (document.readyState !== 'loading') { init(); } else { document.addEventListener('DOMContentLoaded', init); } })(); ================================================ FILE: docs/other-languages/go-container.md ================================================ # Go — Container Mode Run the Go application as a Docker image instead of a host binary using `stove-container` and the `containerApp()` DSL. This gives you image-level parity with what you ship to production — same Dockerfile, same entrypoint, same runtime — without changing a single line of Stove test code. For fast iteration without an image, see [Process Mode](go-process.md). The same Kotlin tests run against either. This page is the **Go-specific recipe**. For the language-agnostic `stove-container` reference — full DSL contract, image-source patterns, networking strategies, `configureContainer`, `beforeStarted`, troubleshooting matrix — see [Container AUT (`stove-container`)](../Components/22-container.md). The Go showcase below uses that module; it does not redefine it. ## Why container mode (Go-specific summary) | Concern | Process mode | Container mode | |---------|--------------|----------------| | **Iteration speed** | Fast — `go build` only | Slower — image build (or fetch from registry) | | **Production parity** | Approximate (host runtime) | Exact (the artifact you ship) | | **Glibc / Alpine differences** | Hidden | Surfaced | | **CI/CD validation** | Indirect | Direct | Use container mode in CI to catch image-only regressions (missing CA certs, wrong base image, locale issues, glibc/musl drift). Keep process mode for the inner debug loop. ## What this guide adds on top of Process Mode The Go application code, OpenTelemetry setup, Kafka bridge integration, and Stove test DSL are identical to [Process Mode](go-process.md). Container mode only changes: 1. **AUT runner** — `containerApp(...)` instead of `goApp(...)` (see the [container component page](../Components/22-container.md)) 2. **Image source** — a tagged image, from CI / a registry / or an optional local build 3. **(Optional) Coverage volume** — bind-mount a host directory into the container so coverage data survives container removal The Kotlin tests, the Stove DSL, the Stove systems, and the Go source code do not change. !!! info "Image build is not Stove's job" `containerApp(...)` only needs an image reference. Use whatever your CI already produced, pull from a registry, or wire an optional local Gradle build task — see [image source patterns](../Components/22-container.md#image-source-patterns) for the three options. The Dockerfile and `buildContainerImage` task below are the *recipe's* convenience for being self-contained, not a requirement. ## (Optional) Dockerfile for the showcase The recipe includes a Dockerfile so the repo is self-contained. In a real Go project, this is whatever your team already ships to production. ```dockerfile title="Dockerfile.container" FROM golang:1.26.2 AS build WORKDIR /workspace COPY go.mod go.sum ./ RUN go mod download COPY *.go ./ ARG GO_BUILD_FLAGS="" RUN CGO_ENABLED=0 GOOS=linux go build ${GO_BUILD_FLAGS} -o /out/go-showcase . FROM alpine:3.23 WORKDIR /app COPY --from=build /out/go-showcase /app/go-showcase EXPOSE 8090 ENTRYPOINT ["/app/go-showcase"] ``` The `GO_BUILD_FLAGS` build-arg is what threads `-cover` through the Docker build when coverage is enabled (process mode does this with `go build -cover` directly). ## Gradle Setup The minimum: a `Test` task that knows the image tag. The image can come from anywhere. ```kotlin title="build.gradle.kts" // Resolve the image tag in priority order: env var → Gradle property → local fallback val containerImage = providers.environmentVariable("APP_IMAGE") .orElse(providers.gradleProperty("app.image")) .orElse("stove-go-showcase-container:local") tasks.register("e2eTest-container") { description = "Runs container-based e2e tests." group = "verification" useJUnitPlatform() systemProperty("go.aut.mode", "container") systemProperty("go.app.container.image", containerImage.get()) systemProperty("kafka.library", "sarama") } ``` In CI, point `APP_IMAGE` (or `-Papp.image=...`) at the tag your image-build job just produced. No `dependsOn("buildContainerImage")` needed — Stove just runs whatever is at that tag. ### (Optional) Local build convenience If you also want a one-command local-build path, wire the Docker build as a separate task and add a *separate* test task that depends on it. Keep the CI-tag path untouched. ```kotlin title="build.gradle.kts" val dockerExecutable = providers.environmentVariable("DOCKER_EXECUTABLE").getOrElse("docker") val coverageEnabled = providers.gradleProperty("go.coverage") .map { it.toBoolean() }.getOrElse(false) val localImageTag = "stove-go-showcase-container:local" tasks.register("buildContainerImage") { description = "Optional convenience: builds the Go showcase Docker image locally." group = "build" dependsOn("goModTidy") val buildFlags = if (coverageEnabled) "-cover" else "" commandLine( dockerExecutable, "build", "--file", projectDir.resolve("Dockerfile.container").absolutePath, "--tag", localImageTag, "--build-arg", "GO_BUILD_FLAGS=$buildFlags", projectDir.absolutePath ) inputs.file(project.file("Dockerfile.container")) inputs.files(fileTree(".") { include("*.go", "go.mod", "go.sum") }) outputs.upToDateWhen { false } // Docker is the source of truth } tasks.register("removeContainerImage") { description = "Removes the locally-built image." group = "build" commandLine(dockerExecutable, "image", "rm", localImageTag) isIgnoreExitValue = true } // Local-build path — only this task triggers a build tasks.register("e2eTest-container-local") { description = "Builds the image locally and runs container e2e tests." group = "verification" dependsOn("buildContainerImage") useJUnitPlatform() systemProperty("go.aut.mode", "container") systemProperty("go.app.container.image", localImageTag) systemProperty("kafka.library", "sarama") if (coverageEnabled) { systemProperty("go.cover.dir", goCoverDirPath) outputs.cacheIf { false } } } ``` `buildContainerImage` is intentionally not cached — Docker is the source of truth for image freshness. The CI test task (`e2eTest-container`) does **not** depend on it. ## Stove Configuration (Go specifics) A single `StoveConfig.kt` can serve both modes by branching on a system property. The infrastructure systems (PostgreSQL, Kafka, tracing, dashboard) are identical to process mode — only the AUT runner block changes: ```kotlin title="StoveConfig.kt" containerApp( image = System.getProperty("go.app.container.image"), target = ContainerTarget.Server( hostPort = APP_PORT, internalPort = APP_PORT, portEnvVar = "APP_PORT", bindHostPort = false // host network — no need to bind ), envProvider = envMapper { // Stove → Go env var mapping (same keys as process mode) "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "database.username" to "DB_USER" "database.password" to "DB_PASS" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:$OTLP_PORT") env("KAFKA_LIBRARY", System.getProperty("kafka.library") ?: "sarama") env("STOVE_KAFKA_BRIDGE_PORT", stoveKafkaBridgePortDefault) env("GOCOVERDIR", coverageDirInContainer) }, configureContainer = { withNetworkMode("host") if (hostCoverageDir.isNotBlank()) { withFileSystemBind(hostCoverageDir, COVERAGE_DIR_IN_CONTAINER) } } ) ``` For the full list of `containerApp` parameters, `ContainerTarget` variants, networking strategies (`host` vs port-binding), and `configureContainer` capabilities, see the [container component page](../Components/22-container.md). ## Running ```bash # CI / registry image — pass the tag in ./gradlew e2eTest-container -Papp.image=ghcr.io/acme/go-showcase:sha-abc123 # or APP_IMAGE=ghcr.io/acme/go-showcase:sha-abc123 ./gradlew e2eTest-container # Optional local-build path (only when you wired buildContainerImage) ./gradlew e2eTest-container-local # Container e2e with Go coverage ./gradlew e2eTest-containerWithCoverage -Pgo.coverage=true # Remove the locally-built image when done ./gradlew removeContainerImage # Use locally-published Stove artifacts (e.g. before a snapshot release) ./gradlew e2eTest-container -PuseMavenLocal=true ``` By default the recipe resolves Stove from Maven Central + Sonatype snapshots so CI validates the same published path that users consume. `mavenLocal()` is opt-in. ## Code Coverage (Go-specific) Container coverage works the same way as [process mode](go-process.md#code-coverage), with two extra wiring details unique to Go-in-a-container: 1. The `Dockerfile` passes `${GO_BUILD_FLAGS}` so `-cover` reaches the build inside the image 2. The host coverage directory is bind-mounted into the container so data survives container teardown ```kotlin // In StoveConfig.kt private const val COVERAGE_DIR_IN_CONTAINER = "/tmp/go-coverage" val hostCoverageDir = System.getProperty("go.cover.dir").orEmpty() val coverageDirInContainer = if (hostCoverageDir.isBlank()) "" else COVERAGE_DIR_IN_CONTAINER containerApp( // ... envProvider = envMapper { // ... env("GOCOVERDIR", coverageDirInContainer) }, configureContainer = { withNetworkMode("host") if (hostCoverageDir.isNotBlank()) { withFileSystemBind(hostCoverageDir, COVERAGE_DIR_IN_CONTAINER) } } ) ``` ```bash ./gradlew e2eTest-containerWithCoverage -Pgo.coverage=true # HTML report at build/go-coverage/coverage.html ``` `signal.Ignore(syscall.SIGPIPE)` in `main()` matters here too — Stove sends SIGTERM to stop the container, and Go must finish flushing coverage data before the process dies. ## Dashboard & MCP Container mode emits to the [Stove Dashboard](../Components/18-dashboard.md) and the [MCP server](../Components/21-mcp.md) the same way process mode does. The `appName` you set in `DashboardSystemOptions` is the only label MCP needs to find the right runs: ```text Agent calls stove_failures → finds failed runs for app_name=go-showcase → calls stove_failure_detail with run_id + test_id → drills into stove_trace to see Go spans ``` Because tracing is `traceparent`-correlated, a Go span captured inside the container shows up in the same trace tree as the originating Stove HTTP call — no additional plumbing required. ## Reference - Container component page (DSL contract, networking, troubleshooting): [Container AUT (`stove-container`)](../Components/22-container.md) - Container module source: `starters/container/stove-container/` - Full working example (process **and** container modes in one repo): [`recipes/process/golang/go-showcase`](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase) - Bridge library source: [`go/stove-kafka`](https://github.com/Trendyol/stove/tree/main/go/stove-kafka) - Component docs: [Dashboard](../Components/18-dashboard.md) · [MCP](../Components/21-mcp.md) · [Tracing](../Components/15-tracing.md) ================================================ FILE: docs/other-languages/go-process.md ================================================ # Go — Process Mode Run the Go binary directly as the application under test using `stove-process` and the `goApp()` DSL. This is the fastest iteration loop: no image build, no registry, just `go build` and run. For container-based AUT (CI parity with the production image), see [Container Mode](go-container.md). ## What this guide covers End-to-end Go testing with HTTP, PostgreSQL, Kafka (sarama / franz-go / segmentio), distributed tracing, dashboard streaming, MCP triage, and integration coverage. The full source is at [`recipes/process/golang/go-showcase`](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase). ## Project Structure ``` go-showcase/ # Standalone Gradle project (copy-paste ready) main.go # Entry point, env var config, graceful shutdown db.go # PostgreSQL queries (auto-traced via otelsql) handlers.go # HTTP handlers + Kafka publish (auto-traced via otelhttp) kafka.go # KafkaProducer interface, factory, shared consumer handler kafka_sarama.go # IBM/sarama implementation kafka_franz.go # twmb/franz-go implementation kafka_segmentio.go # segmentio/kafka-go implementation tracing.go # OpenTelemetry SDK initialization go.mod stovetests/ # Kotlin Stove tests kotlin/com/.../e2e/ setup/ StoveConfig.kt # Single setup file (switches process/container via go.aut.mode) ProductMigration.kt # Creates products table tests/ GoShowcaseTest.kt # E2E tests resources/ kotest.properties build.gradle.kts # Builds Go + runs Kotlin tests settings.gradle.kts # Published Go library used by the showcase: go/stove-kafka/ # Stove Kafka bridge for Go applications bridge.go # Core bridge (library-agnostic gRPC client) sarama/ # IBM/sarama interceptors franz/ # twmb/franz-go hooks segmentio/ # segmentio/kafka-go helpers stoveobserver/ # Generated gRPC code from messages.proto go.mod ``` ## The Go Application A minimal HTTP + PostgreSQL service. The key design choice: all tracing is in the infrastructure layer, not in business logic. ### Entry Point ```go title="main.go" func main() { // Ignore SIGPIPE so log writes to a closed stdout pipe don't kill the process // when running under ProcessBuilder. Critical for graceful shutdown + coverage flush. signal.Ignore(syscall.SIGPIPE) ctx := context.Background() port := getEnv("APP_PORT", "8080") shutdownTracing, _ := initTracing(ctx, "go-showcase") defer shutdownTracing(ctx) db, _ := initDB(connStr) // otelsql wraps database/sql automatically defer db.Close() bridge, _ := stovekafka.NewBridgeFromEnv() // nil in production — zero overhead defer bridge.Close() kafkaLibrary := getEnv("KAFKA_LIBRARY", "sarama") producer, stopKafka, _ := initKafka(kafkaLibrary, brokers, db, bridge) defer stopKafka() mux := http.NewServeMux() registerRoutes(mux, db, producer) handler := otelhttp.NewHandler(mux, "http.request") server := &http.Server{Addr: ":" + port, Handler: handler} // ... graceful shutdown on SIGTERM } ``` Configuration comes entirely from environment variables: | Variable | Purpose | Default | |----------|---------|---------| | `APP_PORT` | HTTP listen port | `8080` | | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | PostgreSQL connection | `localhost`, `5432`, `stove`, `sa`, `sa` | | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP gRPC endpoint for traces | *(disabled if empty)* | | `KAFKA_BROKERS` | Comma-separated Kafka broker addresses | *(disabled if empty)* | | `KAFKA_LIBRARY` | Kafka client library: `sarama`, `franz`, or `segmentio` | `sarama` | | `STOVE_KAFKA_BRIDGE_PORT` | Stove Kafka bridge gRPC port | *(disabled if empty, test-only)* | | `GOCOVERDIR` | Directory for Go integration test coverage data | *(disabled if empty, test-only)* | ### Handlers, DB, Tracing Handlers and DB code are pure business logic — no tracing imports — because `otelhttp` and `otelsql` instrument transparently. See the [container guide](go-container.md) and the showcase repo for the full code; the same files are used in both modes. !!! tip "Sync vs Batch Exporter" Use `sdktrace.WithSyncer(exporter)` for tests so spans are exported immediately when they end. In production, use `WithBatcher(exporter)` for performance. The 5-second default batch interval would cause test assertions to fail because spans wouldn't arrive in time. !!! info "W3C Trace Context Propagation" Setting `propagation.TraceContext{}` is essential. Stove's HTTP client sends a `traceparent` header with each request. The `otelhttp` middleware extracts it, so all spans in the Go app share the same trace ID as the test — and the [Stove Dashboard](../Components/18-dashboard.md) and [MCP](../Components/21-mcp.md) tools can correlate them with the failure. ## Kafka — `stove-kafka` bridge Stove provides a Go bridge library (`stove-kafka`) that enables `shouldBeConsumed` and `shouldBePublished` assertions for Go applications. The bridge forwards produced/consumed messages over gRPC to Stove's `StoveKafkaObserverGrpcServer`. The core is library-agnostic; client-specific subpackages provide interceptors/hooks for popular Go Kafka libraries: | Library | Subpackage | Integration | |---------|-----------|-------------| | [IBM/sarama](https://github.com/IBM/sarama) | `sarama` | `ProducerInterceptor` / `ConsumerInterceptor` | | [twmb/franz-go](https://github.com/twmb/franz-go) | `franz` | `kgo.WithHooks(&franz.Hook{...})` | | [segmentio/kafka-go](https://github.com/segmentio/kafka-go) | `segmentio` | `segmentio.ReportWritten()` / `segmentio.ReportRead()` | !!! tip "Using other Kafka libraries (e.g. confluent-kafka-go)" The subpackages above are conveniences. The core bridge (`PublishedMessage`, `ConsumedMessage`, `Bridge`) has **no Kafka client dependency**. For any library not listed above, import only the core package and call `bridge.ReportPublished()`, `bridge.ReportConsumed()`, and `bridge.ReportCommitted()` directly with your own type conversion. In production, `STOVE_KAFKA_BRIDGE_PORT` is not set, so `NewBridgeFromEnv()` returns `nil`. All Bridge methods are nil-safe no-ops — zero overhead. ### Integrating the Bridge ```bash go get github.com/trendyol/stove/go/stove-kafka ``` ```go import stovekafka "github.com/trendyol/stove/go/stove-kafka" bridge, _ := stovekafka.NewBridgeFromEnv() defer bridge.Close() ``` Wire into your client: === "IBM/sarama" ```go import stovesarama "github.com/trendyol/stove/go/stove-kafka/sarama" config := sarama.NewConfig() config.Producer.Interceptors = []sarama.ProducerInterceptor{ &stovesarama.ProducerInterceptor{Bridge: bridge}, } config.Consumer.Interceptors = []sarama.ConsumerInterceptor{ &stovesarama.ConsumerInterceptor{Bridge: bridge}, } ``` === "twmb/franz-go" ```go import "github.com/trendyol/stove/go/stove-kafka/franz" client, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.WithHooks(&franz.Hook{Bridge: bridge}), ) ``` === "segmentio/kafka-go" ```go import "github.com/trendyol/stove/go/stove-kafka/segmentio" err := writer.WriteMessages(ctx, msgs...) segmentio.ReportWritten(ctx, bridge, msgs...) msg, err := reader.ReadMessage(ctx) segmentio.ReportRead(ctx, bridge, msg) ``` When `Bridge` is nil (production), all interceptors/helpers return immediately with zero overhead. ### Test-Friendly Kafka Settings When running against Testcontainers, configure Kafka clients for **fast feedback**: - **Auto-create topics** — the test container may not have topics pre-created - **Small batch size / low batch timeout** — flush produces immediately - **Short auto-commit interval** — make consumed offsets visible to Stove quickly === "IBM/sarama" ```go config := sarama.NewConfig() config.Producer.Return.Successes = true config.Consumer.Offsets.Initial = sarama.OffsetOldest config.Consumer.Offsets.AutoCommit.Interval = 100 * time.Millisecond ``` === "twmb/franz-go" ```go kgo.AllowAutoTopicCreation(), kgo.AutoCommitInterval(100 * time.Millisecond), kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()), ``` === "segmentio/kafka-go" ```go writer := &kafka.Writer{ BatchSize: 1, BatchTimeout: 10 * time.Millisecond, AllowAutoTopicCreation: true, } reader := kafka.NewReader(kafka.ReaderConfig{ CommitInterval: 100 * time.Millisecond, MaxWait: 500 * time.Millisecond, }) ``` !!! warning "Production vs Test settings" These aggressive settings are optimized for test speed, not throughput. In production, use larger batch sizes, longer commit intervals, and broker-managed topic creation. ### Consumer Groups Each Kafka library run uses a unique consumer group ID (`"go-showcase-" + library`) to prevent offset carryover between sequential test runs. ## Stove Test Setup ### Gradle Build ```kotlin title="build.gradle.kts" val goBinary = layout.buildDirectory.file("go-app").get().asFile val goExecutable = providers.environmentVariable("GO_EXECUTABLE").getOrElse("go") val coverageEnabled = providers.gradleProperty("go.coverage") .map { it.toBoolean() }.getOrElse(false) tasks.register("buildGoApp") { description = "Compiles the Go application." group = "build" val args = mutableListOf(goExecutable, "build") if (coverageEnabled) args.add("-cover") args.addAll(listOf("-o", goBinary.absolutePath, ".")) commandLine(args) inputs.files(fileTree(".") { include("*.go", "go.mod", "go.sum") }) outputs.file(goBinary) } // Per-library e2e test tasks val kafkaLibraries = listOf("sarama", "franz", "segmentio") val kafkaE2eTasks = kafkaLibraries.mapIndexed { index, lib -> tasks.register("e2eTest_$lib") { dependsOn("buildGoApp") systemProperty("go.aut.mode", "process") systemProperty("go.app.binary", goBinary.absolutePath) systemProperty("kafka.library", lib) if (index > 0) mustRunAfter("e2eTest_${kafkaLibraries[index - 1]}") } } tasks.named("e2eTest") { dependsOn(kafkaE2eTasks); enabled = false } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stoveProcess) testImplementation(stoveLibs.stovePostgres) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveTracing) testImplementation(stoveLibs.stoveDashboard) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveExtensionsKotest) } ``` ### Stove Configuration ```kotlin title="StoveConfig.kt" Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:$APP_PORT") } dashboard { DashboardSystemOptions(appName = "go-showcase") } tracing { enableSpanReceiver(port = OTLP_PORT) } kafka { KafkaSystemOptions( configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "database.host=${cfg.host}", "database.port=${cfg.port}", "database.name=stove", "database.username=${cfg.username}", "database.password=${cfg.password}" ) } ).migrations { register() } } goApp( target = ProcessTarget.Server(port = APP_PORT, portEnvVar = "APP_PORT"), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "database.username" to "DB_USER" "database.password" to "DB_PASS" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:$OTLP_PORT") env("KAFKA_LIBRARY") { System.getProperty("kafka.library") ?: "sarama" } env("STOVE_KAFKA_BRIDGE_PORT", stoveKafkaBridgePortDefault) env("GOCOVERDIR") { System.getProperty("go.cover.dir") ?.also { java.io.File(it).mkdirs() } ?: "" } } ) }.run() ``` The `envMapper` block declaratively maps Stove's exposed configurations to environment variables the Go app expects. Use `"stoveKey" to "ENV_VAR"` for config-derived values and `env("NAME", "value")` for static ones. For apps that prefer CLI arguments, use `argsMapper` instead (or alongside). ### Database Migration ```kotlin title="ProductMigration.kt" class ProductMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.sql.execute( queryOf(""" CREATE TABLE IF NOT EXISTS products ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, price DECIMAL(10, 2) NOT NULL ) """).asExecute ) } } ``` ## Writing Tests ```kotlin title="GoShowcaseTest.kt" class GoShowcaseTest : FunSpec({ test("create product, verify HTTP, DB, Kafka, traces") { stove { var productId: String? = null http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Test", price = 42.99).some() ) { actual -> actual.status shouldBe 201 productId = actual.body().id } } postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = productRowMapper ) { rows -> rows.size shouldBe 1 } } kafka { shouldBePublished(10.seconds) { actual.name == "Test" } } tracing { waitForSpans(4, 5000) shouldContainSpan("http.request") shouldNotHaveFailedSpans() } } } }) ``` Verify the Go app consumes events and updates state: ```kotlin test("consume product update events from Kafka") { stove { var productId: String? = null http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Original", price = 10.0).some() ) { actual -> productId = actual.body().id } } kafka { publish("product.update", ProductUpdateEvent(id = productId!!, name = "Updated", price = 99.99)) shouldBeConsumed(10.seconds) { actual.id == productId && actual.name == "Updated" } } postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = productRowMapper ) { rows -> rows.first().name shouldBe "Updated" } } } } ``` ## Dashboard & MCP When the [`stove` CLI](../Components/18-dashboard.md) is running, the Go run streams to `http://localhost:4040` like any JVM run — timeline, traces, snapshots, Kafka explorer. For AI-assisted triage, the same CLI exposes a [Model Context Protocol endpoint](../Components/21-mcp.md) at `http://localhost:4040/mcp`. Agents call `stove_failures` to discover failed Go tests, then `stove_failure_detail`, `stove_timeline`, `stove_trace`, and `stove_snapshot` for compact, structured evidence — no log scraping required. ## Code Coverage For both process-mode and container-mode AUT runs, Stove executes your app outside `go test`, so standard `go test -cover` doesn't apply. Go 1.20+ integration coverage fits this model: build with `go build -cover`, set `GOCOVERDIR`, and flush data on graceful shutdown (SIGTERM). ### How It Works ``` 1. go build -cover → instruments the binary 2. GOCOVERDIR=/path → tells the binary where to write coverage data 3. SIGTERM (Stove stop) → graceful shutdown triggers coverage flush 4. go tool covdata textfmt → converts raw data to standard coverage.out 5. go tool cover -func/-html → human-readable reports ``` ### Gradle Setup The recipe supports coverage via the `-Pgo.coverage=true` Gradle property. When disabled (default), there is zero overhead. ```kotlin title="build.gradle.kts" val coverageEnabled = providers.gradleProperty("go.coverage") .map { it.toBoolean() }.getOrElse(false) val goCoverDirPath = layout.buildDirectory.dir("go-coverage").get().asFile.absolutePath tasks.register("buildGoApp") { val args = mutableListOf(goExecutable, "build") if (coverageEnabled) args.add("-cover") args.addAll(listOf("-o", goBinary.absolutePath, ".")) commandLine(args) } tasks.register("e2eTest_sarama") { if (coverageEnabled) { systemProperty("go.cover.dir", goCoverDirPath) outputs.cacheIf { false } // Coverage data is a side effect } } if (coverageEnabled) { tasks.register("goCoverageReport") { mustRunAfter(kafkaE2eTasks) commandLine(goExecutable, "tool", "covdata", "textfmt", "-i=$goCoverDirPath", "-o=$goCoverOutPath") } tasks.register("goCoverageSummary") { dependsOn("goCoverageReport") commandLine(goExecutable, "tool", "cover", "-func=$goCoverOutPath") } tasks.register("goCoverageHtml") { dependsOn("goCoverageReport") commandLine(goExecutable, "tool", "cover", "-html=$goCoverOutPath", "-o=coverage.html") } tasks.register("e2eTestWithCoverage") { dependsOn(kafkaE2eTasks) finalizedBy("goCoverageSummary", "goCoverageHtml") } } ``` ### SIGPIPE Handling When a Go process runs under Java's `ProcessBuilder`, the stdout pipe can close before the process exits. If Go writes to the closed pipe (e.g. `log.Println` during shutdown), it receives SIGPIPE and terminates immediately — before the coverage counters are flushed. Add this at the top of `main()`: ```go title="main.go" func main() { signal.Ignore(syscall.SIGPIPE) // ... } ``` This is good practice for any long-running Go service managed by an external process, not just for coverage. ### Running ```bash # Without coverage (default — zero overhead) ./gradlew e2eTest_sarama # With coverage — runs tests + generates reports ./gradlew e2eTestWithCoverage -Pgo.coverage=true ``` The HTML report is written to `build/go-coverage/coverage.html`. Container-mode coverage uses the same flag — see [Container Mode](go-container.md#code-coverage). !!! tip "Why no Stove framework changes were needed" Everything is achievable with existing primitives: the `-cover` build flag is a Gradle concern, `GOCOVERDIR` is just another env var, coverage processing happens after tests, and graceful shutdown is handled by the AUT starter (`stove-process` or `stove-container`). ## How Tracing Flows ``` 1. StoveKotestExtension starts a TraceContext before each test 2. Stove HTTP client injects `traceparent` header into requests 3. otelhttp middleware extracts traceparent, creates HTTP span as child 4. Handler passes r.Context() to DB functions 5. otelsql creates DB spans as children of the HTTP span 6. All spans share the same trace ID as the test 7. Spans are exported via OTLP gRPC to Stove's receiver 8. tracing { shouldContainSpan(...) } queries spans by trace ID ``` ## Running ```bash # From the go-showcase directory — runs all three Kafka libraries cd recipes/process/golang/go-showcase ./gradlew e2eTest ./gradlew e2eTest_sarama ./gradlew e2eTest_franz ./gradlew e2eTest_segmentio ./gradlew e2eTestWithCoverage -Pgo.coverage=true ``` ## Go Dependencies ``` go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp # HTTP middleware go.opentelemetry.io/otel # OTel API go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc # OTLP gRPC exporter go.opentelemetry.io/otel/sdk # OTel SDK github.com/XSAM/otelsql # database/sql auto-instrumentation github.com/lib/pq # PostgreSQL driver google.golang.org/grpc # gRPC (for OTLP + bridge) # Kafka — pick one client + its bridge subpackage: github.com/IBM/sarama # + stove-kafka/sarama github.com/twmb/franz-go/pkg/kgo # + stove-kafka/franz github.com/segmentio/kafka-go # + stove-kafka/segmentio github.com/trendyol/stove/go/stove-kafka # Core bridge (always needed) ``` ================================================ FILE: docs/other-languages/go.md ================================================ # Go Stove treats Go as a first-class application under test. The same Stove DSL — `http {}`, `postgresql {}`, `kafka {}`, `tracing {}`, `dashboard {}` — drives a Go service end to end. Distributed traces, dashboard streams, and integration coverage all flow through the standard Stove pipeline. The full source is at [`recipes/process/golang/go-showcase`](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase). One showcase, two AUT modes — pick the one that matches what you want to test. ## Pick a mode | Mode | Starter | When to use | Trade-off | |------|---------|-------------|-----------| | **Process** | `stove-process` (`goApp` / `processApp`) | Fast local iteration, direct binary run, easiest debugging | You manage host runtime/binary alignment | | **Container** | `stove-container` (`containerApp`) | CI parity with the production image, environment isolation | Image build adds setup cost | Rule of thumb: start with [process mode](go-process.md) for fast feedback, then add [container mode](go-container.md) when you want image-level confidence in CI. The same Kotlin tests run against either.
- :material-language-go: **Process Mode** Run the Go binary directly. Fastest iteration loop. [Process Mode guide :material-arrow-right:](go-process.md) - :material-docker: **Container Mode** Run the production Docker image. CI-grade parity. [Container Mode guide :material-arrow-right:](go-container.md)
## What you get out of the box - **HTTP, PostgreSQL, Kafka, MongoDB, Redis, …** — every Stove system works against a Go AUT - **Distributed tracing** via OpenTelemetry — spans from Go appear in the same trace tree as the test - **Dashboard** — the Go run streams to `http://localhost:4040` like any JVM run - **MCP triage** — failed Go runs are queryable through the [`stove` CLI MCP server](../Components/21-mcp.md) - **Kafka assertions** — `shouldBePublished` / `shouldBeConsumed` work for Go via the [`stove-kafka`](https://github.com/trendyol/stove/tree/main/go/stove-kafka) bridge (sarama, franz-go, segmentio, or any client via the core API) - **Integration coverage** — `go build -cover` + `GOCOVERDIR` collected on graceful shutdown, with HTML/summary reports ## Adapting for other languages The same model works for any language. Replace the Go-specific parts (build step, OTel SDK, Kafka bridge): | Part | Go | Python | Node.js | Rust | |------|-----|--------|---------|------| | **Build step** | `go build` | *(none or pip install)* | `npm install && npm run build` | `cargo build` | | **AUT runner** | `goApp()` / `containerApp()` | `processApp()` / `containerApp()` | `processApp()` / `containerApp()` | `processApp()` / `containerApp()` | | **OTel HTTP** | `otelhttp.NewHandler` | `opentelemetry-instrumentation-flask` | `@opentelemetry/instrumentation-http` | `tracing-opentelemetry` | | **OTel DB** | `otelsql` | `opentelemetry-instrumentation-psycopg2` | `@opentelemetry/instrumentation-pg` | `tracing-opentelemetry` | | **Kafka assertions** | `stove-kafka` bridge | *(bridge library needed)* | *(bridge library needed)* | *(bridge library needed)* | The Kotlin test side stays exactly the same — only the AUT runner and config mapping differ. ================================================ FILE: docs/other-languages/index.md ================================================ # Other Languages & Stacks Stove ships with JVM framework starters (Spring Boot, Ktor, Micronaut, Quarkus), but the core testing model isn't limited to JVM applications. You can use Stove to test any application that speaks HTTP, databases, and messaging --- regardless of the language it's written in. The key choice is *how* the application under test runs: - **`stove-process`** — start a host binary (`processApp` / `goApp`). Fastest iteration loop. Zero infrastructure beyond your compiler. - **`stove-container`** — start a Docker image (`containerApp`). CI-grade parity with the artifact you ship to production. Both expose the same envelope: env-var or CLI-arg config mapping, readiness strategies, graceful shutdown, and the same `stove { http { … } postgresql { … } kafka { … } tracing { … } }` test DSL. ## How It Works ```mermaid graph LR S[Stove Test
Kotlin] -->|starts containers| PG[(PostgreSQL)] S -->|starts containers| K[(Kafka)] S -->|starts OTLP receiver| T[Tracing] S -->|process or container| APP[Your App
Go / Python / Rust / ...] APP -->|connects| PG APP -->|connects| K APP -->|exports spans| T S -->|HTTP / gRPC assertions| APP S -->|DB assertions| PG S -->|trace assertions| T ``` Stove starts the infrastructure (databases, message brokers, OTLP receiver), launches your application as either an OS process or a Docker container with the right connection details, and runs tests against it using the same DSL you'd use for JVM apps. ## Supported Languages Any language that can: 1. **Read environment variables (or CLI args)** — to receive database URLs, ports, and credentials 2. **Expose a readiness signal** — typically an HTTP `/health` endpoint, but TCP / custom probes / fixed delay are supported 3. **Shut down on SIGTERM** — for clean test teardown (and for things like Go integration coverage flushing)
- :material-language-go: **Go** — first-class Full walkthrough across both modes: HTTP + PostgreSQL + Kafka + OpenTelemetry + Dashboard + MCP + integration coverage. [Overview :material-arrow-right:](go.md) · [Process Mode :material-arrow-right:](go-process.md) · [Container Mode :material-arrow-right:](go-container.md)
## Process vs. Container at a glance | Concern | `stove-process` | `stove-container` | |---------|----------------|-------------------| | **Starter** | `goApp()` / `processApp()` | `containerApp()` | | **AUT artifact** | Host binary | Docker image | | **Iteration speed** | Fast (compile + run) | Slower (image build) | | **Production parity** | Approximate (host runtime) | Exact | | **CI fit** | Smoke / inner loop | Pre-merge / release validation | | **Networking** | Loopback | Host network *or* port binding | | **Filesystem isolation** | Host filesystem | Container layer + bind mounts | | **Common pitfalls** | Glibc/runtime drift hidden | Network mode + port binding wiring | A common pattern: `e2eTest` uses process mode for daily development, `e2eTest-container` runs container mode in CI. Same Kotlin tests, same StoveConfig, branched only on a `-Dgo.aut.mode=process|container` system property. ## At A Glance vs. JVM apps | Concern | JVM App (Spring Boot, etc.) | Non-JVM App (Go, Python, etc.) | |---------|---------------------------|-------------------------------| | **Application startup** | Framework starter (`springBoot()`, `ktor()`) | `goApp()` / `processApp()` (`stove-process`) or `containerApp()` (`stove-container`) | | **Config passing** | JVM system properties / Spring properties | `envMapper` / `argsMapper` | | **Infrastructure** | Same (`postgresql {}`, `kafka {}`, `http {}`) | Same | | **Test DSL** | Same (`stove { http { ... } postgresql { ... } }`) | Same | | **Tracing** | OTel Java Agent (automatic) | OTel SDK for your language (e.g., `otelhttp`, `otelsql`) | | **Dashboard** | Same (`dashboard {}`) | Same | | **MCP triage** | Same (`stove` CLI, `/mcp`) | Same | | **Bridge (`using {}`)** | Yes (access DI container) | No (separate process / container) | ## The Pattern Every non-JVM integration follows the same three steps, regardless of language or mode: ### 1. Choose a starter and wire the AUT ```kotlin // Process mode goApp( target = ProcessTarget.Server(port = 8090, portEnvVar = "APP_PORT"), envProvider = envMapper { /* ... */ } ) // Container mode containerApp( image = "my-app:local", target = ContainerTarget.Server(hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT"), envProvider = envMapper { /* ... */ }, configureContainer = { withNetworkMode("host") } ) ``` ### 2. Instrument your app with OpenTelemetry Use your language's OTel SDK. Stove starts an OTLP gRPC receiver and passes the endpoint via env vars. Your app exports spans to Stove, and Stove correlates them back to the test via W3C `traceparent` headers. ### 3. Write tests with the standard DSL Tests look identical to JVM tests — `http {}`, `postgresql {}`, `kafka {}`, `tracing {}`, `dashboard {}` all work the same way. ## What You Can't Do Since the application runs as a separate OS process or container: - **No `bridge()` / `using {}`** — you can't access the app's internal state or DI container Everything else works: HTTP assertions, database queries, Kafka publishing and consuming (`shouldBePublished`, `shouldBeConsumed`), tracing, WireMock, gRPC, the [Dashboard](../Components/18-dashboard.md), and the [MCP](../Components/21-mcp.md) triage tools. !!! info "Kafka assertions for non-JVM apps" Stove provides bridge libraries that enable `shouldBeConsumed` and `shouldBePublished` assertions for non-JVM applications. The [`stove-kafka`](https://github.com/trendyol/stove/tree/main/go/stove-kafka) Go library supports IBM/sarama (interceptors), twmb/franz-go (hooks), and segmentio/kafka-go (helpers), and forwards messages via gRPC to Stove's observer. The library-agnostic core also lets you wire any other Kafka client (e.g. confluent-kafka-go) yourself. ## Next Steps - [Go overview](go.md) — pick the right mode for your needs - [Go Process Mode](go-process.md) — fastest iteration, full HTTP + PG + Kafka + tracing + coverage walkthrough - [Go Container Mode](go-container.md) — CI-grade parity with the production image - [Provided Application](../Components/19-provided-application.md) — for testing already-deployed apps (black-box) - [Dashboard](../Components/18-dashboard.md) — live timeline, traces, snapshots, Kafka explorer - [MCP](../Components/21-mcp.md) — agent-friendly endpoint for failed-test triage - [Writing Custom Systems](../writing-custom-systems.md) — extend Stove with new component types ================================================ FILE: docs/release-notes/0.15.0.md ================================================ # 0.15.0 ## From 0.14.x to 0.15.x ### Breaking Changes The most notable breaking change is ser/de operations. The framework was only relying on Jackson for serialization and deserialization. Now, it provides a way to use other serialization libraries. `StoveSerde` is the new interface that you can implement to provide your own serialization and deserialization logic. `StoveSerde` also provides the access to the other serializers that `com-trendyol:stove-testing-e2e` package has: * Jackson * Gson * Kotlinx See the component guides for the current serialization configuration examples. #### Spring Kafka (com-trendyol:stove-spring-testing-e2e-kafka) The `TestSystemKafkaInterceptor` now depends on `StoveSerde` to provide the serialization and deserialization logic instead of `ObjectMapper`. You can of course use your default Jackson implementation by providing the `ObjectMapperConfig.default()` to the `StoveSerde.jackson.anyByteArraySerde` function. ```kotlin hl_lines="3 4" class TestSystemInitializer : BaseApplicationContextInitializer({ bean>(isPrimary = true) bean { StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfig.default()) } // or any other serde that is }) ``` ### Standalone Kafka ```kotlin hl_lines="3" kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfig.default) // or any other serde that is //... ) } ``` ### Couchbase ```kotlin hl_lines="4" couchbase { CouchbaseSystemOptions( clusterSerDe = JacksonJsonSerializer(CouchbaseConfiguration.objectMapper), // here you can provide your own serde //... ) } ``` ### Http ```kotlin httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8001", contentConverter = JacksonConverter(ObjectMapperConfig.default) ) } ``` ### Wiremock ```kotlin wiremock { WireMockSystemOptions( port = 9090, serde = StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfiguration.default) ) ``` ### Elasticsearch ```kotlin elasticsearch { ElasticsearchSystemOptions( jsonpMapper = JacksonJsonpMapper(StoveSerde.jackson.default), // or any JsonpMapper ) } ``` ### Mongodb ```kotlin mongodb { MongoDbSystemOptions( serde = StoveSerde.jackson.default // or any other serde that you implement ) } ``` The default serde is: ```kotlin val serde: StoveSerde = StoveSerde.jackson.anyJsonStringSerde( StoveSerde.jackson.byConfiguring { disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(MapperFeature.DEFAULT_VIEW_INCLUSION) addModule(ObjectIdModule()) addModule(KotlinModule.Builder().build()) } ), ``` ================================================ FILE: docs/release-notes/0.19.0.md ================================================ # 0.19.0 **Release Date:** December 2025 This release introduces gRPC support, WebSocket testing, and provided instances for external infrastructure: - **gRPC System**: New component for testing gRPC APIs (grpc-kotlin, Wire) - **WebSocket Testing**: Added to HTTP system for real-time communication testing - **Partial Mocking**: WireMock now supports `mockPostContaining`, `mockPutContaining`, `mockPatchContaining` - **Embedded Kafka**: Run Kafka tests without Docker using `useEmbeddedKafka = true` - **Provided Instances**: PostgreSQL, MSSQL, MongoDB, Couchbase, Elasticsearch, Redis, and Kafka support connecting to external infrastructure - **Pause/Unpause**: PostgreSQL, MSSQL, MongoDB, Couchbase, Elasticsearch, Redis, and Kafka support container pause/unpause for resilience testing - **Response Headers**: WireMock mocks now support custom response headers --- ## New Features ### gRPC Support Stove now supports testing gRPC APIs with a fluent DSL. The new `grpc` system works with multiple gRPC providers including grpc-kotlin, Wire, and standard gRPC stubs. ```kotlin hl_lines="3 8" // Using typed channels (grpc-kotlin, Wire stubs) grpc { channel { val response = sayHello(HelloRequest(name = "World")) response.message shouldBe "Hello, World!" } } // Using Wire clients grpc { wireClient { val response = SayHello().execute(HelloRequest(name = "World")) response.message shouldBe "Hello, World!" } } ``` All streaming types work naturally with Kotlin coroutines: ```kotlin grpc { channel { // Server streaming serverStream(request).collect { response -> // assertions on each response } // Client streaming val response = clientStream(flow { emit(request1); emit(request2) }) // Bidirectional streaming bidiStream(requestFlow).collect { response -> // assertions } } } ``` **Add the dependency:** ```kotlin testImplementation("com.trendyol:stove-testing-e2e-grpc:$version") ``` --- ### WebSocket Testing The HTTP system now supports WebSocket connections for testing real-time communication: ```kotlin hl_lines="2 7" http { webSocket("/chat") { session -> session.send("Hello!") val response = session.receiveText() response shouldBe "Echo: Hello!" } } // With authentication http { webSocket( uri = "/secure-chat", headers = mapOf("X-Custom-Header" to "value"), token = "jwt-token".some() ) { session -> session.send("Authenticated message") } } // Collect multiple messages http { webSocketExpect("/notifications") { session -> val messages = session.collectTexts(count = 3) messages.size shouldBe 3 } } ``` Available methods: - `webSocket` - Establish connection and interact - `webSocketExpect` - Assertion-focused testing - `webSocketRaw` - Direct access to underlying Ktor session --- ### Partial Mocking for WireMock New partial matching methods allow mocking requests by matching only specific fields in the request body: ```kotlin hl_lines="4 12" wiremock { // Match requests containing specific fields (ignores extra fields) mockPostContaining( url = "/api/orders", requestContaining = mapOf( "productId" to 123, "order.customer.id" to "cust-456" // Dot notation for nested fields ), statusCode = 201, responseBody = OrderResponse(id = "order-1").some() ) } ``` **Features:** - **AND logic**: All specified fields must match - **Dot notation**: Access nested fields like `"order.customer.id"` - **Partial objects**: Nested objects match if they contain at least the specified fields - **Methods**: `mockPostContaining`, `mockPutContaining`, `mockPatchContaining` --- ### Embedded Kafka Mode Run Kafka tests without Docker containers using embedded Kafka: ```kotlin hl_lines="4 5" kafka { KafkaSystemOptions( useEmbeddedKafka = true, // No container needed configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } ``` This is ideal for: - Self-contained integration tests - Faster test startup - Environments without Docker access --- ## Improvements ### Provided Instances (Testcontainer-less Mode) The following components now support connecting to externally managed infrastructure using the `provided()` companion function: | Component | Provided Instance Support | |---------------|:-------------------------:| | PostgreSQL | ✅ | | MSSQL | ✅ | | MongoDB | ✅ | | Couchbase | ✅ | | Elasticsearch | ✅ | | Redis | ✅ | | Kafka | ✅ | **PostgreSQL example:** ```kotlin postgresql { PostgresqlOptions.provided( host = "external-db.example.com", port = 5432, databaseName = "testdb", username = "user", password = "pass", runMigrations = true, cleanup = { client -> client.execute("TRUNCATE users") }, configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) } ``` **Kafka example:** ```kotlin kafka { KafkaSystemOptions.provided( bootstrapServers = "kafka.example.com:9092", runMigrations = true, cleanup = { admin -> admin.deleteTopics(listOf("orders")) }, configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } ``` This is useful for: - CI/CD pipelines with shared infrastructure - Reducing startup time by reusing existing instances - Lower memory/CPU usage by avoiding container overhead --- ### Pause/Unpause Containers PostgreSQL, MSSQL, MongoDB, Couchbase, Elasticsearch, Redis, and Kafka now support pausing and unpausing containers for testing resilience scenarios: ```kotlin postgresql { // Pause to simulate network issues pause() // Your application should handle the connection failure http { get("/health") { it.status shouldBe 503 } } // Unpause to restore connectivity unpause() } ``` --- ### Response Headers in WireMock WireMock now supports custom response headers: ```kotlin wiremock { mockGet( url = "/api/users/123", statusCode = 200, responseBody = user.some(), responseHeaders = mapOf( "X-Request-Id" to "req-123", "X-Rate-Limit-Remaining" to "99" ) ) } ``` --- ### Documentation Improvements - Comprehensive documentation for all components - Updated examples matching actual API signatures - Added component feature matrix showing migration, cleanup, and provided instance support - FAQ section with common questions and answers --- ## Dependency Updates - Kotlin 2.0.x - Kotest 6.0.0.Mx - Koin 4.x - Arrow 2.x - Testcontainers 2.x - Ktor 3.x - Various other dependency updates for security and compatibility --- ## Migration Guide ### From 0.18.x This release is backward compatible. New features are opt-in: | Feature | How to Enable | |---------|---------------| | gRPC testing | Add `stove-testing-e2e-grpc` dependency | | WebSocket testing | Use `http { webSocket("/path") { ... } }` | | Embedded Kafka | Set `useEmbeddedKafka = true` in `KafkaSystemOptions` | | Provided instances | Use `SystemOptions.provided(...)` instead of `SystemOptions(...)` | | Pause/Unpause | Call `system.pause()` and `system.unpause()` on container-based systems | | Partial WireMock mocking | Use `mockPostContaining`, `mockPutContaining`, `mockPatchContaining` | | Response headers | Pass `responseHeaders = mapOf(...)` to WireMock mock methods | ### Breaking Changes None in this release. --- ## Full Changelog See the [GitHub Releases](https://github.com/Trendyol/stove/releases) page for the complete list of commits and contributors. --- ## Contributors Thanks to all contributors who made this release possible! --- ## Getting Started ```kotlin dependencies { testImplementation("com.trendyol:stove-testing-e2e:0.19.0") testImplementation("com.trendyol:stove-spring-testing-e2e:0.19.0") // or ktor, micronaut // Add component-specific dependencies as needed testImplementation("com.trendyol:stove-testing-e2e-rdbms-postgres:0.19.0") testImplementation("com.trendyol:stove-testing-e2e-kafka:0.19.0") testImplementation("com.trendyol:stove-testing-e2e-grpc:0.19.0") // NEW } ``` For snapshot versions, add the snapshot repository: ```kotlin repositories { maven("https://central.sonatype.com/repository/maven-snapshots") } ``` ================================================ FILE: docs/release-notes/0.20.0.md ================================================ # 0.20.0 **Release Date:** January 2026 This release introduces simplified module names, a new BOM, and a comprehensive test reporting system: - **Simplified Module Names**: All modules renamed to remove `testing-e2e` suffix for cleaner artifact names - **New BOM (Bill of Materials)**: Centralized version management via `stove-bom` - **Package Structure Simplification**: Package names simplified from `com.trendyol.stove.testing.e2e.*` to `com.trendyol.stove.*` - **Test Reporting System**: Comprehensive reporting that tracks all actions and assertions during test execution - **Spring Boot 4.x Support**: Full support for Spring Boot 4.x and Spring Kafka 4.x - **Unified Spring Modules**: Single modules that work across Spring Boot 2.x, 3.x, and 4.x - **New Bean Registration DSL**: `stoveSpring4xRegistrar` for Spring Boot 4.x (since `BeanDefinitionDsl` is deprecated) - **Runtime Version Checks**: Clear error messages when Spring Boot/Kafka is missing from classpath - **Ktor DI Flexibility**: Support for Koin, Ktor-DI, or custom resolvers - **Generic Type Resolution**: `using>` now works correctly with full generic type preservation --- ## New Features ### Test Reporting System Stove now includes a built-in reporting system that captures everything that happens during your tests. When a test fails, you get a detailed report showing exactly what happened, making debugging much easier. **Key capabilities:** - **Automatic tracking** of all system interactions (HTTP, Kafka, database, WireMock, gRPC) - **Test failure enrichment** with detailed execution reports embedded in test output - **System snapshots** showing internal state (Kafka messages, WireMock stubs) at the time of failure - **Multiple renderers** - human-readable console output or machine-readable JSON - **Framework integration** with both Kotest and JUnit - **Stack trace preservation** - original stack traces are preserved in test failures #### Quick Start - Kotest Add the extension dependency (optional but recommended): ```kotlin hl_lines="3" dependencies { testImplementation("com.trendyol:stove-extensions-kotest") } ``` Then configure: ```kotlin hl_lines="4 8 11" import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove class TestConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { /* your configuration */ } .run() } override suspend fun afterProject() { Stove.stop() } } ``` #### Quick Start - JUnit Add the extension dependency (optional but recommended): ```kotlin hl_lines="3" dependencies { testImplementation("com.trendyol:stove-extensions-junit") } ``` Then configure: ```kotlin hl_lines="4 8" import com.trendyol.stove.extensions.junit.StoveJUnitExtension import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(StoveJUnitExtension::class) class MyE2ETest { // your tests } ``` The JUnit extension works with both JUnit 5 and 6 since they share the Jupiter API. #### Configuration ```kotlin hl_lines="3 5" Stove { reporting { enabled() // Enable reporting (default: true) dumpOnFailure() // Dump report when tests fail (default: true) failureRenderer(PrettyConsoleRenderer) // Set the renderer } } ``` #### Example Output When a test fails, you'll see output like: ``` ╔══════════════════════════════════════════════════════════════════════════════╗ ║ STOVE TEST EXECUTION REPORT ║ ║ Test: should save the product ║ ║ Status: FAILED ║ ╠══════════════════════════════════════════════════════════════════════════════╣ ║ 14:47:38.215 ✓ PASSED [HTTP] POST /api/products ║ ║ Input: {"id":1234,"name":"Test Product"} ║ ║ ║ ║ 14:47:38.341 ✗ FAILED [Kafka] shouldBePublished ║ ║ Expected: Message matching condition within 5s ║ ║ Actual: No matching message found ║ ║ Error: GOT A TIMEOUT: While expecting the publish of 'ProductCreatedEvent'║ ╠══════════════════════════════════════════════════════════════════════════════╣ ║ SYSTEM SNAPSHOTS ║ ║ ┌─ KAFKA ────────────────────────────────────────────────────────────────────║ ║ Consumed: 0 ║ ║ Produced: 1 ║ ║ State Details: ║ ║ produced: 1 item(s) ║ ║ [0] topic: product-events, value: {"id":1234,"name":"Test Product"} ║ ╚══════════════════════════════════════════════════════════════════════════════╝ ``` #### Available Renderers - **PrettyConsoleRenderer** (default) - Colorized, box-drawing output for terminals - **JsonReportRenderer** - Machine-readable JSON for CI/CD integration See the [Reporting documentation](../Components/13-reporting.md) for full details. --- ### Spring Boot 4.x Support Stove now fully supports Spring Boot 4.x and Spring Kafka 4.x. The existing `stove-spring` and `stove-spring-kafka` modules work with all Spring Boot versions (2.x, 3.x, and 4.x). **Dependencies remain the same:** ```kotlin testImplementation("com.trendyol:stove-spring:0.20.0") testImplementation("com.trendyol:stove-spring-kafka:0.20.0") ``` --- ### New Bean Registration DSL for Spring Boot 4.x Spring Boot 4.x deprecates `BeanDefinitionDsl` (`beans { }` DSL). Stove provides new extension functions for cleaner bean registration: **Spring Boot 2.x / 3.x - use `addTestDependencies`:** ```kotlin import com.trendyol.stove.addTestDependencies springBoot( runner = { params -> runApplication(args = params) { addTestDependencies { bean>() bean { MyServiceImpl() } } } } ) ``` **Spring Boot 4.x - use `addTestDependencies4x`:** ```kotlin import com.trendyol.stove.addTestDependencies4x springBoot( runner = { params -> runApplication(args = params) { addTestDependencies4x { registerBean>(primary = true) registerBean { MyServiceImpl() } } } } ) ``` **Alternative: Using `addInitializers` directly:** ```kotlin // Spring Boot 2.x / 3.x addInitializers(stoveSpringRegistrar { bean() }) // Spring Boot 4.x addInitializers(stoveSpring4xRegistrar { registerBean() }) ``` **Key differences for 4.x:** - Use `registerBean()` instead of `bean()` - Use `registerBean(primary = true)` for primary beans - No `ref()` function - use constructor injection instead --- ### Ktor DI Flexibility Stove's Ktor module now supports multiple dependency injection systems. Previously, Koin was required. Now you can use: 1. **Koin** (existing support) 2. **Ktor-DI** (new built-in support) 3. **Custom resolver** (any DI framework) Both Koin and Ktor-DI are now `compileOnly` dependencies - you bring your preferred DI system. **Using Koin:** ```kotlin dependencies { testImplementation("io.insert-koin:koin-ktor:$koinVersion") } // In your test setup bridge() // Auto-detects Koin ``` **Using Ktor-DI:** ```kotlin dependencies { testImplementation("io.ktor:ktor-server-di:$ktorVersion") } // In your test setup bridge() // Auto-detects Ktor-DI ``` **Using a Custom Resolver:** For any other DI framework (Kodein, Dagger, manual, etc.): ```kotlin bridge { application, type -> // Your custom resolution logic - type is KType preserving generics myDiContainer.resolve(type) } ``` --- ### Generic Type Resolution in Bridge System The `using` function now properly preserves generic type information, allowing you to resolve types like `List`: ```kotlin // Register multiple implementations provide> { listOf(StripePaymentService(), PayPalPaymentService()) } // Resolve with full generic type preserved stove { using> { forEach { service -> service.pay(order) } } } ``` This works by using `KType` instead of `KClass` internally, which preserves generic type parameters that would otherwise be lost due to JVM type erasure. **For custom BridgeSystem implementations:** Override `getByType(type: KType)` to support generic types. The default implementation falls back to `get(klass: KClass)`. --- ### Ktor Test Dependency Registration Unlike Spring Boot's unified `addTestDependencies`, Ktor test dependency registration differs by DI framework: **Koin:** ```kotlin // In your app - accept test modules fun run(args: Array, testModules: List = emptyList()): Application { return embeddedServer(Netty, port = args.getPort()) { install(Koin) { modules(appModule, *testModules.toTypedArray()) } }.start(wait = false).application } // In tests - pass test modules with overrides ktor(runner = { params -> MyApp.run(params, testModules = listOf( module { single(override = true) { FixedTimeProvider() } } )) }) ``` **Ktor-DI:** ```kotlin // In your app - accept test dependencies fun run(args: Array, testDeps: (DependencyRegistrar.() -> Unit)? = null): Application { return embeddedServer(Netty, port = args.getPort()) { install(DI) { dependencies { provide { MyServiceImpl() } testDeps?.invoke(this) // Later provides override earlier ones } } }.start(wait = false).application } // In tests - pass test overrides ktor(runner = { params -> MyApp.run(params) { provide { FixedTimeProvider() } } }) ``` --- ### Runtime Version Checks When Spring Boot, Spring Kafka, or Ktor DI is missing from the classpath, Stove now provides clear error messages: ``` ═══════════════════════════════════════════════════════════════════════════════ Spring Boot Not Found on Classpath! ═══════════════════════════════════════════════════════════════════════════════ stove-spring requires Spring Boot to be on your classpath. Spring Boot is declared as a 'compileOnly' dependency, so you must add it to your project. Add one of the following to your build.gradle.kts: For Spring Boot 2.x: testImplementation("org.springframework.boot:spring-boot-starter:2.7.x") For Spring Boot 3.x: testImplementation("org.springframework.boot:spring-boot-starter:3.x.x") For Spring Boot 4.x: testImplementation("org.springframework.boot:spring-boot-starter:4.x.x") ═══════════════════════════════════════════════════════════════════════════════ ``` --- ## Migration Guide ### From 0.19.x to 0.20.0 This is a **breaking change release**. Follow these steps to migrate: #### 1. Update Module Names (Required) See the [Breaking Changes - Module and Package Renaming](#module-and-package-renaming) section above for detailed migration steps and regex patterns. **Quick Summary:** - Update all artifact names in build files - Replace `stove-testing-e2e` → `stove` - Replace `stove-*-testing-e2e` → `stove-*` - Update all package imports from `com.trendyol.stove.testing.e2e.*` → `com.trendyol.stove.*` #### 2. Test Framework Extensions (Optional) Test framework extensions are now in separate modules. They're optional but recommended for better failure reporting. Add the one that matches your test framework: ```kotlin dependencies { // For Kotest testImplementation("com.trendyol:stove-extensions-kotest") // OR for JUnit 5/6 testImplementation("com.trendyol:stove-extensions-junit") } ``` Update your imports: ```kotlin // Kotest import com.trendyol.stove.extensions.kotest.StoveKotestExtension // JUnit import com.trendyol.stove.extensions.junit.StoveJUnitExtension ``` #### 3. Use the New BOM (Recommended) The new BOM simplifies version management: ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:0.20.0")) // No versions needed - managed by BOM testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") testImplementation("com.trendyol:stove-kafka") } ``` ### From 0.19.x (Other Changes) #### Test Extensions for Better Reporting The reporting extensions are optional but make debugging much easier. Add the one for your test framework: **Kotest:** ```kotlin // Add dependency: testImplementation("com.trendyol:stove-extensions-kotest") class TestConfig : AbstractProjectConfig() { override val extensions = listOf(StoveKotestExtension()) } ``` **JUnit:** ```kotlin @ExtendWith(StoveJUnitExtension::class) class MyE2ETest { } ``` #### For Spring Boot 2.x and 3.x Users **If using `BaseApplicationContextInitializer`:** Migrate to `addTestDependencies` (see Breaking Changes below). **If using `beans { }` directly:** Your existing code continues to work. Optionally, use the new cleaner API: ```kotlin // Old way (still works) addInitializers(beans { bean() }) // New way (recommended) addTestDependencies { bean() } ``` #### For Spring Boot 4.x Users (New!) Spring Boot 4.x is newly supported in this release. Use `addTestDependencies4x`: ```kotlin import com.trendyol.stove.addTestDependencies4x springBoot( runner = { params -> runApplication(args = params) { addTestDependencies4x { registerBean>(primary = true) registerBean { MyServiceImpl() } } } } ) ``` Note: The `beans { }` DSL from Spring is deprecated in 4.x, which is why Stove provides `addTestDependencies4x` with `registerBean()`. --- ## Breaking Changes ### Module and Package Renaming **⚠️ BREAKING:** All Stove modules have been renamed to simplify artifact names and package structure. This is a breaking change that requires updates to your build files and source code. #### Module Name Changes | Old Artifact Name | New Artifact Name | |------------------|-------------------| | `stove-testing-e2e` | `stove` | | `stove-testing-e2e-kafka` | `stove-kafka` | | `stove-testing-e2e-http` | `stove-http` | | `stove-testing-e2e-couchbase` | `stove-couchbase` | | `stove-testing-e2e-elasticsearch` | `stove-elasticsearch` | | `stove-testing-e2e-grpc` | `stove-grpc` | | `stove-testing-e2e-mongodb` | `stove-mongodb` | | `stove-testing-e2e-redis` | `stove-redis` | | `stove-testing-e2e-wiremock` | `stove-wiremock` | | `stove-testing-e2e-rdbms-postgres` | `stove-postgres` | | `stove-testing-e2e-rdbms-mssql` | `stove-mssql` | | `stove-spring-testing-e2e` | `stove-spring` | | `stove-spring-testing-e2e-kafka` | `stove-spring-kafka` | | `stove-ktor-testing-e2e` | `stove-ktor` | | `stove-micronaut-testing-e2e` | `stove-micronaut` | #### Package Name Changes All packages have been simplified: - `com.trendyol.stove.testing.e2e.*` → `com.trendyol.stove.*` - `com.trendyol.stove.testing.e2e.rdbms.postgres` → `com.trendyol.stove.postgres` - `com.trendyol.stove.testing.e2e.rdbms.mssql` → `com.trendyol.stove.mssql` - `com.trendyol.stove.testing.e2e.standalone.kafka` → `com.trendyol.stove.kafka` #### Migration Guide **Step 1: Update Build Files (Gradle/Maven)** **Recommended: Use the new BOM for version management:** ```kotlin // build.gradle.kts dependencies { // Import BOM testImplementation(platform("com.trendyol:stove-bom:$version")) // Core and framework (no version needed - managed by BOM) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") // or stove-ktor, stove-micronaut // Components (no version needed) testImplementation("com.trendyol:stove-kafka") testImplementation("com.trendyol:stove-postgres") // ... other components } ``` **Or without BOM (specify versions explicitly):** ```kotlin dependencies { testImplementation("com.trendyol:stove:$version") testImplementation("com.trendyol:stove-spring:$version") testImplementation("com.trendyol:stove-kafka:$version") testImplementation("com.trendyol:stove-postgres:$version") } ``` **Step 2: Update Package Imports in Source Code** All import statements need to be updated. Use the regex patterns below for automated migration. **Step 3: Automated Migration with Regex** Use these regex patterns in your IDE's find-and-replace (with regex enabled): **For Build Files (Gradle/Maven):** 1. **Replace artifact names in dependencies:** ```regex Find: com\.trendyol:stove-testing-e2e(?!-) Replace: com.trendyol:stove ``` 2. **Replace component artifacts:** ```regex Find: com\.trendyol:stove-testing-e2e-([a-z-]+) Replace: com.trendyol:stove-$1 ``` 3. **Replace RDBMS artifacts:** ```regex Find: com\.trendyol:stove-testing-e2e-rdbms-(postgres|mssql) Replace: com.trendyol:stove-$1 ``` 4. **Replace starter artifacts:** ```regex Find: com\.trendyol:stove-(spring|ktor|micronaut)-testing-e2e(-kafka)? Replace: com.trendyol:stove-$1$2 ``` **For Source Code (Kotlin/Java):** 1. **Replace package imports:** ```regex Find: import com\.trendyol\.stove\.testing\.e2e\.(.*) Replace: import com.trendyol.stove.$1 ``` 2. **Replace fully qualified names:** ```regex Find: com\.trendyol\.stove\.testing\.e2e\.rdbms\.(postgres|mssql) Replace: com.trendyol.stove.$1 ``` 3. **Replace standalone.kafka:** ```regex Find: com\.trendyol\.stove\.testing\.e2e\.standalone\.kafka Replace: com.trendyol.stove.kafka ``` 4. **Replace remaining testing.e2e references:** ```regex Find: com\.trendyol\.stove\.testing\.e2e\.([a-z.]+) Replace: com.trendyol.stove.$1 ``` **Step 4: Manual Verification** After automated replacement, verify: 1. **Build files compile** - Run `./gradlew build` or `mvn compile` 2. **Imports resolve** - Check that all imports are valid 3. **Tests compile** - Run `./gradlew compileTestKotlin` or `mvn test-compile` 4. **Tests pass** - Run your test suite **Example Migration** **Before (0.19.0):** ```kotlin // build.gradle.kts dependencies { testImplementation("com.trendyol:stove-testing-e2e:0.19.0") testImplementation("com.trendyol:stove-spring-testing-e2e:0.19.0") testImplementation("com.trendyol:stove-testing-e2e-kafka:0.19.0") testImplementation("com.trendyol:stove-testing-e2e-rdbms-postgres:0.19.0") } // Test code import com.trendyol.stove.testing.e2e.system.TestSystem import com.trendyol.stove.testing.e2e.kafka.kafka import com.trendyol.stove.testing.e2e.rdbms.postgres.postgresql ``` **After (0.20.0):** ```kotlin // build.gradle.kts dependencies { testImplementation(platform("com.trendyol:stove-bom:0.20.0")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") testImplementation("com.trendyol:stove-kafka") testImplementation("com.trendyol:stove-postgres") } // Test code import com.trendyol.stove.system.TestSystem import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql ``` **Migration Checklist** - [ ] Update all `build.gradle.kts` / `build.gradle` / `pom.xml` files - [ ] Replace all import statements in test source code - [ ] Replace all fully qualified package references - [ ] Update any documentation or scripts referencing old artifact names - [ ] Verify build compiles successfully - [ ] Run test suite to ensure everything works **Need Help?** If you encounter issues during migration: 1. Check the [Migration Guide](#migration-guide) section below 2. Review the [Getting Started](../getting-started.md) guide for updated examples 3. Open an issue on [GitHub](https://github.com/Trendyol/stove/issues) --- ### `BaseApplicationContextInitializer` Removed `BaseApplicationContextInitializer` has been removed. Migrate to `addTestDependencies`: **Before (0.19.0):** ```kotlin class TestSystemInitializer : BaseApplicationContextInitializer({ bean>() bean { MyServiceImpl() } }) // Usage runApplication(args = params) { addInitializers(TestSystemInitializer()) } ``` **After (0.20.0):** ```kotlin import com.trendyol.stove.addTestDependencies runApplication(args = params) { addTestDependencies { bean>() bean { MyServiceImpl() } } } ``` This is simpler - no need to create a separate class. --- ### HttpSystem: `getResponse` renamed to `getResponseBodiless` The `getResponse` method in `HttpSystem` has been renamed to `getResponseBodiless` to better reflect its purpose - it returns a response without parsing the body. **Before:** ```kotlin http { getResponse("/api/endpoint") { response -> response.status shouldBe 200 } } ``` **After:** ```kotlin http { getResponseBodiless("/api/endpoint") { response -> response.status shouldBe 200 } } ``` --- ## Notes ### BridgeSystem API Enhancement The `BridgeSystem` abstract class now has a new method `getByType(type: KType)` which is used by `resolve()` to support generic types. If you have a custom `BridgeSystem` implementation: - **No action required** if you only use simple types - the default implementation falls back to `get(klass: KClass)` - **Override `getByType(type: KType)`** if you want to support generic types like `List`, `Map`, etc. ```kotlin // Example for custom bridge override fun getByType(type: KType): D { // Use type.classifier for KClass // Use type.arguments for generic parameters return myDiFramework.resolve(type) } ``` ### Dead Letter Topic Naming Convention (Spring Kafka) Be aware that Spring Kafka changed the default DLT (Dead Letter Topic) naming convention between versions: | Spring Kafka Version | DLT Suffix | Example | |---------------------|------------|---------| | 2.x | `.DLT` | `my-topic.DLT` | | 3.x, 4.x | `-dlt` | `my-topic-dlt` | This is not a Stove change, but something to be aware of when writing Kafka tests across different Spring Kafka versions. ### Optional: Disable Reporting If you don't want the new reporting feature (not recommended), you can disable it: ```kotlin TestSystem { reportingEnabled(false) } ``` --- ## Dependency Updates - Spring Boot 4.x support (4.0.0+) - Spring Kafka 4.x support (4.0.0+) - Continued support for Spring Boot 2.7.x and 3.x - Continued support for Spring Kafka 2.9.x and 3.x --- ## Full Changelog See the [GitHub Releases](https://github.com/Trendyol/stove/releases) page for the complete list of commits and contributors. --- ## Contributors Thanks to all contributors who made this release possible! --- ## Getting Started ```kotlin dependencies { testImplementation("com.trendyol:stove:0.20.0") testImplementation("com.trendyol:stove-spring:0.20.0") // Add component-specific dependencies as needed testImplementation("com.trendyol:stove-spring-kafka:0.20.0") } ``` For snapshot versions, add the snapshot repository: ```kotlin repositories { maven("https://central.sonatype.com/repository/maven-snapshots") } ``` ================================================ FILE: docs/release-notes/0.21.0.md ================================================ # 0.21.0 **Released:** February 2026 This release introduces:
  • Tracing: See the full execution trace of your application when a test fails: every controller, database query, Kafka message, and HTTP call with timing and failure points
  • gRPC Mocking: Mock external gRPC services in your tests with a type-safe DSL
  • MySQL Support: New stove-mysql module for testing against MySQL databases
  • WireMock Test Scoping: WireMock snapshots are now scoped to the current test for cleaner failure reports
  • Dynamic Port Allocation: WireMock now defaults to port = 0 and gRPC Mock supports it, no more port conflicts in CI
  • Migration Type Aliases: PostgresqlMigration, MongodbMigration, etc. instead of verbose DatabaseMigration<XyzContext>
  • Elasticsearch 9 Support: Adapted to work with Elasticsearch 9.x
  • Spring Showcase Recipe: A comprehensive example project demonstrating all Stove features together
--- ## New Features ### Tracing When a test fails, you no longer have to guess what happened inside your application. Stove captures the entire call chain: every controller method, database query, Kafka message, and HTTP call, and displays it as a trace tree in the failure report: ``` ═══════════════════════════════════════════════════════════════════════════════ EXECUTION TRACE (Call Chain) ═══════════════════════════════════════════════════════════════════════════════ ✓ POST (377ms) ✓ POST /api/product/create (361ms) ✓ ProductController.create (141ms) ✓ ProductCreator.create (0ms) ✓ KafkaProducer.send (137ms) ✓ orders.created publish (81ms) ✗ orders.created process (82ms) ← FAILURE POINT ``` Setup takes two steps: 1. Enable in your Stove config: ```kotlin hl_lines="3-4" Stove() .with { tracing { enableSpanReceiver() } } ``` 2. Attach the OpenTelemetry agent in your build. Copy [`StoveTracingConfiguration.kt`](https://github.com/Trendyol/stove/blob/main/buildSrc/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingConfiguration.kt) to your project's `buildSrc/src/main/kotlin/` directory, then add to your `build.gradle.kts`: ```kotlin import com.trendyol.stove.gradle.stoveTracing stoveTracing { serviceName = "my-service" } ``` !!! tip "Gradle Plugin available since 0.21.2" Starting with [0.21.2](0.21.2.md), a standalone Gradle plugin is available that eliminates the need to copy this file. See the [0.21.2 release notes](0.21.2.md) for details. Everything else is automatic. Trace headers are injected into HTTP, Kafka, and gRPC calls, spans are collected and correlated, and failure reports are enriched with the trace tree. A validation DSL is also available for asserting on the execution flow: ```kotlin hl_lines="2 3 4" tracing { shouldContainSpan("OrderService.processOrder") shouldNotHaveFailedSpans() executionTimeShouldBeLessThan(500.milliseconds) } ``` See the [Tracing documentation](../Components/15-tracing.md) for full details. --- ### gRPC Mocking New `stove-grpc-mock` module for mocking external gRPC services in your tests. This lets you test gRPC client code without running the actual upstream services. ```kotlin dependencies { testImplementation("com.trendyol:stove-grpc-mock:$stoveVersion") } ``` Configure in your Stove setup: ```kotlin hl_lines="2" grpcMock { GrpcMockSystemOptions(port = 0) // Dynamic port allocation } ``` Stub responses with a type-safe DSL: ```kotlin hl_lines="2-3" grpcMock { mockUnary( FraudDetectionServiceGrpc.getCheckFraudMethod(), response = FraudCheckResponse.newBuilder() .setIsFraud(false) .setScore(0.1) .build() ) } ``` Supports unary, server streaming, and conditional matching. See the [gRPC Mocking documentation](../Components/14-grpc-mock.md) for full details. --- ### MySQL Support New `stove-mysql` module with the same familiar DSL as PostgreSQL and MSSQL: ```kotlin dependencies { testImplementation("com.trendyol:stove-mysql:$stoveVersion") } ``` ```kotlin Stove() .with { mysql { MysqlOptions( databaseName = "mydb", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ) } } ``` Supports migrations, `shouldQuery`, `shouldExecute`, and all the database operations you'd expect. See the [MySQL documentation](../Components/16-mysql.md) for details. --- ### WireMock Test Scoping WireMock snapshots in failure reports are now scoped to the current test. Previously, all registered stubs and requests across the entire test suite would appear in the snapshot. Now you only see stubs and requests relevant to the test that failed, making failure reports much easier to read. --- ### Dynamic Port Allocation Both WireMock and gRPC Mock now support dynamic port allocation with `port = 0`, which prevents port conflicts when running tests in parallel on CI. **WireMock** now defaults to `port = 0`. You no longer need to pick a port: ```kotlin wiremock { WireMockSystemOptions( configureExposedConfiguration = { cfg -> listOf("external-apis.inventory.url=${cfg.baseUrl}") } ) } ``` **gRPC Mock** also supports `port = 0`: ```kotlin grpcMock { GrpcMockSystemOptions(port = 0) } ``` In both cases, the actual port is exposed via `configureExposedConfiguration` so your application receives the correct URL. --- ### Spring Showcase Recipe A new comprehensive recipe at `recipes/jvm/kotlin-recipes/spring-showcase/` demonstrates all Stove features working together in a realistic Spring Boot application: - HTTP endpoints with PostgreSQL - Kafka producers and consumers - gRPC server and client with mocked upstream - WireMock for external HTTP APIs - Tracing - Database migrations - db-scheduler integration This is the best starting point for understanding how Stove fits into a real project. --- ### Migration Type Aliases Each module that supports migrations now provides a convenient type alias, so you no longer need to remember `DatabaseMigration` and similar verbose signatures: ```kotlin // Before class CreateUsersTable : DatabaseMigration { ... } // After class CreateUsersTable : PostgresqlMigration { ... } ``` Available aliases: | Module | Type Alias | |---------------------|--------------------------| | stove-postgres | `PostgresqlMigration` | | stove-mysql | `MySqlMigration` | | stove-mssql | `MsSqlMigration` | | stove-mongodb | `MongodbMigration` | | stove-couchbase | `CouchbaseMigration` | | stove-elasticsearch | `ElasticsearchMigration` | | stove-redis | `RedisMigration` | | stove-kafka | `KafkaMigration` | The generic `DatabaseMigration` interface remains fully supported. The aliases are purely additive. --- ## Improvements ### Elasticsearch 9 Support `stove-elasticsearch` now works with Elasticsearch 9.x (specifically 9.3.0). The module adapts to the updated client API automatically. ### Improved Failure Reports - Exception details (type, message, stack trace) are now extracted from OpenTelemetry spans and displayed in trace trees - Console renderer output is cleaner and better formatted - Kafka report entries now include all relevant state ### Tracing Configuration Cache Compatibility The Stove Tracing Gradle plugin and the `stoveTracing` buildSrc helper are both fully compatible with Gradle configuration cache, so builds with `--configuration-cache` work correctly. ### Non-ASCII Test ID Support Trace context test IDs now handle non-ASCII characters (e.g., Japanese, Korean) correctly by normalizing to ASCII with hash suffixes for uniqueness. This ensures consistent behavior when test names use non-Latin scripts. ### BridgeSystem Suspended `BridgeSystem` methods are now `suspend` functions, allowing proper coroutine support in bridge implementations. --- ## Bug Fixes - **HTTP streaming**: Fixed a flow collection issue that could cause streaming responses to hang - **Kafka tests**: Fixed flaky test behavior in Kafka system tests - **Tracing configuration cache**: Fixed serialization issues when using Gradle configuration cache with the Stove Tracing plugin - **ASCII character handling**: Fixed edge cases in test ID sanitization for non-ASCII characters --- ## Dependency Updates - Elasticsearch 9.3.0 - Kafka (Confluent) 8.1.1 - Confluent Platform Kafka 8.0.3 - gRPC Java 1.79.0 - Protobuf 4.33.5 - OpenTelemetry 1.59.0 - Kotlin (latest patch) - Ktor 3.4.0 - Flyway 12.x - HikariCP 7.x - Various Spring, Quarkus, and Micronaut updates --- ## Migration Guide ### From 0.20.x to 0.21.0 This is a non-breaking release. All existing APIs remain compatible. #### New Features to Opt Into **Tracing**: Add `stove-tracing` to your dependencies and follow the [setup guide](../Components/15-tracing.md). **gRPC Mocking**: Add `stove-grpc-mock` to your dependencies if you need to mock external gRPC services. See the [gRPC Mocking documentation](../Components/14-grpc-mock.md). **MySQL**: Add `stove-mysql` if you're testing against MySQL. See the [MySQL documentation](../Components/16-mysql.md). #### Test Framework Extensions `StoveKotestExtension` (`stove-extensions-kotest`) and `StoveJUnitExtension` (`stove-extensions-junit`) are separate packages that must be on your classpath. **Kotest** requires **6.1.3** or later; **JUnit** requires **Jupiter 6.x** if possible. In Kotest 6.x, `AbstractProjectConfig` is no longer auto-scanned. Add a `kotest.properties` file in your test resources (e.g. `src/test-e2e/resources/kotest.properties`): ```properties kotest.framework.config.fqn=com.myapp.e2e.TestConfig ``` Set the value to the fully qualified name of your `AbstractProjectConfig` class. See the [Getting Started guide](../getting-started.md#step-3-create-test-configuration) for full details. #### Recommended Updates - If using Elasticsearch, verify compatibility with Elasticsearch 9.x - If using the Stove Tracing plugin or `stoveTracing`, no changes needed. Configuration cache compatibility is automatic - Consider switching `GrpcMockSystemOptions` to `port = 0` for CI-friendly dynamic port allocation --- ## Getting Started ```kotlin hl_lines="2 4 5 6 7" dependencies { testImplementation(platform("com.trendyol:stove-bom:0.21.0")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-spring") testImplementation("com.trendyol:stove-tracing") testImplementation("com.trendyol:stove-extensions-kotest") // Add components as needed } ``` For snapshot versions: ```kotlin hl_lines="2" repositories { maven("https://central.sonatype.com/repository/maven-snapshots") } ``` ================================================ FILE: docs/release-notes/0.21.2.md ================================================ # 0.21.2 **Released:** February 2026 This release introduces the **Stove Tracing Gradle Plugin**, a standalone plugin that replaces the copy-paste buildSrc approach for configuring OpenTelemetry tracing in your tests. --- ## New Features ### Stove Tracing Gradle Plugin The tracing build configuration is now available as a proper Gradle plugin, published to **Maven Central**. ```kotlin plugins { id("com.trendyol.stove.tracing") version "0.21.2" } stoveTracing { serviceName.set("my-service") } ``` The plugin handles everything: downloading the OpenTelemetry Java Agent, configuring JVM arguments, attaching the agent to your test tasks, and dynamically assigning ports so parallel test runs don't conflict. **Why a plugin?** - No need to copy `StoveTracingConfiguration.kt` into your `buildSrc` - Version updates come through normal dependency management - Consistent `stoveTracing { }` DSL with Gradle's `Property` conventions - Published alongside all other Stove artifacts **Availability:** | Channel | Coordinates | |---------|-------------| | Maven Central | `com.trendyol:stove-tracing-gradle-plugin` | | Maven Central Snapshots | `com.trendyol:stove-tracing-gradle-plugin` (snapshot versions) | Add `mavenCentral()` to your `pluginManagement` repositories. For snapshot versions, also add the snapshot repository: ```kotlin // settings.gradle.kts pluginManagement { repositories { mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots") // only for snapshots gradlePluginPortal() } } ``` See the [Tracing documentation](../Components/15-tracing.md#gradle-plugin) for full configuration options. --- ## Breaking Changes ### `configureStoveTracing` renamed to `stoveTracing` The buildSrc copy-paste function has been renamed from `configureStoveTracing` to `stoveTracing` for consistency with the plugin DSL. If you are using the buildSrc approach, update your build scripts: ```kotlin // Before import com.trendyol.stove.gradle.configureStoveTracing configureStoveTracing { serviceName = "my-service" } // After import com.trendyol.stove.gradle.stoveTracing stoveTracing { serviceName = "my-service" } ``` If you are migrating to the plugin, the DSL name is the same (`stoveTracing { }`), but properties use Gradle's `Property` API: ```kotlin // buildSrc style stoveTracing { serviceName = "my-service" testTaskNames = listOf("integrationTest") } // Plugin style stoveTracing { serviceName.set("my-service") testTaskNames.set(listOf("integrationTest")) } ``` --- ## Migration Guide ### From 0.21.x to 0.21.2 #### Migrating to the Gradle plugin (recommended) 1. Remove `StoveTracingConfiguration.kt` from your `buildSrc/src/main/kotlin/` if you copied it 2. Apply the plugin in your `build.gradle.kts`: ```kotlin plugins { id("com.trendyol.stove.tracing") version "0.21.2" } stoveTracing { serviceName.set("my-service") } ``` #### Staying with buildSrc If you prefer to keep the buildSrc approach, rename the function call: ```kotlin // configureStoveTracing { ... } -> stoveTracing { ... } ``` No other changes are required. ================================================ FILE: docs/release-notes/0.22.2.md ================================================ # 0.22.0 – 0.22.2 **Released:** March 2026 This release introduces **Quarkus support** as a new framework starter, improves console reporting, and fixes Ktor DI detection. --- ## New Features ### Quarkus Support (0.22.0) New `stove-quarkus` module brings first-class support for testing Quarkus applications with Stove: ```kotlin dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove") testImplementation("com.trendyol:stove-quarkus") } ``` Configure in your Stove setup: ```kotlin Stove() .with { quarkus( runner = { params -> run(params) }, withParameters = listOf("quarkus.http.port=8080") ) } .run() ``` Quarkus support includes: - Real Quarkus application startup from your entrypoint - Full composition with Kafka, PostgreSQL, WireMock, HTTP assertions, and tracing See the [Quarkus documentation](../frameworks/quarkus.md) and the [quarkus-example](https://github.com/Trendyol/stove/tree/main/examples/quarkus-example) for details. --- ### Improved Console Reporting (0.22.0) The console report renderer has been rewritten using the [Mordant](https://github.com/ajalt/mordant) library, producing cleaner, prettier output with proper wrapping and formatting. Failure reports are now easier to read at a glance. --- ## Bug Fixes ### Ktor Auto DI Detection (0.22.2) Fixed a linkage error that could occur when Ktor's auto DI detection tried to load optional DI framework classes that weren't on the classpath. The detection now uses reflection-based availability checks before attempting typed runtime checks, preventing `NoClassDefFoundError` in projects that use only Koin or only Ktor-DI (but not both). This also removes the need for Koin-based Ktor projects to carry Ktor-DI as a transitive dependency. --- ## Dependency Updates - Kotlin 2.3.20 - Wire 6.0.0 - Arrow 2.2.2.1 - Ktor 3.4.1 - Kotest 6.1.7 - Koin 4.2.0 - Jackson 2.21.1 - MongoDB Driver 5.6.4 - Elasticsearch 9.3.3 - Confluent Kafka Streams Serde 8.2.0 - Protobuf 4.34.0 - OpenTelemetry 1.60.1 - Micronaut 4.10.18 - Quarkus 3.34.0 - gRPC Java 1.80.0 - Spring Boot 3.5.11 / 4.0.3 - Spring Kafka 3.3.14 / 4.0.4 - Various other dependency updates --- ## Migration Guide ### From 0.21.2 to 0.22.x This is a non-breaking release. All existing APIs remain compatible. #### New Features to Opt Into **Quarkus**: Add `stove-quarkus` to your dependencies if you're testing a Quarkus application. See the [Quarkus documentation](../frameworks/quarkus.md). No other changes are required. ================================================ FILE: docs/release-notes/0.23.0.md ================================================ # 0.23.0 **Released:** March 2026 ## New Features ### Cassandra Support New `stove-cassandra` module for testing applications that use Apache Cassandra: ```kotlin dependencies { testImplementation("com.trendyol:stove-cassandra") } ``` Configure in your Stove setup: ```kotlin Stove() .with { cassandra { CassandraSystemOptions( keyspace = "my_keyspace", configureExposedConfiguration = { cfg -> listOf( "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", "spring.cassandra.local-datacenter=${cfg.datacenter}", "spring.cassandra.keyspace-name=${cfg.keyspace}" ) } ).migrations { register() } } }.run() ``` Includes: - CQL statement execution (`shouldExecute`) and query assertions (`shouldQuery`) - Prepared/bound statement support - Raw `CqlSession` access via `session()` - Keyspace-aware session management — sessions are created without a keyspace first, then rebound after migrations create it - Migrations via `CassandraMigration` type alias - Container pause/unpause for fault injection - Provided instance support via `CassandraSystemOptions.provided()` - Cleanup hooks See the [Cassandra documentation](../Components/17-cassandra.md) for full details. ### Dashboard — Local Observability Dashboard New `stove-dashboard` module and a companion CLI (`stove`) that gives you a real-time web dashboard for your e2e test runs. Add the dependency, apply the tracing Gradle plugin, and register in your Stove config: ```kotlin // build.gradle.kts plugins { id("com.trendyol.stove.tracing") version "" } dependencies { testImplementation(platform("com.trendyol:stove-bom:$version")) testImplementation("com.trendyol:stove-dashboard") testImplementation("com.trendyol:stove-tracing") } stoveTracing { serviceName.set("product-api") } ``` ```kotlin hl_lines="3" Stove() .with { dashboard { DashboardSystemOptions(appName = "product-api") } tracing { enableSpanReceiver() } // ... other systems }.run() ``` Start the CLI, run your tests, and open [http://localhost:4040](http://localhost:4040): - **Timeline** — every HTTP call, Kafka message, database query with inputs, outputs, expected vs. actual - **Trace** — distributed trace tree from OpenTelemetry spans, with exception details and stack traces - **Snapshots** — system state cards captured at test boundaries - **Kafka Explorer** — dedicated view with consumed/published/failed counts and expandable JSON payloads The dashboard emitter is fault-tolerant: non-blocking queue, auto-disables after 5 consecutive gRPC failures, never breaks your tests. If the CLI isn't running, nothing happens. Install the CLI: ```bash brew install Trendyol/trendyol-tap/stove ``` See the [Dashboard documentation](../Components/18-dashboard.md) for full details. --- ## Documentation - Comprehensive documentation improvements across all pages - Added Cassandra to provided instances, components index, and README - Fixed inaccurate Docker requirement claims — docs now clarify that Docker is only needed in container mode, not when using provided instances - Fixed incorrect `TestSystemInterceptor` references → `TestSystemKafkaInterceptor<*, *>` in Kafka docs - Expanded Ktor framework docs with DI auto-detection table and tabbed examples - Added Spring Boot 4.x `addTestDependencies4x` section - Added missing MySQL section to provided instances docs - Added 0.21.2 → 0.22.x migration notes to troubleshooting - Added Dashboard documentation with architecture, installation, configuration, and REST API reference --- ## Dependency Updates - Testcontainers 2.0.4 - Apache Cassandra Java Driver 4.19.2 (new) --- ## Migration Guide ### From 0.22.x to 0.23.0 This is a non-breaking release. All existing APIs remain compatible. #### New Features to Opt Into **Cassandra**: Add `stove-cassandra` to your dependencies if you're testing against Cassandra. See the [Cassandra documentation](../Components/17-cassandra.md). **Dashboard**: Add `stove-dashboard` and `stove-tracing` to your dependencies, apply the `com.trendyol.stove.tracing` Gradle plugin, and install the CLI via Homebrew (`brew install Trendyol/trendyol-tap/stove`) or the shell script. See the [Dashboard documentation](../Components/18-dashboard.md). ================================================ FILE: docs/release-notes/0.24.0.md ================================================ # 0.24.0 **Released:** May 2026 This release makes Stove a polyglot end-to-end testing framework and rounds out the black-box story. Go applications are now first-class citizens — runnable as host processes or Docker containers, with Kafka, OpenTelemetry, and code coverage support out of the box. `stove-container` works for any image, regardless of language. `providedApplication()` unlocks smoke-testing already-deployed apps. Multiple instances of the same system type can now be registered with typed keys for cross-service verification. And the `stove` CLI gains an MCP server so AI agents can triage failed runs through structured tools instead of raw logs. --- ## New Features ### `providedApplication()` — black-box smoke testing against deployed apps Stove no longer requires it to start the application. `providedApplication()` replaces the framework starter and points Stove at a **remote, already-deployed** app — staging, pre-prod, or any environment where the artifact is running independently. ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } postgresql { PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://staging-db:5432/myapp", host = "staging-db", port = 5432, configureExposedConfiguration = { emptyList() } ) } kafka { KafkaSystemOptions.provided( bootstrapServers = "staging-kafka:9092", configureExposedConfiguration = { emptyList() } ) } providedApplication { ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet( url = "https://staging.myapp.com/health" ) ) } }.run() ``` Includes: - Optional readiness check (`ReadinessStrategy.HttpGet` / `TcpPort` / `Probe` / `FixedDelay`) — Stove waits for the deployed app before running tests - Works with **any language or framework** — JVM or otherwise - `*.provided(...)` factory methods on systems (PostgreSQL, MySQL, MSSQL, Cassandra, MongoDB, Redis, Elasticsearch, Couchbase, Kafka, …) point Stove at an existing instance instead of a Testcontainer - `cleanup` lambdas on system options for managing test data on shared external infrastructure - No `Bridge` / `using {}` (no local DI container) — by design Use case: same Stove tests that drive your local Testcontainers-backed e2e suite can run as post-deployment smoke checks against staging — no new framework, no new DSL. See [Provided Application](../Components/19-provided-application.md). ### Multiple instances of the same system (keyed systems) Stove now supports registering **multiple instances of the same system type**, each identified by a typed key (`SystemKey`). Pass the key to both registration and validation DSLs. ```kotlin object OrderService : SystemKey object PaymentService : SystemKey object AppDb : SystemKey object AnalyticsDb : SystemKey Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "https://myapp.com") } // default httpClient(OrderService) { HttpClientSystemOptions(baseUrl = "https://order.internal") } // keyed httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "https://pay.internal") } // keyed postgresql(AppDb) { /* ... */ } postgresql(AnalyticsDb) { /* ... */ } providedApplication() }.run() ``` ```kotlin test("create order, verify across services and databases") { stove { http { /* default — your app */ } http(OrderService) { /* downstream order service */ } http(PaymentService) { /* downstream payment service */ } postgresql(AppDb) { /* app's database */ } postgresql(AnalyticsDb) { /* analytics database */ } } } ``` Supported across PostgreSQL, MySQL, MSSQL, MongoDB, Cassandra, Couchbase, Redis, Elasticsearch, HTTP, gRPC, Kafka, WireMock, gRPC Mock. Single-instance systems (Bridge, Tracing, Dashboard) and framework starters do not support keys — there is only one application under test. Default and keyed instances of the same type coexist independently. Keyed systems get distinguishable names in dashboard reports and traces (`HTTP [OrderService] > GET /api/orders/123`). Pairs naturally with `providedApplication()` for full black-box microservice integration testing across many services and shared databases. See [Multiple Systems](../Components/20-multiple-systems.md). ### `stove-process` — non-JVM applications as host processes New `stove-process` module starts any binary as the application under test. Works for any language; ships with a `goApp()` convenience for Go. ```kotlin dependencies { testImplementation("com.trendyol:stove-process") } ``` ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8090") } postgresql { PostgresqlOptions(...) } goApp( target = ProcessTarget.Server(port = 8090, portEnvVar = "APP_PORT"), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") } ) }.run() ``` For other languages, use `processApp { ProcessApplicationOptions(...) }` with an explicit command. Includes: - `ProcessTarget.Server` / `ProcessTarget.Worker` for HTTP servers vs. background workers - `ReadinessStrategy.HttpGet` / `TcpPort` / `Probe` / `FixedDelay` - `envMapper` and `argsMapper` DSLs to map Stove configs to env vars or CLI flags - Graceful shutdown via SIGTERM with configurable timeout - Background stdout/stderr forwarding See [Go Process Mode](../other-languages/go-process.md) and [Other Languages & Stacks](../other-languages/index.md). ### `stove-container` — applications as Docker images New `stove-container` module runs the AUT as a Docker image. Language-agnostic — Go, Python, Node.js, Rust, .NET, JVM, anything that ships in a container. Stove only needs an image tag; **building the image is your pipeline's job**, not Stove's. Use whatever your CI already produces, pull from a registry, or wire an optional local Gradle build task — all three work. ```kotlin dependencies { testImplementation("com.trendyol:stove-container") } ``` ```kotlin Stove().with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8090") } postgresql { PostgresqlOptions(...) } containerApp( image = System.getProperty("app.container.image"), // tag from CI / registry / local build target = ContainerTarget.Server( hostPort = 8090, internalPort = 8090, portEnvVar = "APP_PORT", bindHostPort = false ), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" }, configureContainer = { withNetworkMode("host") } ) }.run() ``` Includes: - Same `envMapper` / `argsMapper` model as `stove-process` - `ContainerTarget.Server` (port-binding or host-network) and `ContainerTarget.Worker` - `configureContainer { ... }` block for Testcontainers `GenericContainer` access (volume mounts, network mode, capabilities, etc.) - `beforeStarted { ... }` hook for pre-start setup with resolved configuration - Graceful container stop with fallback force-close - Pluggable image registry (`registry`, `compatibleSubstitute`) In CI, point at the image tag your build pipeline already produced (`-Papp.image=...` or env var). For local iteration, optionally wire a Gradle `Exec` task that runs `docker build`. See [Go Container Mode](../other-languages/go-container.md) for the full walkthrough. ### Go is a first-class citizen The Go showcase recipe ([`recipes/process/golang/go-showcase`](https://github.com/Trendyol/stove/tree/main/recipes/process/golang/go-showcase)) demonstrates an HTTP + PostgreSQL + Kafka service with full tracing, dashboard, and coverage. The same StoveConfig switches between process and container mode via `-Dgo.aut.mode=process|container`. Highlights: - HTTP and database queries traced via `otelhttp` + `otelsql` - W3C `traceparent` propagation correlates Go spans with the originating Stove test - `go build -cover` integration coverage with `GOCOVERDIR` and SIGPIPE-safe shutdown - Dashboard streams the Go run alongside JVM runs ### `stove-kafka` — Go Kafka bridge library New Go library at [`go/stove-kafka`](https://github.com/Trendyol/stove/tree/main/go/stove-kafka) enables `shouldBePublished` and `shouldBeConsumed` assertions for Go applications. The bridge forwards produced/consumed/committed messages over gRPC to Stove's observer. ```bash go get github.com/trendyol/stove/go/stove-kafka ``` ```go import stovekafka "github.com/trendyol/stove/go/stove-kafka" bridge, _ := stovekafka.NewBridgeFromEnv() // nil in production — zero overhead defer bridge.Close() ``` Three first-party client integrations: | Library | Subpackage | Integration | |---------|-----------|-------------| | [IBM/sarama](https://github.com/IBM/sarama) | `stove-kafka/sarama` | `ProducerInterceptor` / `ConsumerInterceptor` | | [twmb/franz-go](https://github.com/twmb/franz-go) | `stove-kafka/franz` | `kgo.WithHooks(&franz.Hook{...})` | | [segmentio/kafka-go](https://github.com/segmentio/kafka-go) | `stove-kafka/segmentio` | `ReportWritten()` / `ReportRead()` | Other clients (e.g. confluent-kafka-go) can use the library-agnostic core: `bridge.ReportPublished()`, `bridge.ReportConsumed()`, `bridge.ReportCommitted()`. All `Bridge` methods are nil-safe — the bridge disappears in production where `STOVE_KAFKA_BRIDGE_PORT` is unset. ### MCP server in `stove` CLI The CLI now exposes a local **Model Context Protocol** endpoint so AI agents can triage failed tests through compact, structured tools instead of loading entire logs into context. ```text $ stove Stove CLI v0.24.0 running UI: http://localhost:4040 REST: http://localhost:4040/api/v1 MCP: http://localhost:4040/mcp gRPC: localhost:4041 ``` Generic MCP client config: ```json { "mcpServers": { "stove": { "transport": "streamable-http", "url": "http://localhost:4040/mcp" } } } ``` Tools (all read-only, local-only): | Tool | Purpose | |------|---------| | `stove_apps` | Apps recorded in the dashboard database | | `stove_runs` | Runs, filterable by app and status | | `stove_failures` | Default entrypoint — failed tests grouped by app and run | | `stove_failure_detail` | Compact failure packet for one exact test | | `stove_timeline` | Ordered test actions, failure-focused | | `stove_trace` | Critical path and exception evidence from correlated spans | | `stove_snapshot` | System snapshot summaries with targeted JSON drill-down | | `stove_raw_evidence` | Capped raw lookup for one entry, span, or snapshot | Defaults are token-aware: payloads are truncated deterministically, sensitive keys (`authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, `credential`) are redacted before return. Use `budget: tiny|compact|full` to dial detail. The endpoint accepts loopback only and rejects non-local `Host`/`Origin` headers. See [MCP](../Components/21-mcp.md). ### Go integration test coverage Stove black-box tests can now collect Go integration coverage. Build with `go build -cover`, set `GOCOVERDIR`, and Go writes coverage data on graceful shutdown — fits the `stove-process` and `stove-container` lifecycle. ```bash ./gradlew e2eTestWithCoverage -Pgo.coverage=true ./gradlew e2eTest-containerWithCoverage -Pgo.coverage=true ``` Notes: - No framework changes — uses existing `envMapper` and Gradle tasks - `signal.Ignore(syscall.SIGPIPE)` in `main()` is required so log writes to a closed `ProcessBuilder` stdout pipe do not kill the process before counters flush - For container mode, bind-mount a host coverage directory into the container See [Go Process Mode — Coverage](../other-languages/go-process.md#code-coverage). --- ## Improvements - `ReadinessStrategy.HttpGet` replaces the older `HealthCheckOptions` API consistently across the codebase (process, container, provided application). Old callers should migrate to the new strategy. - `ProcessApplicationUnderTest` exposes background stdout reading and a configurable graceful shutdown timeout - `ContainerApplicationUnderTest` streams container logs through SLF4J with the image as prefix --- ## Documentation - New "Other Languages & Stacks" section split into mode-focused pages: [Process Mode](../other-languages/go-process.md) and [Container Mode](../other-languages/go-container.md) - New [Container AUT component page](../Components/22-container.md) — language-agnostic `stove-container` reference - New [MCP component page](../Components/21-mcp.md) covering discovery, integration, agent workflow, tools, token budgeting, and security - New [Provided Application](../Components/19-provided-application.md) page for black-box / smoke testing - New [Multiple Systems](../Components/20-multiple-systems.md) page for keyed system registration - Coverage walkthrough (Gradle wiring, `GOCOVERDIR`, SIGPIPE) in the Go pages - Refreshed [other-languages/index.md](../other-languages/index.md) with process vs. container guidance --- ## Migration Guide ### From 0.23.0 to 0.24.0 This is a non-breaking release for JVM users. New modules are opt-in. #### Adopt black-box / smoke testing If you want to run your existing Stove tests against a deployed app (staging, pre-prod), swap your framework starter for `providedApplication()` and use the `*.provided(...)` factory on each external system. No new dependency required — `providedApplication()` ships with `com.trendyol:stove`. #### Register multiple instances of one system type Define `SystemKey` singletons and pass them as the first argument to system DSLs (`postgresql(AppDb) { ... }`, `httpClient(OrderService) { ... }`). Default and keyed instances coexist. #### Adopt non-JVM testing For Go (or any language) applications, add either or both: ```kotlin testImplementation("com.trendyol:stove-process") // host binary testImplementation("com.trendyol:stove-container") // Docker image ``` For Go Kafka assertions, add the bridge to your Go module: ```bash go get github.com/trendyol/stove/go/stove-kafka ``` #### Adopt MCP Upgrade the `stove` CLI to 0.24.0: ```bash brew upgrade stove ``` Point your agent runtime at `http://localhost:4040/mcp`. No test code changes required — MCP reads from the same dashboard database that already records your runs. #### `HealthCheckOptions` → `ReadinessStrategy` If you copied internal helpers from earlier snapshots, replace `HealthCheckOptions` with `ReadinessStrategy.HttpGet(url, ...)`. Public APIs already used `ReadinessStrategy`, so most users are unaffected. ================================================ FILE: docs/troubleshooting.md ================================================ # Troubleshooting & FAQ Having issues? This guide covers the most common problems and how to fix them. If you don't find what you're looking for, feel free to open an issue on GitHub. ## Common Issues ### Docker Issues !!! tip "Docker Not Available?" If Docker is not available in your environment (e.g., some CI/CD pipelines), consider using [provided instances](Components/11-provided-instances.md) to connect to existing infrastructure instead of spinning up containers. #### Docker Not Found / Not Running **Symptoms:** ``` Could not find a valid Docker environment ``` **Solutions:** 1. **Verify Docker is installed and running:** ```bash docker --version docker ps ``` 2. **Check Docker daemon status:** ```bash # macOS/Linux systemctl status docker # or docker info ``` 3. **Restart Docker Desktop** (if using Docker Desktop) 4. **Check Docker socket permissions:** ```bash # Linux sudo chmod 666 /var/run/docker.sock ``` #### Docker Image Pull Failures **Symptoms:** ``` Error pulling image: denied: access denied ``` **Solutions:** 1. **Use a custom registry:** ```kotlin DEFAULT_REGISTRY = "your-registry.com" ``` 2. **Login to registry:** ```bash docker login your-registry.com ``` 3. **Configure per-component registry:** ```kotlin kafka { KafkaSystemOptions( container = KafkaContainerOptions( registry = "your-registry.com" ) ) { /* config */ } } ``` #### Port Already in Use **Symptoms:** ``` Bind for 0.0.0.0:8080 failed: port is already allocated ``` **Solutions:** 1. **Find and kill the process using the port:** ```bash # macOS/Linux lsof -i :8080 kill -9 # Windows netstat -ano | findstr :8080 taskkill /PID /F ``` 2. **Use a different port:** ```kotlin springBoot( runner = { params -> myApp.run(params) }, withParameters = listOf("server.port=8081") ) ``` 3. **Use dynamic ports:** Let the framework assign available ports when possible. ### Startup Issues #### Application Fails to Start **Symptoms:** ``` Application failed to start ``` **Solutions:** 1. **Check application logs:** ```kotlin springBoot( withParameters = listOf( "logging.level.root=debug", "logging.level.org.springframework=debug" ) ) ``` 2. **Verify configuration is being passed correctly:** ```kotlin kafka { KafkaSystemOptions { cfg -> println("Kafka config: ${cfg.bootstrapServers}") // Debug print listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } } ``` 3. **Ensure your application accepts CLI arguments:** ```kotlin // Application should parse args fun run(args: Array) { // args should include Stove's configuration } ``` #### Container Startup Timeout **Symptoms:** ``` Container startup timed out ``` **Solutions:** 1. **Increase container startup timeout:** ```kotlin couchbase { CouchbaseSystemOptions( container = CouchbaseContainerOptions( containerFn = { container -> container.withStartupTimeout(Duration.ofMinutes(5)) } ) ) { /* config */ } } ``` 2. **Check container resource requirements:** - Elasticsearch needs at least 2GB RAM - Couchbase needs significant memory - Reduce memory limits in resource-constrained environments 3. **Check Docker resources:** - Increase Docker Desktop memory allocation - Ensure sufficient disk space ### Test Failures #### Assertion Timeout **Symptoms:** ``` Timed out waiting for condition ``` **Solutions:** 1. **Increase assertion timeout:** ```kotlin kafka { shouldBePublished(atLeastIn = 30.seconds) { actual.id == expectedId } } ``` 2. **Check if the operation actually completes:** - Add logging to verify the operation is triggered - Check application logs for errors 3. **Verify async processing is working:** ```kotlin // Debug by checking intermediate state using { println("Pending events: ${pendingCount()}") } ``` #### Serialization/Deserialization Errors **Symptoms:** ``` JsonParseException: Unrecognized field MismatchedInputException: Cannot deserialize ``` **Solutions:** 1. **Align ObjectMapper configuration:** ```kotlin val objectMapper = ObjectMapper().apply { registerModule(KotlinModule.Builder().build()) registerModule(JavaTimeModule()) disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) } Stove() .with { http { HttpClientSystemOptions( contentConverter = JacksonConverter(objectMapper) ) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(objectMapper) ) { /* config */ } } } ``` 2. **Check field name mapping:** ```kotlin data class MyEvent( @JsonProperty("eventId") // Match exact field name val id: String ) ``` 3. **Verify data class has default constructor for Jackson:** ```kotlin // Add default values or use @JsonCreator data class MyEvent( val id: String = "", val name: String = "" ) ``` #### Data Not Found **Symptoms:** ``` Resource with key (xxx) is not found Document not found ``` **Solutions:** 1. **Verify data was actually saved:** ```kotlin // Save couchbase { save(collection = "orders", id = orderId, instance = order) // Immediately verify shouldGet("orders", orderId) { o -> println("Saved order: $o") } } ``` 2. **Check timing - wait for async operations:** ```kotlin // If save is async, wait for it delay(1.seconds) couchbase { shouldGet("orders", orderId) { /* verify */ } } ``` 3. **Verify collection/index names match:** ```kotlin // Ensure collection names are consistent save(collection = "orders", ...) // Note: "orders" not "order" shouldGet("orders", ...) // Must match! ``` #### Kafka Message Not Found **Symptoms:** ``` Message was not published within timeout Message was not consumed within timeout ``` **Solutions:** 1. **Verify Kafka interceptor is configured:** ```kotlin hl_lines="3" // In your Stove setup addTestDependencies { bean>(isPrimary = true) } ``` 2. **Check topic names:** ```kotlin kafka { shouldBePublished(atLeastIn = 10.seconds) { println("Checking topic: ${metadata.topic}") // Debug actual.id == expectedId } } ``` 3. **Verify interceptor class is passed to application:** ```kotlin hl_lines="6" kafka { KafkaSystemOptions { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptorClasses=${cfg.interceptorClass}" // Important! ) } } ``` 4. **Check consumer group offset configuration:** ```kotlin springBoot( withParameters = listOf( "kafka.offset=earliest", // Start from beginning "kafka.autoCreateTopics=true" ) ) ``` #### WireMock Stubs Not Being Hit **Symptoms:** ``` Connection refused to external service Test timeout when calling mocked endpoint Mock not found / unexpected request ``` **Cause:** This is almost always because your application's external service URLs don't match the WireMock URL. **Solutions:** 1. **Ensure ALL external service URLs point to WireMock:** ```kotlin hl_lines="11-13" Stove() .with { wiremock { WireMockSystemOptions(port = 9090) } springBoot( runner = { params -> myApp.run(params) }, withParameters = listOf( // ALL external services must use WireMock URL "payment.service.url=http://localhost:9090", "inventory.service.url=http://localhost:9090", "notification.service.url=http://localhost:9090" ) ) } ``` 2. **Verify your application is reading the URLs from configuration:** ```kotlin // Your application should read URLs from config, not hardcode them @Value("\${payment.service.url}") private lateinit var paymentServiceUrl: String ``` 3. **Check the port matches:** ```kotlin // WireMock port WireMockSystemOptions(port = 9090) // Application parameter must match "payment.service.url=http://localhost:9090" // Same port! ``` 4. **Debug by checking WireMock requests:** ```kotlin wiremock { // After test, check what requests WireMock received WireMock.getAllServeEvents().forEach { event -> println("Request: ${event.request.url}") } } ``` ### Memory Issues #### OutOfMemoryError **Symptoms:** OutOfMemoryError (e.g. `Java heap space`) **Solutions:** 1. **Increase JVM heap for tests:** ```kotlin // build.gradle.kts tasks.test { jvmArgs("-Xmx2g", "-Xms512m") } ``` 2. **Limit container memory:** ```kotlin elasticsearch { ElasticsearchSystemOptions( container = ElasticContainerOptions( containerFn = { container -> container.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m") } ) ) { /* config */ } } ``` 3. **Use provided instances instead of containers** for CI environments. ### CI/CD Issues #### Docker-in-Docker Not Working **Solutions:** 1. **Use DinD sidecar in CI:** ```yaml # GitLab CI example services: - docker:dind variables: DOCKER_HOST: tcp://docker:2375 ``` 2. **Use provided instances:** ```kotlin Stove() .with { kafka { KafkaSystemOptions.provided( bootstrapServers = System.getenv("KAFKA_SERVERS"), configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } } ``` #### Slow CI Builds **Solutions:** 1. **Use provided instances** for external infrastructure 2. **Enable container reuse:** ```kotlin Stove { keepDependenciesRunning() // In development only } ``` 3. **Run tests in parallel** (ensure proper isolation) 4. **Use smaller container images** when available #### Intermittent Failures with Shared Infrastructure **Symptoms:** ``` Tests pass locally but fail randomly in CI Data from another test run appears in assertions "Topic already exists" or "Index already exists" errors Tests fail when multiple builds run in parallel ``` **Cause:** Multiple test runs are using the same resource names (databases, topics, indices) in shared infrastructure. **Solutions:** 1. **Use unique resource prefixes per test run:** ```kotlin hl_lines="2-3 5-7" object TestRunContext { val runId: String = System.getenv("CI_JOB_ID") ?: UUID.randomUUID().toString().take(8) val databaseName = "testdb_$runId" val topicPrefix = "test_${runId}_" val indexPrefix = "test_${runId}_" } ``` 2. **Apply prefixes to all resources:** ```kotlin hl_lines="3-5" springBoot( withParameters = listOf( "spring.datasource.url=jdbc:postgresql://db:5432/${TestRunContext.databaseName}", "kafka.topic.orders=${TestRunContext.topicPrefix}orders", "elasticsearch.index.products=${TestRunContext.indexPrefix}products" ) ) ``` 3. **Clean up only your resources:** ```kotlin cleanup = { admin -> val ourTopics = admin.listTopics().names().get() .filter { it.startsWith(TestRunContext.topicPrefix) } if (ourTopics.isNotEmpty()) { admin.deleteTopics(ourTopics).all().get() } } ``` 4. **Log the run ID for debugging:** ```kotlin init { println("Test Run ID: ${TestRunContext.runId}") } ``` !!! tip "Detailed Guide" See [Provided Instances - Test Isolation](Components/11-provided-instances.md#test-isolation-with-shared-infrastructure) for comprehensive examples. ## FAQ ### General Questions #### Q: Can I use Stove with Java? **A:** Yes, you can use Stove in Java projects! However, the e2e tests themselves need to be written in Kotlin. Stove's DSL is designed specifically for Kotlin, providing a clean and expressive syntax: ```kotlin class MyE2ETest : FunSpec({ test("should create order") { stove { http { postAndExpectBodilessResponse( uri = "/orders", body = Some(CreateOrderRequest()), expect = { status shouldBe 201 } ) } } } }) ``` You can still test your Java application with Stove — just write your e2e test files in Kotlin. #### Q: Can I use JUnit instead of Kotest? **A:** Yes, Stove works with both JUnit and Kotest. See the [Getting Started](getting-started.md) guide for JUnit examples. #### Q: How do I debug tests? **A:** 1. Set breakpoints in your application code 2. Run tests in debug mode 3. Use verbose logging: ```kotlin withParameters = listOf("logging.level.root=debug") ``` 4. Access application beans: ```kotlin using { println("Service state: $this") } ``` #### Q: Can I run tests in parallel? **A:** Yes, but ensure proper test isolation: - Use unique test data (UUIDs) - Don't share state between tests - Be careful with shared resources #### Q: How do I test with SSL/TLS? **A:** Configure the component with security enabled: ```kotlin elasticsearch { ElasticsearchSystemOptions( container = ElasticContainerOptions( disableSecurity = false ), configureExposedConfiguration = { cfg -> // Certificate info available in cfg.certificate listOf(...) } ) } ``` ### Component-Specific Questions #### Q: Why isn't my Kafka message being intercepted? **A:** Ensure: 1. `TestSystemKafkaInterceptor` is registered as a bean 2. `kafka.interceptorClasses` is configured correctly 3. Your Kafka listener container uses the interceptor ```kotlin // Application configuration @Bean fun containerFactory( interceptor: ConsumerAwareRecordInterceptor ): ConcurrentKafkaListenerContainerFactory { return ConcurrentKafkaListenerContainerFactory().apply { setRecordInterceptor(interceptor) } } ``` #### Q: How do I test multiple databases? **A:** Add multiple database components: ```kotlin Stove() .with { postgresql { PostgresqlOptions(...) } mongodb { MongodbSystemOptions(...) } couchbase { CouchbaseSystemOptions(...) } } ``` #### Q: Can I use custom container images? **A:** Yes: ```kotlin kafka { KafkaSystemOptions( container = KafkaContainerOptions( registry = "my-registry.com", image = "custom/kafka", tag = "3.5.0" ) ) { /* config */ } } ``` #### Q: How do I handle database migrations? **A:** Use the migrations API: ```kotlin postgresql { PostgresqlOptions(...).migrations { register() register() } } ``` #### Q: Can I access the underlying testcontainer? **A:** For container operations like pause/unpause: ```kotlin couchbase { pause() // Pause container unpause() // Resume container } ``` For the client: ```kotlin elasticsearch { val client = client() // Get Elasticsearch client // Use client directly } ``` ### Performance Questions #### Q: How can I speed up test execution? **A:** 1. **Keep containers running:** ```kotlin Stove { keepDependenciesRunning() } ``` 2. **Use provided instances in CI:** ```kotlin kafka { KafkaSystemOptions.provided(bootstrapServers = "...", configureExposedConfiguration = { ... }) } ``` 3. **Reduce container resource allocation:** ```kotlin withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m") ``` 4. **Run independent tests in parallel** #### Q: Why is container startup slow? **A:** Container startup depends on: - Image pull time (first run) - Container initialization time - Health check completion Solutions: - Pre-pull images in CI - Use `keepDependenciesRunning()` locally - Increase startup timeout for slow containers #### Q: Why can't my AI agent connect to Stove MCP? **A:** Stove MCP is served by `stove-cli`, not by your test JVM. Start `stove` first and use the endpoint printed in the startup banner, usually `http://localhost:4040/mcp`. You can also check `http://localhost:4040/api/v1/meta`; it should include `"mcp": { "enabled": true, ... }`. If MCP still cannot be reached, use the normal failure report, test output, and logs. MCP is a token-saving path for agents, not a required dependency. ### Migration Questions #### Q: How do I migrate from 0.14.x to 0.15.x? **A:** See [Migration Notes](release-notes/0.15.0.md) for detailed instructions. Key changes: - `StoveSerde` replaces direct `ObjectMapper` usage - Configure serde for each component that needs it #### Q: How do I migrate from 0.21.x to 0.21.2? **A:** See [Migration Notes](release-notes/0.21.2.md) for detailed instructions. Key changes: - `configureStoveTracing` renamed to `stoveTracing` in buildSrc - New Stove Tracing Gradle Plugin available as an alternative to the buildSrc approach - If using the plugin, properties use Gradle's `Property` API (e.g., `serviceName.set("...")` instead of `serviceName = "..."`) #### Q: How do I migrate from 0.21.2 to 0.22.x? **A:** See [Migration Notes](release-notes/0.22.2.md) for detailed instructions. Key changes: - New `stove-quarkus` module available for Quarkus applications - Console reporting rewritten with Mordant for better output - No breaking changes — all existing APIs remain compatible ## Getting Help If you can't find a solution: 1. **Search existing issues:** [GitHub Issues](https://github.com/Trendyol/stove/issues) 2. **Check examples:** [Examples Directory](https://github.com/Trendyol/stove/tree/main/examples) 3. **Open a new issue:** Include: - Stove version - JDK version - Docker version - Complete error message - Minimal reproduction code ## Debug Checklist When troubleshooting, check these items: - [ ] Docker is running and accessible (not needed if using [provided instances](Components/11-provided-instances.md)) - [ ] Correct Stove version in dependencies - [ ] Application main function is properly modified - [ ] Configuration is passed to application - [ ] Serializers match between Stove and application - [ ] Container has enough resources - [ ] Ports are not conflicting - [ ] Network is accessible (for provided instances) - [ ] Timeouts are appropriate for your environment ================================================ FILE: docs/writing-custom-systems.md ================================================ # Writing Custom Systems Stove's built-in systems cover databases, Kafka, HTTP, gRPC, and more, but your application is unique. Maybe you use a job scheduler, publish domain events, need to control time in tests, or talk to a service over a custom protocol. Custom systems let you bring **anything** into the Stove DSL so your tests read like this: ```kotlin hl_lines="7-10" test("should send welcome email after user signs up") { stove { http { post("/users", createUserRequest) { it.status shouldBe 201 } } tasks { shouldBeExecuted(atLeastIn = 10.seconds) { recipientEmail == "new-user@example.com" } } } } ``` That `tasks { }` block is a custom system. Building one is straightforward. ## The Pattern Every custom system has three pieces: ### 1. The System Class Implement PluggedSystem and pick a lifecycle interface that fits your needs: ```kotlin hl_lines="1-2 11-16" class DbSchedulerSystem( override val stove: Stove ) : PluggedSystem, AfterRunAwareWithContext { private lateinit var listener: StoveDbSchedulerListener override suspend fun afterRun(context: ApplicationContext) { listener = context.getBean(StoveDbSchedulerListener::class.java) } suspend inline fun shouldBeExecuted( atLeastIn: Duration = 5.seconds, noinline condition: T.() -> Boolean ): DbSchedulerSystem { listener.waitUntilObserved(atLeastIn, T::class, condition) return this } override fun close() {} } ``` The lifecycle interfaces control when your system runs: before the app starts, after it starts, or when configuration is collected. | Interface | When Called | |-----------|------------| | `RunAware` | Before application starts | | `AfterRunAware` | After application starts | | `AfterRunAwareWithContext` | After application starts, with DI context (e.g., Spring `ApplicationContext`) | | `ExposesConfiguration` | When collecting configuration to pass to the application | ### 2. DSL Extensions Two extension functions wire your system into Stove's DSL: ```kotlin hl_lines="1-3 5-8" @StoveDsl fun WithDsl.dbScheduler(): Stove = this.stove.getOrRegister(DbSchedulerSystem(this.stove)).let { this.stove } @StoveDsl suspend fun ValidationDsl.tasks( validation: suspend DbSchedulerSystem.() -> Unit ): Unit = validation(this.stove.getOrNone().getOrElse { throw SystemNotRegisteredException(DbSchedulerSystem::class) }) ``` The first one registers the system during setup (`.with { dbScheduler() }`). The second one exposes it during tests (`tasks { ... }`). ### 3. Bean Registration If your system needs a component inside the application (like a listener), register it as a test bean: ```kotlin hl_lines="4-6" springBoot( runner = { params -> runApplication(*params) { addTestDependencies { bean(isPrimary = true) } } } ) ``` That's the whole pattern. The rest is your domain logic. ## Ideas Here are examples of what you can build. Each shows the test DSL (the part your teammates will see), not the implementation details. ### Scheduled Task Testing Test that your application scheduled and executed a task with the expected payload: ```kotlin hl_lines="3 9" stove { http { postAndExpectBodilessResponse("/orders", body = orderRequest.some()) { it.status shouldBe 200 } } tasks { shouldBeExecuted(atLeastIn = 10.seconds) { orderId == expectedOrderId && recipientEmail == "customer@example.com" } } } ``` !!! note "Full working example" See the [spring-showcase recipe](https://github.com/Trendyol/stove/blob/main/recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/setup/DbSchedulerSystem.kt) for the complete `DbSchedulerSystem` implementation with reporting integration. ### Domain Event Capture Capture Spring application events in memory and assert on them: ```kotlin hl_lines="7 11" stove { http { post("/users", createUserRequest) { it.status shouldBe 201 } } domainEvents { shouldBePublished(atLeastIn = 5.seconds) { userId == expectedId && name == "John" } shouldNotBePublished { userId == expectedId } } } ``` The system behind this is a `@EventListener` bean that collects events into a `ConcurrentLinkedQueue`, and a `DomainEventSystem` that polls it with a timeout. ### Time Control Replace your application's `Clock` with a test-controllable one: ```kotlin hl_lines="6-7 12" stove { http { post("/login", credentials) { sessionId = it.sessionId } } time { advance(31.minutes) } http { getResponseBodiless("/protected", headers = mapOf("Session-ID" to sessionId)) { it.status shouldBe 401 // Session expired } } } ``` The system injects a `StoveTestClock` (extending `java.time.Clock`) as a Spring bean, and the `advance()` / `setTime()` methods manipulate it. ### Exposing Configuration If your system starts infrastructure (like a container) and needs to pass connection details to the application: ```kotlin class MySystem( override val stove: Stove, private val options: MySystemOptions ) : PluggedSystem, RunAware, ExposesConfiguration { private lateinit var config: MyExposedConfig override suspend fun run() { config = MyExposedConfig(host = "localhost", port = startContainer()) } override fun configuration(): List = options.configureExposedConfiguration(config) override fun close() {} } ``` Stove collects all `configuration()` outputs and passes them to the application as startup parameters. ## Extending Built-In Systems You don't always need a full system. Sometimes an extension function on an existing system is enough: ```kotlin hl_lines="1-2 15-17" @StoveDsl suspend fun KafkaSystem.publishWithCorrelationId( topic: String, message: Any, correlationId: String = UUID.randomUUID().toString() ) { publish( topic = topic, message = message, headers = mapOf("X-Correlation-ID" to correlationId) ) } // Usage kafka { publishWithCorrelationId("orders.created", orderEvent) } ``` This works for any built-in system: `HttpSystem`, `KafkaSystem`, `PostgresqlSystem`, etc. Use `@StoveDsl` for IDE auto-completion support. ================================================ FILE: examples/build.gradle.kts ================================================ subprojects { configurations.configureEach { this.resolutionStrategy { eachDependency { if (requested.group == "com.google.protobuf" && requested.name.startsWith("protobuf-")) { useVersion(libs.versions.google.protobuf.get()) because("Align protobuf runtime with generated code version") } } } } } ================================================ FILE: examples/ktor-example/build.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") import com.trendyol.stove.gradle.stoveTracing plugins { kotlin("jvm") version libs.versions.kotlin application idea kotlin("plugin.serialization") version libs.versions.kotlin alias(libs.plugins.protobuf) } application { val groupId = rootProject.group.toString() val artifactId = project.name mainClass.set("$groupId.$artifactId.ApplicationKt") val isDevelopment: Boolean = project.ext.has("development") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") } stoveTracing { serviceName = "ktor-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } dependencies { implementation(libs.ktor.server) implementation(libs.ktor.server.cio) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.server.call.logging) implementation(libs.koin.ktor) implementation(libs.koin.logger.slf4j) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.core) implementation(libs.r2dbc.postgresql) implementation(libs.kafka) implementation(libs.hoplite.yaml) implementation(libs.jackson.kotlin) implementation(libs.jackson.databind) // OpenTelemetry API for manual span recording (exceptions in catch blocks) implementation(libs.opentelemetry.api) // gRPC service clients (FeatureToggle, Pricing) implementation(libs.io.grpc) implementation(libs.io.grpc.stub) implementation(libs.io.grpc.protobuf) implementation(libs.io.grpc.netty) implementation(libs.io.grpc.kotlin) implementation(libs.google.protobuf.kotlin) testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) testImplementation(projects.stove.lib.stoveHttp) testImplementation(projects.stove.lib.stoveWiremock) testImplementation(projects.stove.lib.stovePostgres) testImplementation(projects.stove.lib.stoveKafka) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.lib.stoveGrpc) testImplementation(projects.stove.lib.stoveGrpcMock) testImplementation(projects.stove.starters.ktor.stoveKtor) } repositories { mavenCentral() maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } } protobuf { protoc { artifact = libs.protoc.get().toString() } plugins { create("grpc").apply { artifact = libs.grpc.protoc.gen.java.get().toString() } create("grpckt").apply { artifact = "${libs.grpc.protoc.gen.kotlin.get()}:jdk8@jar" } } generateProtoTasks { all().forEach { task -> task.plugins { create("grpc") create("grpckt") } task.builtins { create("kotlin") } } } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/Application.kt ================================================ @file:Suppress("ExtractKtorModule") package stove.ktor.example import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.cio.* import io.ktor.server.engine.* import io.ktor.server.plugins.contentnegotiation.* import org.koin.core.module.Module import org.koin.dsl.module import org.koin.ktor.ext.get import org.koin.ktor.plugin.Koin import org.koin.logger.SLF4JLogger import stove.ktor.example.app.* import stove.ktor.example.application.ExampleAppConsumer const val CONNECT_TIMEOUT_SECONDS = 10L fun main(args: Array) { run(args, shouldWait = true) } fun run( args: Array, shouldWait: Boolean = false, applicationOverrides: () -> Module = { module { } } ): Application { val config = loadConfiguration(args) val applicationEngine = embeddedServer(CIO, port = config.port, host = "localhost") { mainModule(config, applicationOverrides) } applicationEngine.monitor.subscribe(ApplicationStarted) { it.get>().start() } applicationEngine.monitor.subscribe(ApplicationStopping) { it.get>().stop() } applicationEngine.start(wait = shouldWait) return applicationEngine.application } fun Application.mainModule(config: AppConfiguration, applicationOverrides: () -> Module) { install(ContentNegotiation) { json() } install(Koin) { SLF4JLogger() modules( module { single { config } }, kafka(), postgresql(), app(config), applicationOverrides() ) } configureRouting() } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/app/app.kt ================================================ package stove.ktor.example.app import com.fasterxml.jackson.databind.ObjectMapper import org.koin.dsl.* import stove.ktor.example.application.* import stove.ktor.example.domain.ProductRepository import stove.ktor.example.infrastructure.FeatureToggleClient import stove.ktor.example.infrastructure.PricingClient val objectMapperRef: ObjectMapper = ObjectMapper().apply { findAndRegisterModules() } fun app(cfg: AppConfiguration) = module { // External gRPC clients - both can point to the same mock server in tests single { FeatureToggleClient(cfg.featureToggle.host, cfg.featureToggle.port) } single { PricingClient(cfg.pricing.host, cfg.pricing.port) } single { ProductRepository(get()) } single { ProductService(get(), get(), get(), get(), get()) } single { MutexLockProvider() }.bind() } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/app/configuration.kt ================================================ package stove.ktor.example.app import com.sksamuel.hoplite.* import com.sksamuel.hoplite.env.Environment @OptIn(ExperimentalHoplite::class) inline fun loadConfiguration(args: Array = arrayOf()): T = ConfigLoaderBuilder .default() .addEnvironmentSource() .addCommandLineSource(args) .withExplicitSealedTypes() .withEnvironment(AppEnv.toEnv()) .apply { when (AppEnv.current()) { AppEnv.Local -> { addResourceSource("/application.yaml", optional = true) } AppEnv.Prod -> { addResourceSource("/application-prod.yaml", optional = true) addResourceSource("/application.yaml", optional = true) } else -> { addResourceSource("/application.yaml", optional = true) } } }.build() .loadConfigOrThrow() data class AppConfiguration( val port: Int, val database: DatabaseConfiguration, val kafka: KafkaConfiguration, val featureToggle: FeatureToggleConfiguration = FeatureToggleConfiguration(), val pricing: PricingConfiguration = PricingConfiguration() ) data class FeatureToggleConfiguration( val host: String = "localhost", val port: Int = 9090 ) data class PricingConfiguration( val host: String = "localhost", val port: Int = 9090 ) data class DatabaseConfiguration( val host: String, val port: Int, val name: String, val jdbcUrl: String, val username: String, val password: String ) data class KafkaConfiguration( val bootstrapServers: String, val groupId: String, val clientId: String, val interceptorClasses: List, val topics: Map ) data class TopicConfiguration( val topic: String, val retry: String, val error: String ) enum class AppEnv( val env: String ) { Unspecified(""), Local(Environment.local.name), Prod(Environment.prod.name) ; companion object { fun current(): AppEnv = when (System.getenv("ENVIRONMENT")) { Unspecified.env -> Unspecified Local.env -> Local Prod.env -> Prod else -> Local } fun toEnv(): Environment = when (current()) { Local -> Environment.local Prod -> Environment.prod else -> Environment.local } } fun isLocal(): Boolean = this === Local fun isProd(): Boolean = this === Prod } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/app/database.kt ================================================ package stove.ktor.example.app import io.r2dbc.postgresql.* import org.koin.core.context.GlobalContext.get import org.koin.dsl.module import stove.ktor.example.CONNECT_TIMEOUT_SECONDS import java.time.Duration fun postgresql() = module { single { val config = get() val builder = PostgresqlConnectionConfiguration.builder().apply { host(config.database.host) database(config.database.name) port(config.database.port) password(config.database.password) username(config.database.username) } PostgresqlConnectionFactory(builder.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)).build()) } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/app/kafka.kt ================================================ package stove.ktor.example.app import com.fasterxml.jackson.module.kotlin.readValue import org.apache.kafka.clients.consumer.* import org.apache.kafka.clients.producer.* import org.apache.kafka.common.serialization.* import org.koin.core.module.Module import org.koin.dsl.module import stove.ktor.example.application.* import kotlin.time.Duration.Companion.seconds fun kafka(): Module = module { single { createConsumer(get()) } single { createProducer(get()) } single { ExampleAppConsumer(get(), get()) } } @Suppress("MagicNumber") private fun createConsumer(config: AppConfiguration): KafkaConsumer { val pollTimeoutSec = POLL_TIMEOUT_SECONDS val heartbeatSec = pollTimeoutSec + 1 return KafkaConsumer( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.kafka.bootstrapServers, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ExampleAppKafkaValueDeserializer::class.java, ConsumerConfig.GROUP_ID_CONFIG to config.kafka.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG to config.kafka.interceptorClasses, ConsumerConfig.CLIENT_ID_CONFIG to config.kafka.clientId, ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG to true, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to heartbeatSec.seconds.inWholeSeconds.toInt(), ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to true ) ) } private fun createProducer(config: AppConfiguration): KafkaProducer = KafkaProducer( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.kafka.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ExampleAppKafkaValueSerializer::class.java, ProducerConfig.INTERCEPTOR_CLASSES_CONFIG to config.kafka.interceptorClasses, ProducerConfig.CLIENT_ID_CONFIG to config.kafka.clientId ) ) @Suppress("UNCHECKED_CAST") class ExampleAppKafkaValueDeserializer : Deserializer { override fun deserialize( topic: String, data: ByteArray ): T = objectMapperRef.readValue(data) as T } class ExampleAppKafkaValueSerializer : Serializer { override fun serialize( topic: String, data: T ): ByteArray = objectMapperRef.writeValueAsBytes(data) } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/app/routing.kt ================================================ package stove.ktor.example.app import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.StatusCode import org.koin.ktor.ext.get import stove.ktor.example.application.* fun Application.configureRouting() { routing { post("/products/{id}") { try { val id = call.parameters["id"]!!.toInt() val request = call.receive() call.get().update(id, request) call.respond(HttpStatusCode.OK) } catch ( @Suppress("TooGenericExceptionCaught") ex: Exception ) { // Record exception in span for tracing visibility Span.current().apply { recordException(ex) setStatus(StatusCode.ERROR, ex.message ?: "Unknown error") } ex.printStackTrace() call.respond(HttpStatusCode.BadRequest) } } } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/application/ExampleAppConsumer.kt ================================================ package stove.ktor.example.application import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.apache.kafka.clients.consumer.* import stove.ktor.example.app.AppConfiguration import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration const val POLL_TIMEOUT_SECONDS = 2 class ExampleAppConsumer( config: AppConfiguration, kafkaConsumer: KafkaConsumer ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val topics = config.kafka.topics.values .fold(listOf()) { acc, topic -> acc + topic.topic + topic.error + topic.retry } private val subscription = kafkaConsumer .apply { subscribe(topics) } fun start() { loop() } private fun loop() { channelFlow { while (isActive) { val records = subscription.poll(POLL_TIMEOUT_SECONDS.seconds.toJavaDuration()) for (record in records) { send(record) } } }.onEach { consume(it) } .catch { exception -> throw exception } .launchIn(scope) } fun stop() = scope.cancel() private fun consume(message: ConsumerRecord) { println("Consumed message: $message") } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/application/LockProvider.kt ================================================ package stove.ktor.example.application import kotlinx.coroutines.sync.Mutex import java.time.Duration interface LockProvider { suspend fun acquireLock( name: String, duration: Duration ): Boolean suspend fun releaseLock(name: String) } class MutexLockProvider : LockProvider { private val mutex = Mutex() override suspend fun acquireLock( name: String, duration: Duration ): Boolean = mutex.tryLock(this) override suspend fun releaseLock(name: String) { mutex.unlock(this) } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/application/ProductService.kt ================================================ package stove.ktor.example.application import org.apache.kafka.clients.producer.* import stove.ktor.example.domain.* import stove.ktor.example.infrastructure.FeatureToggleClient import stove.ktor.example.infrastructure.PricingClient import java.time.Duration import kotlin.coroutines.* class ProductService( private val repository: ProductRepository, private val lockProvider: LockProvider, private val kafkaProducer: KafkaProducer, private val featureToggleClient: FeatureToggleClient, private val pricingClient: PricingClient ) { companion object { private const val DURATION = 30L private const val FEATURE_PRODUCT_UPDATE = "product-update-enabled" } suspend fun update(id: Int, request: UpdateProductRequest) { // 1. Check if product update feature is enabled (Feature Toggle Service) val featureCheck = featureToggleClient.isFeatureEnabled( featureName = FEATURE_PRODUCT_UPDATE, context = request.userId ?: "anonymous" ) if (!featureCheck.enabled) { error("Product update feature is currently disabled") } // 2. Get pricing information (Pricing Service) val priceInfo = pricingClient.calculatePrice( productId = id.toString(), quantity = 1, currency = "USD", customerTier = "standard" ) val acquireLock = lockProvider.acquireLock(::ProductService.name, Duration.ofSeconds(DURATION)) if (!acquireLock) { print("lock could not be acquired") return } try { repository.transaction { val product = it.findById(id) product.name = request.name it.update(product) } // Publish event with price info suspendCoroutine { kafkaProducer .send( ProducerRecord( "product", id.toString(), DomainEvents.ProductUpdated(id, request.name, priceInfo.finalPrice) ) ) { _, exception -> if (exception != null) { it.resumeWithException(exception) } else { it.resume(Unit) } } } } finally { lockProvider.releaseLock(::ProductService.name) } } /** * Get product with calculated price. */ suspend fun getProductWithPrice(productId: Int, customerId: String): ProductWithPrice { val product = repository.findById(productId) // Get discount from Pricing Service val discount = pricingClient.getDiscount(customerId, "electronics") // Calculate final price val price = pricingClient.calculatePrice( productId = productId.toString(), quantity = 1, currency = "USD", customerTier = if (discount.isApplicable) "premium" else "standard" ) return ProductWithPrice( id = product.id, name = product.name, basePrice = price.basePrice, discount = price.discount, finalPrice = price.finalPrice ) } } data class ProductWithPrice( val id: Int, val name: String, val basePrice: Double, val discount: Double, val finalPrice: Double ) ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/application/UpdateProductRequest.kt ================================================ package stove.ktor.example.application import kotlinx.serialization.Serializable @Serializable data class UpdateProductRequest( val name: String, val userId: String? = null ) ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/domain/Product.kt ================================================ package stove.ktor.example.domain data class Product( val id: Int, var name: String ) object DomainEvents { data class ProductUpdated( val id: Int, val name: String, val price: Double = 0.0 ) } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/domain/ProductRepository.kt ================================================ package stove.ktor.example.domain import io.r2dbc.postgresql.PostgresqlConnectionFactory import io.r2dbc.postgresql.api.PostgresqlConnection import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitSingle class ProductRepository( private val postgresqlConnectionFactory: PostgresqlConnectionFactory ) { private lateinit var connection: PostgresqlConnection suspend fun findById(id: Int): Product = connection .createStatement( "SELECT * FROM Products WHERE id=$id" ).execute() .awaitFirst() .map { r, rm -> Product( (r.get(Product::id.name, rm.getColumnMetadata(Product::id.name).javaType!!) as Int), r.get(Product::name.name, rm.getColumnMetadata(Product::name.name).javaType!!) as String ) }.awaitSingle() suspend fun update(product: Product) { connection .createStatement("UPDATE Products SET ${Product::name.name}=('${product.name}') WHERE ${Product::id.name}=${product.id}") .execute() .awaitFirstOrNull() } suspend fun transaction(invoke: suspend (ProductRepository) -> Unit) { connection = this.postgresqlConnectionFactory.create().awaitFirst() connection.beginTransaction().awaitFirstOrNull() try { invoke(this) connection.commitTransaction().awaitFirstOrNull() } catch ( @Suppress("TooGenericExceptionCaught") ex: Exception ) { connection.rollbackTransaction().awaitFirstOrNull() throw ex } } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/infrastructure/FeatureToggleClient.kt ================================================ package stove.ktor.example.infrastructure import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import stove.ktor.example.grpc.* import java.util.concurrent.TimeUnit /** * gRPC client for the external Feature Toggle service. * * This client calls a hypothetical external Feature Toggle microservice to: * - Check if specific features are enabled * - Get all feature flags for a context */ class FeatureToggleClient( private val host: String, private val port: Int ) : AutoCloseable { private val channel: ManagedChannel = ManagedChannelBuilder .forAddress(host, port) .usePlaintext() .build() private val stub: FeatureToggleServiceGrpcKt.FeatureToggleServiceCoroutineStub = FeatureToggleServiceGrpcKt.FeatureToggleServiceCoroutineStub(channel) /** * Check if a feature is enabled for a given context. */ suspend fun isFeatureEnabled(featureName: String, context: String): IsFeatureEnabledResponse { val request = isFeatureEnabledRequest { this.featureName = featureName this.context = context } return stub.isFeatureEnabled(request) } /** * Get all feature flags for a context. */ suspend fun getFeatures(context: String): GetFeaturesResponse { val request = getFeaturesRequest { this.context = context } return stub.getFeatures(request) } @Suppress("MagicNumber") override fun close() { channel.shutdown().awaitTermination(5, TimeUnit.SECONDS) } } ================================================ FILE: examples/ktor-example/src/main/kotlin/stove/ktor/example/infrastructure/PricingClient.kt ================================================ package stove.ktor.example.infrastructure import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import stove.ktor.example.grpc.* import java.util.concurrent.TimeUnit /** * gRPC client for the external Pricing service. * * This client calls a hypothetical external Pricing microservice to: * - Calculate prices for products * - Get applicable discounts for customers */ class PricingClient( private val host: String, private val port: Int ) : AutoCloseable { private val channel: ManagedChannel = ManagedChannelBuilder .forAddress(host, port) .usePlaintext() .build() private val stub: PricingServiceGrpcKt.PricingServiceCoroutineStub = PricingServiceGrpcKt.PricingServiceCoroutineStub(channel) /** * Calculate price for a product. */ suspend fun calculatePrice( productId: String, quantity: Int, currency: String = "USD", customerTier: String = "standard" ): CalculatePriceResponse { val request = calculatePriceRequest { this.productId = productId this.quantity = quantity this.currency = currency this.customerTier = customerTier } return stub.calculatePrice(request) } /** * Get applicable discount for a customer. */ suspend fun getDiscount(customerId: String, productCategory: String): GetDiscountResponse { val request = getDiscountRequest { this.customerId = customerId this.productCategory = productCategory } return stub.getDiscount(request) } @Suppress("MagicNumber") override fun close() { channel.shutdown().awaitTermination(5, TimeUnit.SECONDS) } } ================================================ FILE: examples/ktor-example/src/main/proto/feature_toggle.proto ================================================ syntax = "proto3"; package featuretoggle; option java_package = "stove.ktor.example.grpc"; option java_multiple_files = true; // Request to check if a feature is enabled message IsFeatureEnabledRequest { string feature_name = 1; string context = 2; // e.g., user_id, tenant_id, etc. } // Response with feature status message IsFeatureEnabledResponse { bool enabled = 1; string variant = 2; // Optional variant name for A/B testing } // Request to get all features for a context message GetFeaturesRequest { string context = 1; } // Response with all feature flags message GetFeaturesResponse { map features = 1; } // External Feature Toggle service (hypothetical dependency) service FeatureToggleService { // Check if a specific feature is enabled rpc IsFeatureEnabled(IsFeatureEnabledRequest) returns (IsFeatureEnabledResponse); // Get all feature flags for a context rpc GetFeatures(GetFeaturesRequest) returns (GetFeaturesResponse); } ================================================ FILE: examples/ktor-example/src/main/proto/pricing.proto ================================================ syntax = "proto3"; package pricing; option java_package = "stove.ktor.example.grpc"; option java_multiple_files = true; // Request to calculate price message CalculatePriceRequest { string product_id = 1; int32 quantity = 2; string currency = 3; string customer_tier = 4; // e.g., "standard", "premium", "vip" } // Price calculation response message CalculatePriceResponse { double base_price = 1; double discount = 2; double final_price = 3; string currency = 4; } // Request to get discount for a customer message GetDiscountRequest { string customer_id = 1; string product_category = 2; } // Discount response message GetDiscountResponse { double discount_percentage = 1; string discount_code = 2; bool is_applicable = 3; } // External Pricing service (hypothetical dependency) service PricingService { // Calculate price for a product rpc CalculatePrice(CalculatePriceRequest) returns (CalculatePriceResponse); // Get applicable discount for a customer rpc GetDiscount(GetDiscountRequest) returns (GetDiscountResponse); } ================================================ FILE: examples/ktor-example/src/main/resources/application.yaml ================================================ port: 8080 database: jdbcUrl: "jdbc:postgresql://localhost:5432/stove" host: "localhost" port: 1234 name: "stove" username: "" password: "" kafka: bootstrapServers: "localhost:9092" groupId: "test-group" clientId: "test-client" interceptorClasses: [] topics: product: topic: "product" retry: "product.retry" error: "product.error" ================================================ FILE: examples/ktor-example/src/main/resources/logback.xml ================================================ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n ================================================ FILE: examples/ktor-example/src/test/kotlin/com/stove/ktor/example/e2e/ExampleTest.kt ================================================ package com.stove.ktor.example.e2e import arrow.core.* import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.stove import com.trendyol.stove.system.using import com.trendyol.stove.testing.grpcmock.grpcMock import io.grpc.Status import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import stove.ktor.example.application.* import stove.ktor.example.domain.* import stove.ktor.example.grpc.* import kotlin.random.Random import kotlin.time.Duration.Companion.seconds class ExampleTest : FunSpec({ data class ProductOfTest( val id: Long, val name: String ) test("should save product when both Feature Toggle and Pricing services respond successfully") { stove { val givenId = Random.nextInt() val givenName = "T-Shirt, Red, M" val givenUserId = "user-123" val expectedPrice = 29.99 // ===================================================== // Mock MULTIPLE gRPC services in the SAME grpcMock block // All services are handled by the same mock server // ===================================================== grpcMock { // Mock #1: Feature Toggle Service - enable the feature mockUnary( serviceName = "featuretoggle.FeatureToggleService", methodName = "IsFeatureEnabled", response = IsFeatureEnabledResponse .newBuilder() .setEnabled(true) .setVariant("default") .build() ) // Mock #2: Pricing Service - return calculated price mockUnary( serviceName = "pricing.PricingService", methodName = "CalculatePrice", response = CalculatePriceResponse .newBuilder() .setBasePrice(34.99) .setDiscount(5.00) .setFinalPrice(expectedPrice) .setCurrency("USD") .build() ) } postgresql { shouldExecute( """ DROP TABLE IF EXISTS Products; CREATE TABLE IF NOT EXISTS Products ( id serial PRIMARY KEY, name VARCHAR (50) NOT NULL ); """.trimIndent() ) shouldExecute("INSERT INTO Products (id, name) VALUES ('$givenId', 'T-Shirt, Red, S')") } http { postAndExpectBodilessResponse( "/products/$givenId", body = UpdateProductRequest(givenName, givenUserId).some(), token = None ) { actual -> actual.status shouldBe 200 } } postgresql { shouldQuery("Select * FROM Products WHERE id=$givenId", mapper = { row -> ProductOfTest(row.long("id"), row.string("name")) }) { it.count() shouldBe 1 it.first().name shouldBe givenName } } kafka { shouldBePublished(5.seconds) { actual.id == givenId && actual.name == givenName && actual.price == expectedPrice } shouldBeConsumed(20.seconds) { actual.id == givenId && actual.name == givenName } } } } test("should reject update when Feature Toggle is disabled (Pricing not called)") { stove { val givenId = Random.nextInt() val givenName = "T-Shirt, Blue, L" val givenUserId = "user-456" grpcMock { // Feature Toggle disabled - Pricing service won't be called mockUnary( serviceName = "featuretoggle.FeatureToggleService", methodName = "IsFeatureEnabled", response = IsFeatureEnabledResponse .newBuilder() .setEnabled(false) .build() ) // Note: No Pricing mock needed - feature check fails first } postgresql { shouldExecute( """ DROP TABLE IF EXISTS Products; CREATE TABLE IF NOT EXISTS Products ( id serial PRIMARY KEY, name VARCHAR (50) NOT NULL ); """.trimIndent() ) shouldExecute("INSERT INTO Products (id, name) VALUES ('$givenId', 'T-Shirt, Blue, S')") } http { postAndExpectBodilessResponse( "/products/$givenId", body = UpdateProductRequest(givenName, givenUserId).some(), token = None ) { actual -> actual.status shouldBe 400 } } // Verify product was NOT updated postgresql { shouldQuery("Select * FROM Products WHERE id=$givenId", mapper = { row -> ProductOfTest(row.long("id"), row.string("name")) }) { it.first().name shouldBe "T-Shirt, Blue, S" } } } } test("should handle Pricing Service failure gracefully") { stove { val givenId = Random.nextInt() val givenName = "T-Shirt, Green, XL" val givenUserId = "user-789" grpcMock { // Feature Toggle enabled mockUnary( serviceName = "featuretoggle.FeatureToggleService", methodName = "IsFeatureEnabled", response = IsFeatureEnabledResponse .newBuilder() .setEnabled(true) .build() ) // Pricing Service returns error mockError( serviceName = "pricing.PricingService", methodName = "CalculatePrice", status = Status.Code.UNAVAILABLE, message = "Pricing service is temporarily unavailable" ) } postgresql { shouldExecute( """ DROP TABLE IF EXISTS Products; CREATE TABLE IF NOT EXISTS Products ( id serial PRIMARY KEY, name VARCHAR (50) NOT NULL ); """.trimIndent() ) shouldExecute("INSERT INTO Products (id, name) VALUES ('$givenId', 'T-Shirt, Green, S')") } http { postAndExpectBodilessResponse( "/products/$givenId", body = UpdateProductRequest(givenName, givenUserId).some(), token = None ) { actual -> // Should fail because pricing service is unavailable actual.status shouldBe 400 } } } } test("should mock different responses for same service based on request matching") { stove { val givenId = Random.nextInt() val givenName = "Premium T-Shirt" val givenUserId = "vip-user" grpcMock { // Feature Toggle - enabled mockUnary( serviceName = "featuretoggle.FeatureToggleService", methodName = "IsFeatureEnabled", response = IsFeatureEnabledResponse .newBuilder() .setEnabled(true) .setVariant("premium") .build() ) // Pricing - VIP price with bigger discount mockUnary( serviceName = "pricing.PricingService", methodName = "CalculatePrice", response = CalculatePriceResponse .newBuilder() .setBasePrice(99.99) .setDiscount(30.00) // VIP gets 30% off .setFinalPrice(69.99) .setCurrency("USD") .build() ) } postgresql { shouldExecute( """ DROP TABLE IF EXISTS Products; CREATE TABLE IF NOT EXISTS Products ( id serial PRIMARY KEY, name VARCHAR (50) NOT NULL ); """.trimIndent() ) shouldExecute("INSERT INTO Products (id, name) VALUES ('$givenId', 'Old Name')") } http { postAndExpectBodilessResponse( "/products/$givenId", body = UpdateProductRequest(givenName, givenUserId).some(), token = None ) { actual -> actual.status shouldBe 200 } } kafka { shouldBePublished(5.seconds) { actual.price == 69.99 // VIP price } } } } test("stove should be able to override the test deps") { stove { using { (this is NoOpLockProvider) shouldBe true } } } }) ================================================ FILE: examples/ktor-example/src/test/kotlin/com/stove/ktor/example/e2e/StoveConfig.kt ================================================ package com.stove.ktor.example.e2e import com.trendyol.stove.dashboard.* import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.ktor.* import com.trendyol.stove.postgres.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.* import com.trendyol.stove.testing.grpcmock.* import com.trendyol.stove.tracing.tracing import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import stove.ktor.example.app.objectMapperRef import stove.ktor.example.run class StoveConfig : AbstractProjectConfig() { companion object { private val appPort = PortFinder.findAvailablePort() init { stoveKafkaBridgePortDefault = PortFinder.findAvailablePortAsString() System.setProperty(STOVE_KAFKA_BRIDGE_PORT, stoveKafkaBridgePortDefault) } } override val extensions: List = listOf(StoveKotestExtension()) @Suppress("LongMethod") override suspend fun beforeProject() = Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$appPort" ) } bridge() tracing { enableSpanReceiver() } dashboard { DashboardSystemOptions(appName = "ktor-example") } postgresql { PostgresqlOptions(configureExposedConfiguration = { cfg -> listOf( "database.jdbcUrl=${cfg.jdbcUrl}", "database.host=${cfg.host}", "database.port=${cfg.port}", "database.username=${cfg.username}", "database.password=${cfg.password}" ) }) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(objectMapperRef), containerOptions = KafkaContainerOptions(tag = "8.0.3") ) { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.interceptorClasses=${it.interceptorClass}" ) } } // ===================================================== // Single gRPC mock server for ALL external gRPC services // Uses dynamic port (0) to avoid CI conflicts // ===================================================== grpcMock { GrpcMockSystemOptions( // port = 0 by default (dynamic port) removeStubAfterRequestMatched = true, configureExposedConfiguration = { cfg -> // Both gRPC clients in the app point to the SAME mock server listOf( "featureToggle.host=${cfg.host}", "featureToggle.port=${cfg.port}", "pricing.host=${cfg.host}", "pricing.port=${cfg.port}" ) } ) } ktor( withParameters = listOf( "port=$appPort" // gRPC settings are now auto-injected via grpcMock's configureExposedConfiguration ), runner = { parameters -> run(parameters) { addTestSystemDependencies() } } ) }.run() override suspend fun afterProject() { Stove.stop() } } ================================================ FILE: examples/ktor-example/src/test/kotlin/com/stove/ktor/example/e2e/TestStub.kt ================================================ package com.stove.ktor.example.e2e import org.koin.core.module.Module import org.koin.dsl.* import stove.ktor.example.application.LockProvider import java.time.Duration fun addTestSystemDependencies(): Module = module { single { NoOpLockProvider() }.bind() } class NoOpLockProvider : LockProvider { override suspend fun acquireLock( name: String, duration: Duration ): Boolean { println("from NoOpLockProvider") return true } override suspend fun releaseLock(name: String) = Unit } ================================================ FILE: examples/ktor-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.ktor.example.e2e.StoveConfig ================================================ FILE: examples/ktor-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: examples/micronaut-example/build.gradle.kts ================================================ import com.trendyol.stove.gradle.stoveTracing plugins { kotlin("jvm") version libs.versions.kotlin kotlin("plugin.serialization") version libs.versions.kotlin alias(libs.plugins.google.ksp) alias(libs.plugins.micronaut.application) alias(libs.plugins.micronaut.aot) application idea } dependencies { runtimeOnly(libs.snakeyaml) ksp(platform(libs.micronaut.platform)) ksp(libs.micronaut.inject.kotlin) implementation(libs.micronaut.kotlin.runtime) implementation(libs.micronaut.serde.jackson) implementation(libs.micronaut.http.client) implementation(libs.micronaut.http.server.netty) implementation(libs.micronaut.inject) implementation(libs.micronaut.core) implementation(libs.micronaut.micrometer.core) implementation(libs.micronaut.data.r2dbc) implementation(libs.jackson.kotlin) implementation(libs.kafka) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.core) implementation(libs.kotlinx.reactive) implementation(libs.r2dbc.postgresql) implementation(libs.postgresql) implementation(libs.jackson.kotlin) implementation(libs.kotlinx.slf4j) } dependencies { testImplementation(projects.stove.lib.stoveHttp) testImplementation(projects.stove.lib.stoveWiremock) testImplementation(projects.stove.lib.stovePostgres) testImplementation(projects.stove.lib.stoveElasticsearch) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.lib.stoveTracing) testImplementation(projects.stove.starters.micronaut.stoveMicronaut) testImplementation(projects.testExtensions.stoveExtensionsKotest) } application { mainClass = "stove.micronaut.example.ApplicationKt" } graalvmNative.toolchainDetection = false java { sourceCompatibility = JavaVersion.toVersion("17") } stoveTracing { serviceName = "micronaut-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } micronaut { version(libs.versions.micronaut.platform.get()) runtime("netty") testRuntime("kotest5") processing { incremental(true) annotations("stove.micronaut.example.*") } aot { optimizeServiceLoading = false convertYamlToJava = false precomputeOperations = true cacheEnvironment = true optimizeClassLoading = true deduceEnvironment = true optimizeNetty = true replaceLogbackXml = true } } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/Application.kt ================================================ package stove.micronaut.example import io.micronaut.context.ApplicationContext import io.micronaut.runtime.EmbeddedApplication fun main(args: Array) { run(args) } fun run( args: Array, init: ApplicationContext.() -> Unit = {} ): ApplicationContext { val context = ApplicationContext .builder() .args(*args) .build() .also(init) .start() context.findBean(EmbeddedApplication::class.java).ifPresent { app -> if (!app.isRunning) { app.start() } } return context } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/application/domain/Product.kt ================================================ package stove.micronaut.example.application.domain import io.micronaut.serde.annotation.Serdeable import java.util.* @Serdeable data class Product( val id: String, val name: String, val supplierId: Long, val isBlacklist: Boolean, val createdDate: Date ) { companion object { fun new(id: String, name: String, supplierId: Long, isBlacklist: Boolean): Product = Product( id = id, name = name, supplierId = supplierId, createdDate = Date(), isBlacklist = isBlacklist ) } } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/application/repository/ProductRepository.kt ================================================ package stove.micronaut.example.application.repository import stove.micronaut.example.application.domain.Product interface ProductRepository { suspend fun save(product: Product): Product suspend fun findById(id: Long): Product? } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/application/services/ProductService.kt ================================================ package stove.micronaut.example.application.services import jakarta.inject.Singleton import stove.micronaut.example.application.domain.Product import stove.micronaut.example.application.repository.ProductRepository import stove.micronaut.example.infrastructure.http.SupplierHttpService @Singleton class ProductService( private val productRepository: ProductRepository, private val supplierHttpService: SupplierHttpService ) { suspend fun createProduct(id: String, productName: String, supplierId: Long): Product { val supplier = supplierHttpService.getSupplierPermission(supplierId) val product = Product.new(id, productName, supplierId, supplier!!.isBlacklisted) productRepository.save(product) return product } } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/application/services/SupplierService.kt ================================================ package stove.micronaut.example.application.services import io.micronaut.serde.annotation.Serdeable @Serdeable data class SupplierPermission( val id: Long, val isBlacklisted: Boolean ) interface SupplierService { suspend fun getSupplierPermission(supplierId: Long): SupplierPermission? } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/infrastructure/ObjectMapperConfig.kt ================================================ package stove.micronaut.example.infrastructure import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.KotlinModule import io.micronaut.context.annotation.Bean import io.micronaut.context.annotation.Factory @Factory class ObjectMapperConfig { companion object { fun createObjectMapperWithDefaults(): ObjectMapper { val isoInstantModule = SimpleModule() return ObjectMapper() .registerModule(KotlinModule.Builder().build()) .registerModule(isoInstantModule) .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } } @Bean fun objectMapper(): ObjectMapper = createObjectMapperWithDefaults() } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/infrastructure/api/ProductController.kt ================================================ package stove.micronaut.example.infrastructure.api import io.micronaut.http.annotation.* import stove.micronaut.example.application.domain.Product import stove.micronaut.example.application.services.ProductService import stove.micronaut.example.infrastructure.api.model.request.CreateProductRequest @Controller("/products") class ProductController( private val productService: ProductService ) { @Get("/index") fun get( @QueryValue keyword: String = "default" ): String = "Hi from Stove framework with $keyword" @Post("/create") suspend fun createProduct( @Body request: CreateProductRequest ): Product = productService.createProduct( id = request.id, productName = request.name, supplierId = request.supplierId ) } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/infrastructure/api/model/request/CreateProductRequest.kt ================================================ package stove.micronaut.example.infrastructure.api.model.request import io.micronaut.serde.annotation.Serdeable @Serdeable data class CreateProductRequest( val id: String, val name: String, val supplierId: Long ) ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/infrastructure/http/SupplierHttpService.kt ================================================ package stove.micronaut.example.infrastructure.http import io.micronaut.http.annotation.Get import io.micronaut.http.client.annotation.Client import io.micronaut.websocket.exceptions.WebSocketClientException import jakarta.inject.Singleton import stove.micronaut.example.application.services.SupplierPermission import stove.micronaut.example.application.services.SupplierService @Singleton class SupplierHttpService( private val supplierHttpClient: SupplierHttpClient ) : SupplierService { override suspend fun getSupplierPermission(supplierId: Long): SupplierPermission? = try { val response = supplierHttpClient.getSupplierPermission(supplierId) println("API Response: $response") response } catch (e: WebSocketClientException) { println("Error fetching supplier permission: ${e.message}") null } } @Client(id = "lookup-api") interface SupplierHttpClient { @Get("/v2/suppliers/{supplierId}?storeFrontId=1") suspend fun getSupplierPermission(supplierId: Long): SupplierPermission } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/infrastructure/persistence/ProductJdbcRepository.kt ================================================ package stove.micronaut.example.infrastructure.persistence import io.r2dbc.spi.ConnectionFactory import jakarta.inject.Singleton import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrNull import reactor.core.publisher.Flux import reactor.core.publisher.Mono import stove.micronaut.example.application.domain.Product import stove.micronaut.example.application.repository.ProductRepository import java.util.* @Singleton class ProductJdbcRepository( private val connectionFactory: ConnectionFactory ) : ProductRepository { override suspend fun save(product: Product): Product { Mono .from(connectionFactory.create()) .flatMap { connection -> Mono .from( connection .createStatement( """ INSERT INTO products (id, name, supplier_id, is_blacklist, created_date) VALUES ($1, $2, $3, $4, $5) """ ).bind(INDEX_ID, product.id) .bind(INDEX_NAME, product.name) .bind(INDEX_SUPPLIER_ID, product.supplierId) .bind(INDEX_IS_BLACKLIST, product.isBlacklist) .bind(INDEX_CREATED_DATE, product.createdDate) .execute() ).doFinally { connection.close() } }.flatMap { result -> Mono.from(result.rowsUpdated) } .awaitFirst() return product } override suspend fun findById(id: Long): Product? = Mono .from(connectionFactory.create()) .flatMapMany { connection -> Flux .from( connection .createStatement("SELECT * FROM products WHERE id = $1") .bind(INDEX_ID, id.toString()) .execute() ).flatMap { result -> result.map { row, _ -> Product( id = row.get("id", String::class.java)!!, name = row.get("name", String::class.java)!!, supplierId = row.get("supplier_id", Long::class.java)!!, isBlacklist = row.get("is_blacklist", Boolean::class.java)!!, createdDate = row.get("created_date", Date::class.java)!! ) } }.doFinally { connection.close() } }.next() .awaitFirstOrNull() companion object { private const val INDEX_ID = 0 private const val INDEX_NAME = 1 private const val INDEX_SUPPLIER_ID = 2 private const val INDEX_IS_BLACKLIST = 3 private const val INDEX_CREATED_DATE = 4 } } ================================================ FILE: examples/micronaut-example/src/main/kotlin/stove/micronaut/example/infrastructure/postgres/PostgresConfiguration.kt ================================================ package stove.micronaut.example.infrastructure.postgres import io.micronaut.context.annotation.Factory import io.r2dbc.spi.ConnectionFactory import jakarta.inject.Singleton @Factory class PostgresConfiguration { @Singleton fun connectionFactory(connectionFactory: ConnectionFactory): ConnectionFactory = connectionFactory } ================================================ FILE: examples/micronaut-example/src/main/resources/application.yml ================================================ micronaut: application: name: micronaut-example server: port: 8080 http: services: lookup-api: url: http://localhost:7079 connect-timeout: 2s read-timeout: 22s micrometer: metrics: enabled: true common-tags: application: "micronaut-example" r2dbc: datasources: default: url: r2dbc:postgresql://localhost:5432/stove username: postgres password: postgres ================================================ FILE: examples/micronaut-example/src/main/resources/logback.xml ================================================ %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n ================================================ FILE: examples/micronaut-example/src/test/kotlin/com/stove/micronaut/example/e2e/CreateProductsTableMigration.kt ================================================ package com.stove.micronaut.example.e2e import com.trendyol.stove.postgres.PostgresSqlMigrationContext import com.trendyol.stove.postgres.PostgresqlMigration import org.slf4j.Logger import org.slf4j.LoggerFactory class CreateProductsTableMigration : PostgresqlMigration { private val logger: Logger = LoggerFactory.getLogger(CreateProductsTableMigration::class.java) override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { logger.info("Creating products table") connection.operations.execute( """ DROP TABLE IF EXISTS products; CREATE TABLE IF NOT EXISTS products ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, supplier_id BIGINT NOT NULL, is_blacklist BOOLEAN NOT NULL DEFAULT false, created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); """.trimIndent() ) logger.info("Products table created") } } ================================================ FILE: examples/micronaut-example/src/test/kotlin/com/stove/micronaut/example/e2e/ProductControllerTest.kt ================================================ package com.stove.micronaut.example.e2e import arrow.core.some import com.trendyol.stove.http.http import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain import io.r2dbc.spi.ConnectionFactory import stove.micronaut.example.application.domain.Product import stove.micronaut.example.application.services.SupplierPermission import stove.micronaut.example.infrastructure.api.model.request.CreateProductRequest import java.util.* class ProductControllerTest : FunSpec({ test("index should be reachable") { stove { http { get("/products/index", queryParams = mapOf("keyword" to "index")) { actual -> actual shouldContain "Hi from Stove framework with index" println(actual) } } } } test("should save product to PostgreSQL when product creation request is sent") { val id = UUID.randomUUID().toString() val request = CreateProductRequest(id = id, name = "product name", supplierId = 120688) val supplierMock = SupplierPermission(id = 120688, isBlacklisted = false) stove { wiremock { mockGet( "/v2/suppliers/${supplierMock.id}?storeFrontId=1", statusCode = 200, responseBody = supplierMock.some() ) } http { postAndExpectJson("/products/create", body = request.some()) { actual -> actual.supplierId shouldBe 120688 actual.name shouldBe "product name" } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = '${request.id}'", mapper = { row -> Product( id = row.string("id"), name = row.string("name"), supplierId = row.long("supplier_id"), isBlacklist = row.boolean("is_blacklist"), createdDate = Date(row.sqlTimestamp("created_date").time) ) } ) { products -> products.size shouldBe 1 products.first().name shouldBe request.name products.first().id shouldBe request.id products.first().supplierId shouldBe request.supplierId products.first().isBlacklist shouldBe false } } } } test("a bean from application should be reachable") { stove { using { this shouldNotBe null } } } }) ================================================ FILE: examples/micronaut-example/src/test/kotlin/com/stove/micronaut/example/e2e/StoveConfig.kt ================================================ package com.stove.micronaut.example.e2e import com.trendyol.stove.dashboard.DashboardSystemOptions import com.trendyol.stove.dashboard.dashboard import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.micronaut.* import com.trendyol.stove.postgres.* import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import stove.micronaut.example.run as runMicronautApp class StoveConfig : AbstractProjectConfig() { private val appPort = PortFinder.findAvailablePort() override val extensions: List = listOf(StoveKotestExtension()) private val logger: Logger = LoggerFactory.getLogger("WireMockMonitor") @Suppress("LongMethod") override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$appPort" ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "r2dbc.datasources.default.url=r2dbc:postgresql://${cfg.host}:${cfg.port}/stove", "r2dbc.datasources.default.username=${cfg.username}", "r2dbc.datasources.default.password=${cfg.password}" ) } ).migrations { register() } } bridge() tracing { enableSpanReceiver() } dashboard { DashboardSystemOptions(appName = "micronaut-example") } wiremock { WireMockSystemOptions( port = 0, removeStubAfterRequestMatched = true, afterRequest = { e, _ -> logger.info(e.request.toString()) }, configureExposedConfiguration = { cfg -> listOf("micronaut.http.services.lookup-api.url=${cfg.baseUrl}") } ) } micronaut( runner = { parameters -> runMicronautApp(parameters) { } }, withParameters = listOf( "micronaut.server.port=$appPort", "logging.level.root=info", "logging.level.org.micronaut.web=info" ) ) }.run() } override suspend fun afterProject(): Unit = Stove.stop() } ================================================ FILE: examples/micronaut-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.micronaut.example.e2e.StoveConfig ================================================ FILE: examples/micronaut-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: examples/quarkus-example/build.gradle.kts ================================================ import com.trendyol.stove.gradle.stoveTracing @DisableCachingByDefault(because = "Creates an empty classes directory required by Quarkus code generation") abstract class EnsureDirectoryTask : DefaultTask() { @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction fun createDirectory() { outputDirectory.get().asFile.mkdirs() } } plugins { alias(libs.plugins.quarkus) alias(libs.plugins.allopen) idea application } dependencies { implementation(enforcedPlatform(libs.quarkus)) implementation(libs.quarkus.rest) implementation(libs.quarkus.rest.jackson) implementation(libs.quarkus.arc) implementation(libs.quarkus.kotlin) implementation(libs.quarkus.agroal) implementation(libs.quarkus.jdbc.postgresql) implementation(libs.quarkus.flyway) implementation(libs.quarkus.messaging.kafka) implementation(libs.jackson.kotlin) implementation(libs.opentelemetry.instrumentation.annotations) } dependencies { testImplementation(projects.stove.testExtensions.stoveExtensionsKotest) testImplementation(projects.stove.lib.stoveHttp) testImplementation(projects.stove.lib.stoveWiremock) testImplementation(projects.stove.lib.stovePostgres) testImplementation(projects.stove.lib.stoveKafka) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.lib.stoveTracing) testImplementation(projects.stove.starters.quarkus.stoveQuarkus) } allOpen { annotation("jakarta.ws.rs.Path") annotation("jakarta.enterprise.context.ApplicationScoped") } application { mainClass.set("stove.quarkus.example.QuarkusMainApp") } val ensureJavaMainClassesDir by tasks.registering(EnsureDirectoryTask::class) { outputDirectory.set(layout.buildDirectory.dir("classes/java/main")) } tasks.matching { it.name == "quarkusGenerateCode" || it.name == "quarkusGenerateCodeTests" }.configureEach { dependsOn(ensureJavaMainClassesDir) } tasks.named("test") { dependsOn("quarkusBuild") } kotlin { compilerOptions { javaParameters = true } } stoveTracing { serviceName = "quarkus-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/QuarkusMainApp.kt ================================================ package stove.quarkus.example import io.quarkus.runtime.Quarkus import io.quarkus.runtime.annotations.QuarkusMain @QuarkusMain object QuarkusMainApp { @JvmStatic fun main(args: Array) { Quarkus.run(*args) } } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/StoveStartupSignal.kt ================================================ package stove.quarkus.example import io.quarkus.runtime.ShutdownEvent import io.quarkus.runtime.StartupEvent import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.event.Observes @Suppress("unused") @ApplicationScoped class StoveStartupSignal { fun onStart( @Observes event: StartupEvent ) { System.setProperty(READY_PROPERTY, "true") } fun onStop( @Observes event: ShutdownEvent ) { System.clearProperty(READY_PROPERTY) } companion object { const val READY_PROPERTY: String = "stove.quarkus.ready" } } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/api/ProductResource.kt ================================================ package stove.quarkus.example.api import jakarta.ws.rs.* import jakarta.ws.rs.core.MediaType import stove.quarkus.example.application.ProductCreateRequest import stove.quarkus.example.application.ProductCreator @Path("/api") @Produces(MediaType.TEXT_PLAIN) class ProductResource( private val productCreator: ProductCreator ) { @GET @Path("/index") fun index( @QueryParam("keyword") keyword: String? ): String = "Hi from Stove Quarkus example with $keyword" @POST @Path("/product/create") @Consumes(MediaType.APPLICATION_JSON) fun createProduct(request: ProductCreateRequest): String = productCreator.create(request) } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/application/Models.kt ================================================ package stove.quarkus.example.application data class ProductCreateRequest( val id: Long, val name: String, val supplierId: Long ) data class ProductCreatedEvent( val id: Long, val name: String, val supplierId: Long ) data class CreateProductCommand( val id: Long, val name: String, val supplierId: Long ) data class SupplierPermission( val supplierId: Long, val isAllowed: Boolean ) fun CreateProductCommand.toCreateRequest(): ProductCreateRequest = ProductCreateRequest( id = id, name = name, supplierId = supplierId ) fun ProductCreateRequest.toProductCreatedEvent(): ProductCreatedEvent = ProductCreatedEvent( id = id, name = name, supplierId = supplierId ) ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/application/ProductCreator.kt ================================================ package stove.quarkus.example.application import io.opentelemetry.instrumentation.annotations.WithSpan import jakarta.enterprise.context.ApplicationScoped import stove.quarkus.example.infrastructure.http.SupplierHttpService import stove.quarkus.example.infrastructure.kafka.ProductEventPublisher import stove.quarkus.example.infrastructure.postgres.ProductRepository @ApplicationScoped class ProductCreator( private val supplierHttpService: SupplierHttpService, private val productRepository: ProductRepository, private val productEventPublisher: ProductEventPublisher ) { @WithSpan("ProductCreator.create") fun create(request: ProductCreateRequest): String { val supplierPermission = supplierHttpService.getSupplierPermission(request.supplierId) if (!supplierPermission.isAllowed) { return "Supplier with the given id(${request.supplierId}) is not allowed for product creation" } productRepository.save(request) productEventPublisher.publish(request.toProductCreatedEvent()) return "OK" } } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/http/SupplierHttpService.kt ================================================ package stove.quarkus.example.infrastructure.http import com.fasterxml.jackson.databind.ObjectMapper import io.opentelemetry.instrumentation.annotations.WithSpan import jakarta.enterprise.context.ApplicationScoped import org.eclipse.microprofile.config.inject.ConfigProperty import stove.quarkus.example.application.SupplierPermission import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration @ApplicationScoped class SupplierHttpService( private val objectMapper: ObjectMapper ) { @ConfigProperty(name = "clients.supplier.url") lateinit var supplierBaseUrl: String private val httpClient: HttpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofSeconds(SUPPLIER_CONNECT_TIMEOUT_SECONDS)) .build() @WithSpan("SupplierHttpService.getSupplierPermission") fun getSupplierPermission(id: Long): SupplierPermission { val request = HttpRequest .newBuilder(URI.create("$supplierBaseUrl/suppliers/$id/allowed")) .header("Accept", "application/json") .GET() .build() val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) check(response.statusCode() == HTTP_OK_STATUS) { "Supplier service returned ${response.statusCode()} for supplier $id" } return objectMapper.readValue(response.body(), SupplierPermission::class.java) } } private const val SUPPLIER_CONNECT_TIMEOUT_SECONDS = 2L private const val HTTP_OK_STATUS = 200 ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/kafka/CustomProducerInterceptor.kt ================================================ package stove.quarkus.example.infrastructure.kafka import org.apache.kafka.clients.producer.ProducerInterceptor import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.clients.producer.RecordMetadata const val USER_EMAIL_HEADER = "X-UserEmail" const val DEFAULT_USER_EMAIL_HEADER_VALUE = "stove@trendyol.com" class CustomProducerInterceptor : ProducerInterceptor { override fun onSend(record: ProducerRecord): ProducerRecord { if (record.headers().lastHeader(USER_EMAIL_HEADER) == null) { record.headers().add(USER_EMAIL_HEADER, DEFAULT_USER_EMAIL_HEADER_VALUE.toByteArray()) } return record } override fun configure(configs: MutableMap?) = Unit override fun onAcknowledgement( metadata: RecordMetadata?, exception: Exception? ) = Unit override fun close() = Unit } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/kafka/KafkaClientConfiguration.kt ================================================ package stove.quarkus.example.infrastructure.kafka import io.smallrye.common.annotation.Identifier import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.inject.Produces import org.eclipse.microprofile.config.inject.ConfigProperty @ApplicationScoped class KafkaClientConfiguration { @ConfigProperty(name = "app.kafka.bridge-interceptor-class", defaultValue = "") lateinit var bridgeInterceptorClass: String @Produces @Identifier("product-create") fun productCreateConfiguration(): Map = buildMap { put("auto.offset.reset", "earliest") put("allow.auto.create.topics", true) bridgeInterceptorClass.takeIf { it.isNotBlank() }?.let { put("interceptor.classes", it) } } @Produces @Identifier("product-created") fun productCreatedConfiguration(): Map = buildMap { put("acks", "1") put("interceptor.classes", producerInterceptors()) } private fun producerInterceptors(): String = buildList { add(CustomProducerInterceptor::class.java.name) bridgeInterceptorClass.takeIf { it.isNotBlank() }?.let(::add) }.joinToString(",") } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/kafka/KafkaSerde.kt ================================================ package stove.quarkus.example.infrastructure.kafka import io.quarkus.kafka.client.serialization.ObjectMapperDeserializer import io.quarkus.kafka.client.serialization.ObjectMapperSerializer import stove.quarkus.example.application.CreateProductCommand import stove.quarkus.example.application.ProductCreatedEvent class CreateProductCommandDeserializer : ObjectMapperDeserializer(CreateProductCommand::class.java) class ProductCreatedEventSerializer : ObjectMapperSerializer() ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/kafka/ProductCommandConsumer.kt ================================================ package stove.quarkus.example.infrastructure.kafka import io.opentelemetry.instrumentation.annotations.WithSpan import io.smallrye.common.annotation.Blocking import jakarta.enterprise.context.ApplicationScoped import org.eclipse.microprofile.reactive.messaging.Incoming import stove.quarkus.example.application.CreateProductCommand import stove.quarkus.example.application.ProductCreator import stove.quarkus.example.application.toCreateRequest @ApplicationScoped class ProductCommandConsumer( private val productCreator: ProductCreator ) { @Incoming("product-create") @Blocking @WithSpan("ProductCommandConsumer.consume") fun consume(command: CreateProductCommand) { productCreator.create(command.toCreateRequest()) } } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/kafka/ProductEventPublisher.kt ================================================ package stove.quarkus.example.infrastructure.kafka import io.opentelemetry.instrumentation.annotations.WithSpan import io.smallrye.reactive.messaging.MutinyEmitter import io.smallrye.reactive.messaging.kafka.KafkaRecord import jakarta.enterprise.context.ApplicationScoped import org.eclipse.microprofile.reactive.messaging.Channel import stove.quarkus.example.application.ProductCreatedEvent @ApplicationScoped class ProductEventPublisher( @param:Channel("product-created") private val emitter: MutinyEmitter ) { @WithSpan("ProductEventPublisher.publish") fun publish(event: ProductCreatedEvent) { emitter.sendMessageAndAwait( KafkaRecord .of(event.id.toString(), event) .withHeader("X-EventType", ProductCreatedEvent::class.simpleName!!) ) } } ================================================ FILE: examples/quarkus-example/src/main/kotlin/stove/quarkus/example/infrastructure/postgres/ProductRepository.kt ================================================ package stove.quarkus.example.infrastructure.postgres import io.opentelemetry.instrumentation.annotations.WithSpan import jakarta.enterprise.context.ApplicationScoped import stove.quarkus.example.application.ProductCreateRequest import javax.sql.DataSource @ApplicationScoped class ProductRepository( private val dataSource: DataSource ) { @WithSpan("ProductRepository.save") fun save(request: ProductCreateRequest) { dataSource.connection.use { connection -> connection .prepareStatement( """ INSERT INTO products (id, name, supplier_id) VALUES (?, ?, ?) """.trimIndent() ).use { statement -> statement.setLong(ID_PARAMETER_INDEX, request.id) statement.setString(NAME_PARAMETER_INDEX, request.name) statement.setLong(SUPPLIER_ID_PARAMETER_INDEX, request.supplierId) statement.executeUpdate() } } } } private const val ID_PARAMETER_INDEX = 1 private const val NAME_PARAMETER_INDEX = 2 private const val SUPPLIER_ID_PARAMETER_INDEX = 3 ================================================ FILE: examples/quarkus-example/src/main/resources/application.properties ================================================ quarkus.console.enabled=false quarkus.class-loading.parent-first-artifacts=org.apache.kafka:kafka-clients quarkus.http.port=8080 quarkus.datasource.db-kind=postgresql quarkus.datasource.devservices.enabled=false quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/stove quarkus.datasource.username=postgres quarkus.datasource.password=postgres quarkus.flyway.migrate-at-start=true kafka.bootstrap.servers=localhost:9092 app.kafka.bridge-interceptor-class= mp.messaging.incoming.product-create.connector=smallrye-kafka mp.messaging.incoming.product-create.topic=trendyol.stove.service.product.create.0 mp.messaging.incoming.product-create.group.id=stove-quarkus-example mp.messaging.incoming.product-create.value.deserializer=stove.quarkus.example.infrastructure.kafka.CreateProductCommandDeserializer mp.messaging.incoming.product-create.kafka-configuration=product-create mp.messaging.outgoing.product-created.connector=smallrye-kafka mp.messaging.outgoing.product-created.topic=trendyol.stove.service.product.created.0 mp.messaging.outgoing.product-created.value.serializer=stove.quarkus.example.infrastructure.kafka.ProductCreatedEventSerializer mp.messaging.outgoing.product-created.kafka-configuration=product-created clients.supplier.url=http://localhost:7078 ================================================ FILE: examples/quarkus-example/src/main/resources/db/migration/V1__create_products.sql ================================================ CREATE TABLE IF NOT EXISTS products ( id BIGINT PRIMARY KEY, name VARCHAR(255) NOT NULL, supplier_id BIGINT NOT NULL ); ================================================ FILE: examples/quarkus-example/src/test/kotlin/com/stove/quarkus/example/e2e/ExampleTest.kt ================================================ package com.stove.quarkus.example.e2e import arrow.core.some import com.trendyol.stove.http.* import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.stove import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.delay import stove.quarkus.example.application.CreateProductCommand import stove.quarkus.example.application.ProductCreateRequest import stove.quarkus.example.application.ProductCreatedEvent import stove.quarkus.example.application.SupplierPermission import stove.quarkus.example.infrastructure.kafka.DEFAULT_USER_EMAIL_HEADER_VALUE import stove.quarkus.example.infrastructure.kafka.USER_EMAIL_HEADER import kotlin.time.Duration.Companion.seconds class ExampleTest : FunSpec({ val textPlainHeaders = mapOf("Accept" to "text/plain") data class PersistedProduct( val id: Long, val name: String, val supplierId: Long ) test("index should be reachable") { stove { http { get( "/api/index", queryParams = mapOf("keyword" to testCase.name.name), headers = textPlainHeaders ) { actual -> actual shouldContain "Hi from Stove Quarkus example with ${testCase.name.name}" } } } } test("should create new product when send product create request from api for the allowed supplier") { stove { val productCreateRequest = ProductCreateRequest(1L, name = "product name", supplierId = 99L) val supplierPermission = SupplierPermission(productCreateRequest.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${productCreateRequest.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } http { postAndExpectBody( "/api/product/create", body = productCreateRequest.some(), headers = textPlainHeaders ) { actual -> actual.status shouldBe 200 actual.body() shouldBe "OK" } } kafka { shouldBePublished(5.seconds) { actual.id == productCreateRequest.id && actual.name == productCreateRequest.name && actual.supplierId == productCreateRequest.supplierId && metadata.headers[USER_EMAIL_HEADER] == DEFAULT_USER_EMAIL_HEADER_VALUE } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${productCreateRequest.id}", mapper = { row -> PersistedProduct( row.long("id"), row.string("name"), row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first() shouldBe PersistedProduct( id = productCreateRequest.id, name = productCreateRequest.name, supplierId = productCreateRequest.supplierId ) } } } } test("should return validation message when supplier is not allowed") { stove { val productCreateRequest = ProductCreateRequest(2L, name = "product name", supplierId = 98L) val supplierPermission = SupplierPermission(productCreateRequest.supplierId, isAllowed = false) wiremock { mockGet( "/suppliers/${productCreateRequest.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } http { postAndExpectBody( "/api/product/create", body = productCreateRequest.some(), headers = textPlainHeaders ) { actual -> actual.status shouldBe 200 actual.body() shouldBe "Supplier with the given id(${productCreateRequest.supplierId}) is not allowed for product creation" } } } } test("should create new product when send product create event for the allowed supplier") { stove { val createProductCommand = CreateProductCommand(4L, name = "product name", supplierId = 96L) val supplierPermission = SupplierPermission(createProductCommand.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${createProductCommand.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } kafka { publish("trendyol.stove.service.product.create.0", createProductCommand) shouldBeConsumed(10.seconds) { actual.id == createProductCommand.id && actual.name == createProductCommand.name && actual.supplierId == createProductCommand.supplierId } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${createProductCommand.id}", mapper = { row -> PersistedProduct( row.long("id"), row.string("name"), row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first() shouldBe PersistedProduct( id = createProductCommand.id, name = createProductCommand.name, supplierId = createProductCommand.supplierId ) } } kafka { shouldBePublished(10.seconds) { actual.id == createProductCommand.id && actual.name == createProductCommand.name && actual.supplierId == createProductCommand.supplierId } } } } test("tracing should capture quarkus request flow") { stove { val productCreateRequest = ProductCreateRequest(5L, name = "traced product", supplierId = 95L) val supplierPermission = SupplierPermission(productCreateRequest.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${productCreateRequest.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } http { postAndExpectBody( "/api/product/create", body = productCreateRequest.some(), headers = textPlainHeaders ) { actual -> actual.status shouldBe 200 actual.body() shouldBe "OK" } } tracing { waitForExpectedSpans( expectedOperationNames = listOf( "ProductCreator.create", "SupplierHttpService.getSupplierPermission", "ProductEventPublisher.publish" ), timeoutMs = 15_000 ) shouldContainSpan("ProductCreator.create") shouldContainSpan("SupplierHttpService.getSupplierPermission") shouldContainSpan("ProductEventPublisher.publish") spanCountShouldBeAtLeast(4) } } } }) private suspend fun com.trendyol.stove.tracing.TracingValidationScope.waitForExpectedSpans( expectedOperationNames: List, timeoutMs: Long ) { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { val spans = collector.getTrace(traceId) val operationNames = spans.map { it.operationName } val allExpectedSpansArePresent = expectedOperationNames.all { expectedOperationName -> operationNames.any { operationName -> operationName.contains(expectedOperationName) } } if (allExpectedSpansArePresent) { return } delay(250) } error( "Timeout waiting for spans: ${expectedOperationNames.joinToString()} in ${collector.getTrace(traceId).map { it.operationName }}" ) } ================================================ FILE: examples/quarkus-example/src/test/kotlin/com/stove/quarkus/example/e2e/StoveConfig.kt ================================================ package com.stove.quarkus.example.e2e import com.trendyol.stove.dashboard.DashboardSystemOptions import com.trendyol.stove.dashboard.dashboard import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.postgres.* import com.trendyol.stove.quarkus.quarkus import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.apache.kafka.clients.admin.NewTopic import stove.quarkus.example.QuarkusMainApp class StoveConfig : AbstractProjectConfig() { companion object { private val appPort = PortFinder.findAvailablePort() } override val extensions: List = listOf(StoveKotestExtension()) @Suppress("LongMethod") override suspend fun beforeProject() { Stove() .with { tracing { enableSpanReceiver() } dashboard { DashboardSystemOptions(appName = "quarkus-example") } httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$appPort" ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "quarkus.datasource.jdbc.url=${cfg.jdbcUrl}", "quarkus.datasource.username=${cfg.username}", "quarkus.datasource.password=${cfg.password}" ) } ) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(), containerOptions = KafkaContainerOptions(tag = "8.0.3") ) { listOf( "kafka.bootstrap.servers=${it.bootstrapServers}", "app.kafka.bridge-interceptor-class=${it.interceptorClass}" ) }.migrations { register() } } wiremock { WireMockSystemOptions( port = 0, removeStubAfterRequestMatched = true, configureExposedConfiguration = { cfg -> listOf("clients.supplier.url=${cfg.baseUrl}") } ) } quarkus( runner = { params -> QuarkusMainApp.main(params) }, withParameters = listOf("quarkus.http.port=$appPort") ) }.run() } override suspend fun afterProject() { Stove.stop() } } class CreateQuarkusExampleTopicsMigration : KafkaMigration { override val order: Int = 1 override suspend fun execute(connection: KafkaMigrationContext) { connection.admin .createTopics( listOf( NewTopic("trendyol.stove.service.product.create.0", 1, 1), NewTopic("trendyol.stove.service.product.created.0", 1, 1) ) ).all() .get() } } ================================================ FILE: examples/quarkus-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.quarkus.example.e2e.StoveConfig ================================================ FILE: examples/quarkus-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: examples/spring-4x-example/build.gradle.kts ================================================ import com.trendyol.stove.gradle.stoveTracing plugins { alias(libs.plugins.spring.plugin) alias(libs.plugins.spring.boot.four) idea application } dependencies { implementation(libs.spring.boot.four) implementation(libs.spring.boot.four.autoconfigure) implementation(libs.spring.boot.four.webflux) implementation(libs.spring.boot.four.actuator) annotationProcessor(libs.spring.boot.four.annotationProcessor) implementation(libs.spring.boot.four.kafka) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.core) implementation(libs.kotlinx.reactive) implementation(libs.kotlinx.slf4j) // OpenTelemetry instrumentation API for @WithSpan annotation implementation(libs.opentelemetry.instrumentation.annotations) } dependencies { testImplementation(projects.stove.testExtensions.stoveExtensionsKotest) testImplementation(libs.jackson3.kotlin) testImplementation(projects.stove.lib.stoveHttp) testImplementation(projects.stove.lib.stoveWiremock) testImplementation(projects.stove.lib.stoveTracing) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.starters.spring.stoveSpring) testImplementation(projects.stove.starters.spring.stoveSpringKafka) } application { mainClass.set("stove.spring.example4x.ExampleAppkt") } // ============================================================================ // TRACING SETUP - OpenTelemetry Java Agent (buildSrc) // ============================================================================ stoveTracing { serviceName = "spring-4x-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } tasks.test { testLogging { events("passed", "skipped", "failed", "standardOut", "standardError") showStandardStreams = true } } ================================================ FILE: examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/ExampleApp.kt ================================================ package stove.spring.example4x import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.context.ConfigurableApplicationContext @SpringBootApplication class ExampleApp fun main(args: Array) { run(args) } /** * This is the point where spring application gets run. * run(args, init) method is the important point for the testing configuration. * init allows us to override any dependency from the testing side that is being time related or configuration related. * Spring itself opens this configuration higher order function to the outside. */ fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext = runApplication(*args, init = init) ================================================ FILE: examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/application/handlers/ProductCreator.kt ================================================ package stove.spring.example4x.application.handlers import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.stereotype.Service import stove.spring.example4x.infrastructure.api.ProductCreateRequest @Service class ProductCreator { @WithSpan("ProductCreator.create") suspend fun create(request: ProductCreateRequest) { // In a real application, this would persist the product println("Creating product: ${request.name} with id ${request.id}") } } data class ProductCreatedEvent( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/api/ProductController.kt ================================================ package stove.spring.example4x.infrastructure.api import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import stove.spring.example4x.application.handlers.* import stove.spring.example4x.infrastructure.messaging.kafka.KafkaProducer @RestController @RequestMapping("/api") class ProductController( private val productCreator: ProductCreator, private val kafkaProducer: KafkaProducer ) { @GetMapping("/index") suspend fun index( @RequestParam(required = false) keyword: String? ): ResponseEntity = ResponseEntity.ok("Hi from Stove framework with ${keyword ?: "no keyword"}") @WithSpan("ProductController.create") @PostMapping("/product/create") suspend fun create( @RequestBody request: ProductCreateRequest ): ResponseEntity { productCreator.create(request) kafkaProducer.send(ProductCreatedEvent(request.id, request.name, request.supplierId)) return ResponseEntity.ok().build() } } data class ProductCreateRequest( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaConfiguration.kt ================================================ @file:Suppress("DEPRECATION") package stove.spring.example4x.infrastructure.messaging.kafka import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.springframework.boot.context.properties.* import org.springframework.context.annotation.* import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.RecordInterceptor import org.springframework.kafka.support.serializer.* @Configuration @EnableKafka @EnableConfigurationProperties(KafkaProperties::class) class KafkaConfiguration { @Bean fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor? ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConsumerFactory(consumerFactory) interceptor?.let { factory.setRecordInterceptor(it) } return factory } @Bean @Suppress("MagicNumber") fun consumerFactory( config: KafkaProperties ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ErrorHandlingDeserializer::class.java, ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS to StringDeserializer::class.java, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to config.heartbeatInSeconds * 1000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to config.heartbeatInSeconds * 3000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to config.heartbeatInSeconds * 3000 ) ) @Bean fun kafkaTemplate( config: KafkaProperties ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JacksonJsonSerializer::class.java, ProducerConfig.ACKS_CONFIG to config.acks ) ) ) } @ConfigurationProperties(prefix = "kafka") data class KafkaProperties( val bootstrapServers: String, val groupId: String = "spring-4x-example", val offset: String = "earliest", val acks: String = "1", val heartbeatInSeconds: Int = 3, val topicPrefix: String = "trendyol.stove.service" ) ================================================ FILE: examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaProducer.kt ================================================ package stove.spring.example4x.infrastructure.messaging.kafka import io.opentelemetry.instrumentation.annotations.WithSpan import kotlinx.coroutines.future.await import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Component import stove.spring.example4x.application.handlers.ProductCreatedEvent @Component class KafkaProducer( private val kafkaTemplate: KafkaTemplate, private val kafkaProperties: KafkaProperties ) { @WithSpan("KafkaProducer.send") suspend fun send(event: ProductCreatedEvent) { val topic = "${kafkaProperties.topicPrefix}.productCreated.1" kafkaTemplate.send(topic, event.id.toString(), event).await() } } ================================================ FILE: examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/ProductCreateConsumer.kt ================================================ package stove.spring.example4x.infrastructure.messaging.kafka import org.slf4j.* import org.springframework.kafka.annotation.KafkaListener import org.springframework.messaging.handler.annotation.* import org.springframework.stereotype.Component import stove.spring.example4x.application.handlers.ProductCreator import stove.spring.example4x.infrastructure.api.ProductCreateRequest import tools.jackson.databind.json.JsonMapper @Component class ProductCreateConsumer( private val productCreator: ProductCreator, private val jsonMapper: JsonMapper ) { private val logger: Logger = LoggerFactory.getLogger(javaClass) @KafkaListener(topics = ["trendyol.stove.service.product.create.0"], groupId = "\${kafka.groupId}") suspend fun consume( @Payload message: String, @Header("X-UserEmail", required = false) userEmail: String? ) { logger.info("Received message: $message with userEmail: $userEmail") val command = jsonMapper.readValue(message, CreateProductCommand::class.java) productCreator.create(ProductCreateRequest(command.id, command.name, command.supplierId)) } } @Component class ProductEventsConsumer { private val logger: Logger = LoggerFactory.getLogger(javaClass) @KafkaListener( topics = ["trendyol.stove.service.productCreated.1"], groupId = "\${kafka.groupId}", containerFactory = "kafkaListenerContainerFactory" ) fun consumeProductCreatedEvent( @Payload message: String ) { logger.info("Received message: $message") } } data class CreateProductCommand( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-4x-example/src/main/resources/application.yml ================================================ spring: application: name: "stove-spring-4x-example" server: port: 8001 kafka: bootstrapServers: localhost:9092 topicPrefix: trendyol.stove.service acks: "1" offset: "latest" heartbeatInSeconds: 30 groupId: spring-4x-example ================================================ FILE: examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/ExampleTest.kt ================================================ package com.stove.spring.example4x.e2e import arrow.core.some import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import stove.spring.example4x.application.handlers.ProductCreatedEvent import stove.spring.example4x.infrastructure.api.ProductCreateRequest import stove.spring.example4x.infrastructure.messaging.kafka.CreateProductCommand import kotlin.time.Duration.Companion.seconds class ExampleTest : FunSpec({ test("index should be reachable") { stove { http { get("/api/index", queryParams = mapOf("keyword" to testCase.name.name)) { actual -> actual shouldContain "Hi from Stove framework with ${testCase.name.name}" println(actual) } get("/api/index") { actual -> actual shouldContain "Hi from Stove framework with" println(actual) } } } } test("should create new product when send product create request from api") { stove { val productCreateRequest = ProductCreateRequest(1L, name = "product name", 99L) http { postAndExpectBodilessResponse(uri = "/api/product/create", body = productCreateRequest.some()) { actual -> actual.status shouldBe 200 } } kafka { shouldBePublished(5.seconds) { actual.id == productCreateRequest.id && actual.name == productCreateRequest.name && actual.supplierId == productCreateRequest.supplierId } shouldBeConsumed(5.seconds) { actual.id == productCreateRequest.id && actual.name == productCreateRequest.name && actual.supplierId == productCreateRequest.supplierId } } } } test("should consume product create command from kafka") { stove { val createProductCommand = CreateProductCommand(2L, name = "product from kafka", 100L) kafka { publish("trendyol.stove.service.product.create.0", createProductCommand) shouldBeConsumed(10.seconds) { actual.id == createProductCommand.id && actual.name == createProductCommand.name && actual.supplierId == createProductCommand.supplierId } } } } }) ================================================ FILE: examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/StoveConfig.kt ================================================ package com.stove.spring.example4x.e2e import com.trendyol.stove.dashboard.DashboardSystemOptions import com.trendyol.stove.dashboard.dashboard import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.spring.* import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import stove.spring.example4x.run import tools.jackson.databind.json.JsonMapper class StoveConfig : AbstractProjectConfig() { private val appPort = PortFinder.findAvailablePort() override val extensions: List = listOf(StoveKotestExtension()) private val logger: Logger = LoggerFactory.getLogger("WireMockMonitor") override suspend fun beforeProject(): Unit = Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$appPort" ) } kafka { KafkaSystemOptions( containerOptions = KafkaContainerOptions(tag = "8.0.3"), configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=spring-4x-example" ) } ) } bridge() // Enable tracing - starts OTLP gRPC receiver on port 4317 // Service name is automatically extracted from incoming spans (set by OTel agent) tracing { enableSpanReceiver() } dashboard { DashboardSystemOptions(appName = "spring-4x-example") } wiremock { WireMockSystemOptions( port = 0, removeStubAfterRequestMatched = true, afterRequest = { e, _ -> logger.info(e.request.toString()) } ) } springBoot( runner = { parameters -> // The application will be auto-instrumented by OTel agent // configured in build.gradle.kts tasks.test { } run(parameters) { addTestDependencies4x { registerBean>(primary = true) registerBean { val jsonMapper = this.bean() StoveJackson3ThroughIfStringSerde(jsonMapper) } } } }, withParameters = listOf( "server.port=$appPort", "logging.level.root=info", "logging.level.org.springframework.web=info", "spring.profiles.active=default", "kafka.heartbeatInSeconds=2", "kafka.offset=earliest" ) ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } ================================================ FILE: examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/jackson3.kt ================================================ package com.stove.spring.example4x.e2e import com.trendyol.stove.serialization.StoveSerde import org.slf4j.LoggerFactory import tools.jackson.databind.json.JsonMapper class StoveJackson3ThroughIfStringSerde( private val jsonMapper: JsonMapper ) : StoveSerde { private val logger = LoggerFactory.getLogger(javaClass) override fun serialize(value: Any): ByteArray = when (value) { is ByteArray -> { logger.info("Value is already a ByteArray, returning as is.") value } is String -> { logger.info("Serializing String value.") val byteArray = value.toByteArray() byteArray } else -> { logger.info("Serializing value of type: {}", value::class.java.name) val byteArray = runCatching { jsonMapper.writeValueAsBytes(value) } .onFailure { logger.error("Serialization failed", it) } .getOrThrow() byteArray } } override fun deserialize(value: ByteArray, clazz: Class): T { logger.info("Deserializing to class: {}", clazz.name) val value = runCatching { jsonMapper.readValue(value, clazz) }.onFailure { logger.error("Deserialization failed", it) }.getOrThrow() logger.info("Deserialized value: {}", value) return value } } ================================================ FILE: examples/spring-4x-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.spring.example4x.e2e.StoveConfig ================================================ FILE: examples/spring-4x-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: examples/spring-example/build.gradle.kts ================================================ import com.trendyol.stove.gradle.stoveTracing plugins { alias(libs.plugins.spring.plugin) alias(libs.plugins.spring.boot.three) idea application } stoveTracing { serviceName = "spring-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } dependencies { implementation(libs.spring.boot.three) implementation(libs.spring.boot.three.autoconfigure) implementation(libs.spring.boot.three.webflux) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.jackson.json) implementation(libs.spring.boot.three.actuator) annotationProcessor(libs.spring.boot.three.annotationProcessor) implementation(libs.spring.boot.three.kafka) implementation(libs.exposed.core) implementation(libs.exposed.jdbc) implementation(libs.exposed.java.time) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.core) implementation(libs.kotlinx.reactive) implementation(libs.postgresql) implementation(libs.jackson.kotlin) implementation(libs.kotlinx.slf4j) implementation(libs.hikari) } dependencies { testImplementation(projects.stove.testExtensions.stoveExtensionsKotest) testImplementation(projects.stove.testExtensions.stoveExtensionsJunit) testImplementation(projects.stove.lib.stoveHttp) testImplementation(projects.stove.lib.stoveWiremock) testImplementation(projects.stove.lib.stovePostgres) testImplementation(projects.stove.lib.stoveElasticsearch) testImplementation(projects.stove.starters.spring.stoveSpring) testImplementation(projects.stove.starters.spring.stoveSpringKafka) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.lib.stoveTracing) } application { mainClass.set("stove.spring.example.ExampleAppKt") } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/ExampleApp.kt ================================================ package stove.spring.example import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.context.ConfigurableApplicationContext @SpringBootApplication @ConfigurationPropertiesScan class ExampleApp fun main(args: Array) { run(args) } /** * This is the point where spring application gets run. * run(args, init) method is the important point for the testing configuration. * init allows us to override any dependency from the testing side that is being time related or configuration related. * Spring itself opens this configuration higher order function to the outside. */ fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext = runApplication(*args, init = init) ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/application/handlers/ProductCreator.kt ================================================ package stove.spring.example.application.handlers import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import stove.spring.example.domain.Products import stove.spring.example.infrastructure.Headers import stove.spring.example.infrastructure.http.SupplierHttpService import stove.spring.example.infrastructure.messaging.kafka.* import stove.spring.example.infrastructure.messaging.kafka.consumers.CreateProductCommand import java.time.Instant @Component class ProductCreator( private val supplierHttpService: SupplierHttpService, private val kafkaProducer: KafkaProducer ) { @Value("\${kafka.producer.product-created.topic-name}") lateinit var productCreatedTopic: String suspend fun create(req: ProductCreateRequest): String = suspendTransaction { val supplierPermission = supplierHttpService.getSupplierPermission(req.supplierId) if (!supplierPermission.isAllowed) { return@suspendTransaction "Supplier with the given id(${req.supplierId}) is not allowed for product creation" } Products.insert { it[id] = req.id it[name] = req.name it[supplierId] = req.supplierId it[Products.createdDate] = Instant.now() } kafkaProducer.send( KafkaOutgoingMessage( topic = productCreatedTopic, key = req.id.toString(), headers = mapOf(Headers.EVENT_TYPE to ProductCreatedEvent::class.simpleName!!), partition = 0, payload = req.mapToProductCreatedEvent() ) ) return@suspendTransaction "OK" } } fun CreateProductCommand.mapToCreateRequest(): ProductCreateRequest = ProductCreateRequest(this.id, this.name, this.supplierId) fun ProductCreateRequest.mapToProductCreatedEvent(): ProductCreatedEvent = ProductCreatedEvent( this.id, this.name, this.supplierId, Instant.now() ) data class ProductCreatedEvent( val id: Long, val name: String, val supplierId: Long, val createdDate: Instant, val type: String = ProductCreatedEvent::class.simpleName!! ) data class ProductCreateRequest( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/application/services/SupplierService.kt ================================================ package stove.spring.example.application.services data class SupplierPermission( val supplierId: Long, val isAllowed: Boolean ) interface SupplierService { suspend fun getSupplierPermission(id: Long): SupplierPermission } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/domain/ProductTable.kt ================================================ package stove.spring.example.domain import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.timestamp object Products : Table("products") { val id = long("id") val name = varchar("name", 255) val supplierId = long("supplier_id") val createdDate = timestamp("created_date") override val primaryKey = PrimaryKey(id) } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/Constants.kt ================================================ package stove.spring.example.infrastructure import org.slf4j.MDC import java.net.InetAddress import java.net.UnknownHostException object Defaults { val HOST_NAME: String = try { InetAddress.getLocalHost().hostName } catch ( @Suppress("SwallowedException") e: UnknownHostException ) { "stove-service-host" } const val AGENT_NAME = "stove-service" const val USER_EMAIL = "stove@trendyol.com" } object Headers { const val USER_EMAIL_KEY: String = "X-UserEmail" const val CORRELATION_ID_KEY: String = "X-CorrelationId" const val AGENT_NAME_KEY = "X-AgentName" const val PUBLISHED_DATE_KEY = "X-PublishedDate" const val MESSAGE_ID_KEY = "X-MessageId" const val HOST_KEY = "X-Host" const val EVENT_TYPE = "X-EventType" fun getOrDefault( key: String, defaultValue: String = Defaults.USER_EMAIL ): String = try { MDC.get(key) ?: MDC.get(key.lowercase()) ?: MDC.get(key.uppercase()) ?: defaultValue } catch ( @Suppress("SwallowedException") exception: IllegalStateException ) { defaultValue } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/ObjectMapperConfig.kt ================================================ package stove.spring.example.infrastructure import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.module.kotlin.KotlinModule import org.springframework.boot.autoconfigure.AutoConfigureBefore import org.springframework.boot.autoconfigure.jackson.* import org.springframework.context.annotation.* @Configuration @AutoConfigureBefore(JacksonAutoConfiguration::class) class ObjectMapperConfig { companion object { fun default(): ObjectMapper = ObjectMapper() .registerModule(KotlinModule.Builder().build()) .findAndRegisterModules() .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) } @Bean @Primary fun objectMapper(): ObjectMapper = default() @Bean fun jacksonCustomizer(): Jackson2ObjectMapperBuilderCustomizer { val default = default() return Jackson2ObjectMapperBuilderCustomizer { builder -> builder.factory(default.factory) } } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/api/ProductController.kt ================================================ package stove.spring.example.infrastructure.api import kotlinx.coroutines.reactive.* import kotlinx.coroutines.reactor.mono import org.springframework.http.codec.multipart.FilePart import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import stove.spring.example.application.handlers.ProductCreateRequest import stove.spring.example.application.handlers.ProductCreator @RestController @RequestMapping("/api") class ProductController( private val productCreator: ProductCreator ) { @GetMapping("/index") suspend fun get( @RequestParam(required = false) keyword: String? ): String = "Hi from Stove framework with $keyword" @PostMapping("/product/create") suspend fun createProduct( @RequestBody productCreateRequest: ProductCreateRequest ): String = productCreator.create(productCreateRequest) @PostMapping("/product/import") suspend fun importFile( @RequestPart(name = "name") name: String, @RequestPart(name = "file") file: FilePart ): String { val content = file .content() .flatMap { mono { it.asInputStream().readAllBytes() } } .awaitSingle() .let { String(it) } return "File ${file.filename()} is imported with $name and content: $content" } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/http/SupplierHttpService.kt ================================================ package stove.spring.example.infrastructure.http import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* import org.springframework.stereotype.Component import stove.spring.example.application.services.* @Component class SupplierHttpService( private val supplierHttpClient: HttpClient ) : SupplierService { override suspend fun getSupplierPermission(id: Long): SupplierPermission = supplierHttpClient .get("/suppliers/$id/allowed") { contentType(ContentType.Application.Json) }.body() } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/http/WebClientConfiguration.kt ================================================ package stove.spring.example.infrastructure.http import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.jackson.* import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import stove.spring.example.infrastructure.ObjectMapperConfig @Suppress("MagicNumber") @Configuration @EnableConfigurationProperties(WebClientConfigurationProperties::class) class WebClientConfiguration( private val webClientConfigurationProperties: WebClientConfigurationProperties ) { @Bean fun supplierHttpClient(): HttpClient = HttpClient(OkHttp) { install(ContentNegotiation) { jackson(contentType = io.ktor.http.ContentType.Application.Json) { ObjectMapperConfig.default() } } defaultRequest { url(webClientConfigurationProperties.supplierHttp.url) } engine { config { followRedirects(true) connectTimeout(java.time.Duration.ofSeconds(30)) readTimeout(java.time.Duration.ofSeconds(30)) } } expectSuccess = true } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/http/WebClientConfigurationProperties.kt ================================================ package stove.spring.example.infrastructure.http import org.springframework.boot.context.properties.ConfigurationProperties import java.net.URI @ConfigurationProperties(prefix = "http-clients") data class WebClientConfigurationProperties( var supplierHttp: ClientConfigurationProperty = ClientConfigurationProperty() ) data class ClientConfigurationProperty( var url: String = "", val uri: URI = URI.create(url), var connectTimeout: Int = 0, var readTimeout: Long = 0 ) ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/KafkaProducer.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka import kotlinx.coroutines.future.await import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.common.header.internals.RecordHeader import org.slf4j.* import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Component data class KafkaOutgoingMessage( val topic: String, val key: K, val payload: V, val headers: Map, val partition: Int? = null ) @Component class KafkaProducer( private val kafkaTemplate: KafkaTemplate ) { private val logger: Logger = LoggerFactory.getLogger(KafkaProducer::class.java) suspend fun send(message: KafkaOutgoingMessage) { val recordHeaders = message.headers.map { RecordHeader(it.key, it.value.toByteArray()) } val record = ProducerRecord( message.topic, message.partition, message.key, message.payload, recordHeaders ) logger.info("Kafka message has published $message") kafkaTemplate.send(record).await() } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/configuration/ConsumerSettings.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.configuration import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.serialization.StringDeserializer import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer import org.springframework.kafka.support.serializer.JsonDeserializer import org.springframework.stereotype.Component import java.time.Duration interface ConsumerSettings : MapBasedSettings @Component @EnableConfigurationProperties(KafkaProperties::class) class DefaultConsumerSettings( val kafkaProperties: KafkaProperties ) : ConsumerSettings { companion object { const val AUTO_COMMIT_INTERVAL = 5L const val SESSION_TIMEOUT = 120L const val MAX_POLL_INTERVAL = 5L } @Value("\${kafka.config.thread-count.basic-listener}") private val basicListenerThreadCount: String = "100" /** * We gave some properties as parameterized from application yaml for the override of this param from the stove. * These are like below; * autoCreateTopics: we are sending as true this param for creating missing topics in initialize time. * heartbeatInSeconds: we should reduce heartbeat seconds the e2e environment, so we parameterized this field. * secureKafka: this is Kafka secure parameter we can set false in default yaml. * If we want to use it for stage and prod yaml environment for adding secure Kafka configs set isSecure:true * offset: we should override this field as earliest for the stove e2e environment. */ override fun settings(): Map { val props: MutableMap = HashMap() props[ConsumerConfig.CLIENT_ID_CONFIG] = kafkaProperties.createClientId() props[ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG] = kafkaProperties.autoCreateTopics props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java props[ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS] = JsonDeserializer::class.java props[ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS] = StringDeserializer::class.java props[JsonDeserializer.TRUSTED_PACKAGES] = "*" props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = kafkaProperties.offset props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = true props[ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG] = ofSeconds(AUTO_COMMIT_INTERVAL) props[ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG] = ofSeconds(SESSION_TIMEOUT) props[ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG] = ofSeconds(kafkaProperties.heartbeatInSeconds.toLong()) props[ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG] = ofMinutes(MAX_POLL_INTERVAL) props[ConsumerConfig.MAX_POLL_RECORDS_CONFIG] = basicListenerThreadCount props[ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG] = kafkaProperties.defaultApiTimeout props[ConsumerConfig.DEFAULT_API_TIMEOUT_MS_CONFIG] = kafkaProperties.requestTimeout // if we want to add secure Kafka config we can add these config inside of if (kafkaProperties.isSecure) return props } private fun ofSeconds(seconds: Long) = Duration.ofSeconds(seconds).toMillis().toInt() private fun ofMinutes(minutes: Long) = Duration.ofMinutes(minutes).toMillis().toInt() } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/configuration/KafkaConsumerConfiguration.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.configuration import org.springframework.context.annotation.* import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff @EnableKafka @Configuration @Suppress("UNCHECKED_CAST") class KafkaConsumerConfiguration( private val interceptor: RecordInterceptor<*, *> ) { @Bean fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConcurrency(1) factory.consumerFactory = consumerFactory factory.containerProperties.isDeliveryAttemptHeader = true val errorHandler = DefaultErrorHandler(FixedBackOff(0, 0)) factory.setCommonErrorHandler(errorHandler) factory.setRecordInterceptor(interceptor as RecordInterceptor) return factory } @Bean fun kafkaRetryListenerContainerFactory( consumerRetryFactory: ConsumerFactory ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConcurrency(1) factory.containerProperties.isDeliveryAttemptHeader = true factory.consumerFactory = consumerRetryFactory val errorHandler = DefaultErrorHandler(FixedBackOff(INTERVAL, 1)) factory.setCommonErrorHandler(errorHandler) factory.setRecordInterceptor(interceptor as RecordInterceptor) return factory } @Bean fun consumerFactory( consumerSettings: ConsumerSettings ): ConsumerFactory = DefaultKafkaConsumerFactory(consumerSettings.settings()) @Bean fun consumerRetryFactory( consumerSettings: ConsumerSettings ): ConsumerFactory = DefaultKafkaConsumerFactory(consumerSettings.settings()) companion object { const val RETRY_LISTENER_BEAN_NAME = "kafkaRetryListenerContainerFactory" const val LISTENER_BEAN_NAME = "kafkaListenerContainerFactory" const val INTERVAL = 5000L } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/configuration/KafkaProducerConfiguration.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.configuration import org.apache.kafka.clients.producer.Producer import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.clients.producer.RecordMetadata import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory import org.springframework.kafka.support.ProducerListener @EnableKafka @Configuration class KafkaProducerConfiguration { private val logger = LoggerFactory.getLogger(javaClass) @Bean fun producer(producerFactory: ProducerFactory): Producer = producerFactory.createProducer() @Bean fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { val kafkaTemplate = KafkaTemplate(producerFactory) kafkaTemplate.setProducerListener( object : ProducerListener { override fun onError( producerRecord: ProducerRecord, recordMetadata: RecordMetadata?, exception: java.lang.Exception? ) { logger.error( "ProducerListener Topic: ${producerRecord.topic()}, Key: ${producerRecord.value()}", exception ) super.onError(producerRecord, recordMetadata, exception) } } ) return kafkaTemplate } @Bean fun producerFactory(producerSettings: ProducerSettings): ProducerFactory = DefaultKafkaProducerFactory(producerSettings.settings()) } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/configuration/KafkaProperties.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.configuration import org.springframework.boot.context.properties.ConfigurationProperties import java.util.* @ConfigurationProperties(prefix = "kafka") data class KafkaProperties( var bootstrapServers: String = "", var acks: String = "1", var topicPrefix: String = "", var heartbeatInSeconds: Int = 30, var requestTimeout: String, var defaultApiTimeout: String, var compression: String = "zstd", var offset: String = "latest", var autoCreateTopics: Boolean = false, var secureKafka: Boolean = false ) { val maxProducerConsumerBytes = "4194304" fun createClientId() = UUID.randomUUID().toString().substring(0, SUBSTRING_LENGTH) companion object { private const val SUBSTRING_LENGTH = 5 } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/configuration/MapBasedSettings.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.configuration interface MapBasedSettings { fun settings(): Map } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/configuration/ProducerSettings.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.configuration import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.kafka.support.serializer.JsonSerializer import org.springframework.stereotype.Component import stove.spring.example.infrastructure.messaging.kafka.interceptors.CustomProducerInterceptor interface ProducerSettings : MapBasedSettings @Component @EnableConfigurationProperties(KafkaProperties::class) class DefaultProducerSettings( private val kafkaProperties: KafkaProperties ) : ProducerSettings { override fun settings(): Map { val props: MutableMap = HashMap() props[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java props[ProducerConfig.INTERCEPTOR_CLASSES_CONFIG] = CustomProducerInterceptor::class.java.name props[ProducerConfig.ACKS_CONFIG] = kafkaProperties.acks props[ProducerConfig.COMPRESSION_TYPE_CONFIG] = kafkaProperties.compression props[ProducerConfig.CLIENT_ID_CONFIG] = kafkaProperties.createClientId() props[ProducerConfig.MAX_REQUEST_SIZE_CONFIG] = kafkaProperties.maxProducerConsumerBytes props["default.api.timeout.ms"] = kafkaProperties.defaultApiTimeout props[ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG] = kafkaProperties.requestTimeout return props } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/consumers/FailingProductCreateConsumer.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.consumers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.slf4j.MDCContext import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Component import stove.spring.example.infrastructure.messaging.kafka.configuration.KafkaConsumerConfiguration data class BusinessException( override val message: String ) : RuntimeException(message) data class FailingEvent( val id: Long ) @Component @ConditionalOnProperty(prefix = "kafka.consumers", value = ["enabled"], havingValue = "true") class FailingProductCreateConsumer { private val logger = LoggerFactory.getLogger(javaClass) @KafkaListener( topics = ["#{@productFailingEventTopicConfig.topic}"], groupId = "#{@consumerConfig.groupId}", containerFactory = KafkaConsumerConfiguration.LISTENER_BEAN_NAME ) fun listen(record: ConsumerRecord<*, *>): Unit = runBlocking(MDCContext()) { logger.info("Received product failing event $record") throw BusinessException("Failing product create event") } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/consumers/JobTopicConfig.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.consumers import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Configuration @Configuration @ConfigurationProperties(prefix = "kafka.consumers") class ConsumerConfig( var enabled: Boolean = false, var groupId: String = "", var retryTopicSuffix: String = "", var errorTopicSuffix: String = "" ) @Configuration @ConfigurationProperties(prefix = "kafka.consumers.product-create") class ProductCreateEventTopicConfig : TopicConfig() @Configuration @ConfigurationProperties(prefix = "kafka.consumers.product-failing") class ProductFailingEventTopicConfig : TopicConfig() abstract class TopicConfig( var topic: String = "", var retryTopic: String = "", var errorTopic: String = "" ) ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/consumers/ProductCreateConsumers.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.consumers import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.convertValue import kotlinx.coroutines.runBlocking import kotlinx.coroutines.slf4j.MDCContext import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Component import stove.spring.example.application.handlers.* import stove.spring.example.infrastructure.messaging.kafka.configuration.KafkaConsumerConfiguration @Component @ConditionalOnProperty(prefix = "kafka.consumers", value = ["enabled"], havingValue = "true") class ProductTransferConsumers( private val productCreator: ProductCreator, private val objectMapper: ObjectMapper ) { private val logger = LoggerFactory.getLogger(ProductTransferConsumers::class.java) @KafkaListener( topics = ["#{@productCreateEventTopicConfig.topic}"], groupId = "#{@consumerConfig.groupId}", containerFactory = KafkaConsumerConfiguration.LISTENER_BEAN_NAME ) @KafkaListener( topics = ["#{@productCreateEventTopicConfig.retryTopic}"], groupId = "#{@consumerConfig.groupId}_retry", containerFactory = KafkaConsumerConfiguration.RETRY_LISTENER_BEAN_NAME ) fun listen(record: ConsumerRecord<*, Any>) = runBlocking(MDCContext()) { logger.info("Received product transfer command $record") val command = objectMapper.convertValue(record.value()) productCreator.create(command.mapToCreateRequest()) } } data class CreateProductCommand( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/interceptors/CustomConsumerInterceptor.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.interceptors import org.apache.kafka.clients.consumer.* import org.apache.kafka.common.header.Header import org.slf4j.MDC import org.springframework.kafka.listener.RecordInterceptor import org.springframework.stereotype.Component import java.nio.charset.StandardCharsets /** * if we use RecordInterceptor we should change * it as ConsumerAwareRecordInterceptor for the stove e2e testing. */ @Component class CustomConsumerInterceptor : RecordInterceptor { override fun intercept( record: ConsumerRecord, consumer: Consumer ): ConsumerRecord? { val contextMap = HashMap() record .headers() .filter { it.key().lowercase().startsWith("x-") } .forEach { h: Header -> contextMap[h.key()] = String(h.value(), StandardCharsets.UTF_8) } MDC.setContextMap(contextMap) return record } } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/messaging/kafka/interceptors/CustomProducerInterceptor.kt ================================================ package stove.spring.example.infrastructure.messaging.kafka.interceptors import org.apache.kafka.clients.producer.ProducerInterceptor import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.clients.producer.RecordMetadata import stove.spring.example.infrastructure.Defaults import stove.spring.example.infrastructure.Headers import java.time.Instant import java.util.UUID class CustomProducerInterceptor : ProducerInterceptor { companion object { private val DEFAULT_HOST_NAME_AS_BYTE: ByteArray = Defaults.HOST_NAME.toByteArray() } override fun onSend(record: ProducerRecord): ProducerRecord { val messageId = UUID.randomUUID().toString() record.headers().add(Headers.MESSAGE_ID_KEY, messageId.toByteArray()) record.headers().add(Headers.PUBLISHED_DATE_KEY, Instant.now().toString().toByteArray()) record.headers().add(Headers.HOST_KEY, DEFAULT_HOST_NAME_AS_BYTE) record.headers().add( Headers.CORRELATION_ID_KEY, Headers.getOrDefault(Headers.CORRELATION_ID_KEY, messageId).toByteArray() ) record.headers().add(Headers.AGENT_NAME_KEY, Defaults.AGENT_NAME.toByteArray()) record.headers().add( Headers.USER_EMAIL_KEY, Headers.getOrDefault(Headers.USER_EMAIL_KEY, Defaults.USER_EMAIL).toByteArray() ) return record } override fun configure(configs: MutableMap?) = Unit override fun onAcknowledgement( metadata: RecordMetadata?, exception: Exception? ) = Unit override fun close() = Unit } ================================================ FILE: examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/postgres/ExposedConfiguration.kt ================================================ package stove.spring.example.infrastructure.postgres import com.zaxxer.hikari.HikariDataSource import org.jetbrains.exposed.v1.jdbc.Database import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.* import javax.sql.DataSource @ConfigurationProperties("spring.datasource") data class DataSourceConfig( val url: String, val username: String, val password: String ) @Configuration class ExposedConfiguration { @Bean fun dataSource( dataSourceConfig: DataSourceConfig ): DataSource = HikariDataSource().apply { this.jdbcUrl = dataSourceConfig.url this.username = dataSourceConfig.username this.password = dataSourceConfig.password } @Bean fun database(dataSource: DataSource): Database = Database.connect(dataSource) } ================================================ FILE: examples/spring-example/src/main/resources/application.yml ================================================ spring: application: name: "stove" servlet: multipart: max-request-size: 10MB datasource: url: jdbc:postgresql://localhost:5432/stove username: postgres password: postgres hikari: maximum-pool-size: 10 server: port: 8001 http2: enabled: false http-clients: supplier-http: url: http://localhost:7078 connectTimeout: 2000 readTimeout: 20000 kafka: bootstrapServers: localhost:9092 topicPrefix: trendyol.stove.service acks: 1 secureKafka: false autoCreateTopics: false offset: "latest" heartbeatInSeconds: 30 request-timeout: "20000" default-api-timeout: "20000" config: thread-count: basic-listener: 25 producer: prefix: trendyol.stove.service product-created: topic-name: ${kafka.producer.prefix}.productCreated.1 consumers: retryTopicSuffix: trendyol.stove.service.retry errorTopicSuffix: trendyol.stove.service.error enabled: true groupId: trendyol.stove.service product-create: topic: trendyol.stove.service.product.create.0 retryTopic: ${kafka.consumers.product-create.topic}.${kafka.consumers.retryTopicSuffix} errorTopic: ${kafka.consumers.product-create.topic}.${kafka.consumers.errorTopicSuffix} product-failing: topic: trendyol.stove.service.product.failing.0 retryTopic: ${kafka.consumers.product-failing.topic}.${kafka.consumers.retryTopicSuffix} errorTopic: ${kafka.consumers.product-failing.topic}.${kafka.consumers.errorTopicSuffix} ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/CreateProductsTableMigration.kt ================================================ package com.stove.spring.example.e2e import com.trendyol.stove.postgres.PostgresSqlMigrationContext import com.trendyol.stove.postgres.PostgresqlMigration import org.slf4j.Logger import org.slf4j.LoggerFactory class CreateProductsTableMigration : PostgresqlMigration { private val logger: Logger = LoggerFactory.getLogger(CreateProductsTableMigration::class.java) override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { logger.info("Creating products table") connection.operations.execute( """ DROP TABLE IF EXISTS products; CREATE TABLE IF NOT EXISTS products ( id BIGINT PRIMARY KEY, name VARCHAR(255) NOT NULL, supplier_id BIGINT NOT NULL, created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); """.trimIndent() ) logger.info("Products table created") } } ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/ExampleTest.kt ================================================ package com.stove.spring.example.e2e import arrow.core.some import com.trendyol.stove.http.* import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.string.shouldContain import org.jetbrains.exposed.v1.jdbc.Database import org.springframework.http.MediaType import stove.spring.example.application.handlers.* import stove.spring.example.application.services.SupplierPermission import stove.spring.example.infrastructure.messaging.kafka.consumers.* import kotlin.time.Duration.Companion.seconds class ExampleTest : FunSpec({ test("bridge should work") { stove { using { this shouldNotBe null } } } test("index should be reachable") { stove { http { get("/api/index", queryParams = mapOf("keyword" to testCase.name.name)) { actual -> actual shouldContain "Hi from Stove framework with ${testCase.name.name}" println(actual) } get("/api/index") { actual -> actual shouldContain "Hi from Stove framework with" println(actual) } } } } test("should create new product when send product create request from api for the allowed supplier") { stove { val productCreateRequest = ProductCreateRequest(1L, name = "product name", 99L) val supplierPermission = SupplierPermission(productCreateRequest.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${productCreateRequest.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } http { postAndExpectBodilessResponse(uri = "/api/product/create", body = productCreateRequest.some()) { actual -> actual.status shouldBe 200 } } kafka { shouldBePublished { actual.id == productCreateRequest.id && actual.name == productCreateRequest.name && actual.supplierId == productCreateRequest.supplierId } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${productCreateRequest.id}", mapper = { row -> ProductCreateRequest( row.long("id"), row.string("name"), row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe productCreateRequest.id products.first().name shouldBe productCreateRequest.name products.first().supplierId shouldBe productCreateRequest.supplierId } } } } test("should throw error when send product create request from api for for the not allowed supplier") { stove { val productCreateRequest = ProductCreateRequest(2L, name = "product name", 98L) val supplierPermission = SupplierPermission(productCreateRequest.supplierId, isAllowed = false) wiremock { mockGet( "/suppliers/${productCreateRequest.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } http { postAndExpectJson(uri = "/api/product/create", body = productCreateRequest.some()) { actual -> actual shouldBe "Supplier with the given id(${productCreateRequest.supplierId}) is not allowed for product creation" } } } } test("should throw error when send product create event for the not allowed supplier") { stove { val command = CreateProductCommand(3L, name = "product name", 97L) val supplierPermission = SupplierPermission(command.supplierId, isAllowed = false) wiremock { mockGet( "/suppliers/${supplierPermission.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } kafka { publish("trendyol.stove.service.product.create.0", command) shouldBeConsumed(10.seconds) { actual.id == command.id } } } } test("should create new product when send product create event for the allowed supplier") { stove { val createProductCommand = CreateProductCommand(4L, name = "product name", 96L) val supplierPermission = SupplierPermission(createProductCommand.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${createProductCommand.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } kafka { publish("trendyol.stove.service.product.create.0", createProductCommand) shouldBeConsumed { actual.id == createProductCommand.id && actual.name == createProductCommand.name && actual.supplierId == createProductCommand.supplierId && metadata.headers["X-UserEmail"] == "stove@trendyol.com" } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${createProductCommand.id}", mapper = { row -> ProductCreateRequest( row.long("id"), row.string("name"), row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe createProductCommand.id products.first().name shouldBe createProductCommand.name products.first().supplierId shouldBe createProductCommand.supplierId } } kafka { shouldBePublished { actual.id == createProductCommand.id && actual.name == createProductCommand.name && actual.supplierId == createProductCommand.supplierId } } } } test("when failing event is published then it should be validated") { stove { kafka { publish("trendyol.stove.service.product.failing.0", FailingEvent(5L)) shouldBeFailed { actual.id == 5L && reason is BusinessException } shouldBeFailed { actual == FailingEvent(5L) && reason is BusinessException } } } } test("file import should work") { stove { http { postMultipartAndExpectResponse( "/api/product/import", body = listOf( StoveMultiPartContent.Text("name", "product name"), StoveMultiPartContent.File( "file", "file.txt", "file".toByteArray(), contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE ) ) ) { actual -> actual.body() shouldBe "File file.txt is imported with product name and content: file" } } } } }) ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/StoveConfig.kt ================================================ package com.stove.spring.example.e2e import com.trendyol.stove.dashboard.* import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.postgres.* import com.trendyol.stove.spring.* import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import stove.spring.example.run class StoveConfig : AbstractProjectConfig() { private val appPort = PortFinder.findAvailablePort() override val extensions: List = listOf(StoveKotestExtension()) private val logger: Logger = LoggerFactory.getLogger("WireMockMonitor") @Suppress("LongMethod") override suspend fun beforeProject(): Unit = Stove() .with { dashboard { DashboardSystemOptions(appName = "spring-example") } tracing { enableSpanReceiver() } httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$appPort" ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } kafka { KafkaSystemOptions( containerOptions = KafkaContainerOptions(tag = "8.0.3"), configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.isSecure=false" ) } ) } bridge() wiremock { WireMockSystemOptions( port = 0, removeStubAfterRequestMatched = true, afterRequest = { e, _ -> logger.info(e.request.toString()) }, configureExposedConfiguration = { cfg -> listOf("http-clients.supplier-http.url=${cfg.baseUrl}") } ) } springBoot( runner = { parameters -> run(parameters) { this.addTestSystemDependencies() } }, withParameters = listOf( "server.port=$appPort", "logging.level.root=info", "logging.level.org.springframework.web=info", "spring.profiles.active=default", "kafka.heartbeatInSeconds=2", "kafka.autoCreateTopics=true", "kafka.offset=earliest", "kafka.secureKafka=false" ) ) }.run() override suspend fun afterProject() { // Stove.stop() is intentionally not called here to allow JUnit tests // (which run via a separate test engine in the same JVM) to use the // same Stove instance. Cleanup happens via JVM shutdown hooks. } } ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/TestSystemInitializer.kt ================================================ package com.stove.spring.example.e2e import com.trendyol.stove.kafka.TestSystemKafkaInterceptor import com.trendyol.stove.serialization.* import com.trendyol.stove.spring.stoveSpringRegistrar import org.springframework.boot.SpringApplication import stove.spring.example.infrastructure.ObjectMapperConfig fun SpringApplication.addTestSystemDependencies() { this.addInitializers( stoveSpringRegistrar { bean>(isPrimary = true) bean { StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfig.default()) } } ) } ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/TracingValidationTest.kt ================================================ package com.stove.spring.example.e2e import arrow.core.some import com.trendyol.stove.http.* import com.trendyol.stove.system.* import com.trendyol.stove.tracing.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import stove.spring.example.application.handlers.ProductCreateRequest import stove.spring.example.application.services.SupplierPermission class TracingValidationTest : FunSpec({ test("tracing should capture HTTP request context implicitly") { stove { // Trace is auto-started, just make HTTP call http { get("/api/index", queryParams = mapOf("keyword" to "tracing-test")) { actual -> actual shouldContain "Hi from Stove framework" } } // Access trace context for validation - all props accessible directly tracing { traceId.length shouldBe 32 rootSpanId.length shouldBe 16 val traceparent = toTraceparent() traceparent shouldContain traceId println("✓ Trace context created implicitly:") println(" - traceId: $traceId") println(" - testId: $testId") println(" - traceparent: $traceparent") } } } test("tracing should work with full request flow") { stove { val productCreateRequest = ProductCreateRequest(100L, name = "traced product", 999L) val supplierPermission = SupplierPermission(productCreateRequest.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${productCreateRequest.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } http { postAndExpectBodilessResponse(uri = "/api/product/create", body = productCreateRequest.some()) { actual -> actual.status shouldBe 200 } } // Validate trace was captured tracing { println("✓ Full request flow traced:") println(" - traceId: $traceId") println(" - testId: $testId") } } } test("trace collector should record spans") { stove { tracing { // Record test spans collector.record( SpanInfo( traceId = traceId, spanId = TraceContext.generateSpanId(), parentSpanId = rootSpanId, operationName = "TestController.handleRequest", serviceName = "collector-test", startTimeNanos = System.nanoTime(), endTimeNanos = System.nanoTime() + 1_000_000, status = SpanStatus.OK ) ) collector.record( SpanInfo( traceId = traceId, spanId = TraceContext.generateSpanId(), parentSpanId = rootSpanId, operationName = "TestService.processData", serviceName = "collector-test", startTimeNanos = System.nanoTime(), endTimeNanos = System.nanoTime() + 2_000_000, status = SpanStatus.OK ) ) // Validate spans - methods available directly shouldContainSpan("TestController.handleRequest") shouldContainSpan("TestService.processData") shouldNotHaveFailedSpans() spanCountShouldBeAtLeast(2) println("✓ Trace collector recorded spans:") println(renderTree()) } } } }) ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/hierarchy/BehaviorSpecHierarchyTest.kt ================================================ package com.stove.spring.example.e2e.hierarchy import com.trendyol.stove.http.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.string.shouldContain class BehaviorSpecHierarchyTest : BehaviorSpec({ given("the index endpoint") { `when`("requesting with a keyword") { then("should return greeting with keyword") { stove { http { get("/api/index", queryParams = mapOf("keyword" to "bdd-test")) { actual -> actual shouldContain "Hi from Stove framework with bdd-test" } } } } then("should contain framework name") { stove { http { get("/api/index", queryParams = mapOf("keyword" to "bdd")) { actual -> actual shouldContain "Stove" } } } } } `when`("requesting without a keyword") { then("should return default greeting") { stove { http { get("/api/index") { actual -> actual shouldContain "Hi from Stove framework" } } } } } } given("health check scenarios") { `when`("the application is running") { then("index should be reachable") { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } } } }) ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/hierarchy/DescribeSpecHierarchyTest.kt ================================================ package com.stove.spring.example.e2e.hierarchy import com.trendyol.stove.http.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.string.shouldContain class DescribeSpecHierarchyTest : DescribeSpec({ describe("Index API") { it("should return greeting") { stove { http { get("/api/index") { actual -> actual shouldContain "Hi from Stove framework" } } } } describe("with query parameters") { it("should include keyword in response") { stove { http { get("/api/index", queryParams = mapOf("keyword" to "describe-test")) { actual -> actual shouldContain "describe-test" } } } } it("should handle different keywords") { stove { http { get("/api/index", queryParams = mapOf("keyword" to "another")) { actual -> actual shouldContain "another" } } } } } } describe("Application health") { it("should respond to index") { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } } }) ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/hierarchy/FunSpecContextHierarchyTest.kt ================================================ package com.stove.spring.example.e2e.hierarchy import com.trendyol.stove.http.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.string.shouldContain class FunSpecContextHierarchyTest : FunSpec({ context("index endpoint") { test("should return greeting") { stove { http { get("/api/index") { actual -> actual shouldContain "Hi from Stove framework" } } } } test("should accept keyword parameter") { stove { http { get("/api/index", queryParams = mapOf("keyword" to "ctx-test")) { actual -> actual shouldContain "ctx-test" } } } } } context("nested context levels") { context("level two") { test("should still work at deeper nesting") { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } } } test("flat top-level test") { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } }) ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/hierarchy/NestedJunitHierarchyTest.kt ================================================ package com.stove.spring.example.e2e.hierarchy import com.trendyol.stove.extensions.junit.StoveJUnitExtension import com.trendyol.stove.http.* import com.trendyol.stove.system.stove import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(StoveJUnitExtension::class) class NestedJunitHierarchyTest { @Nested inner class IndexEndpoint { @Test fun `should return greeting`() = runBlocking { stove { http { get("/api/index") { actual -> actual shouldContain "Hi from Stove framework" } } } } @Test fun `should include keyword in response`() = runBlocking { stove { http { get("/api/index", queryParams = mapOf("keyword" to "junit-nested")) { actual -> actual shouldContain "junit-nested" } } } } @Nested inner class WithQueryParams { @Test fun `should handle keyword parameter`() = runBlocking { stove { http { get("/api/index", queryParams = mapOf("keyword" to "deep-nested")) { actual -> actual shouldContain "deep-nested" } } } } } } @Nested inner class HealthCheck { @Test fun `should be reachable`() = runBlocking { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } } @Test fun `flat junit test at root level`() = runBlocking { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } } ================================================ FILE: examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/hierarchy/StringSpecHierarchyTest.kt ================================================ package com.stove.spring.example.e2e.hierarchy import com.trendyol.stove.http.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.string.shouldContain class StringSpecHierarchyTest : StringSpec({ "should return greeting" { stove { http { get("/api/index") { actual -> actual shouldContain "Hi from Stove framework" } } } } "should include keyword in response" { stove { http { get("/api/index", queryParams = mapOf("keyword" to "string-spec")) { actual -> actual shouldContain "string-spec" } } } } "should contain framework name" { stove { http { get("/api/index") { actual -> actual shouldContain "Stove" } } } } }) ================================================ FILE: examples/spring-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.spring.example.e2e.StoveConfig ================================================ FILE: examples/spring-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: examples/spring-standalone-example/build.gradle.kts ================================================ import com.trendyol.stove.gradle.stoveTracing plugins { alias(libs.plugins.spring.plugin) alias(libs.plugins.spring.boot.three) idea application } dependencies { implementation(libs.spring.boot.three) implementation(libs.spring.boot.three.autoconfigure) implementation(libs.spring.boot.three.webflux) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.jackson.json) implementation(libs.spring.boot.three.actuator) annotationProcessor(libs.spring.boot.three.annotationProcessor) implementation(libs.spring.boot.three.kafka) implementation(libs.exposed.core) implementation(libs.exposed.jdbc) implementation(libs.exposed.java.time) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.core) implementation(libs.kotlinx.reactive) implementation(libs.postgresql) implementation(libs.jackson.kotlin) implementation(libs.kotlinx.slf4j) implementation(libs.hikari) } dependencies { testImplementation(projects.stove.testExtensions.stoveExtensionsKotest) testImplementation(projects.stove.lib.stoveHttp) testImplementation(projects.stove.lib.stoveTracing) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.lib.stoveWiremock) testImplementation(projects.stove.lib.stovePostgres) testImplementation(projects.stove.lib.stoveElasticsearch) testImplementation(projects.stove.lib.stoveKafka) testImplementation(projects.stove.starters.spring.stoveSpring) } stoveTracing { serviceName = "spring-standalone-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } application { mainClass.set("stove.spring.standalone.example.ExampleAppKt") } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/ExampleApp.kt ================================================ package stove.spring.standalone.example import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.context.ConfigurableApplicationContext @SpringBootApplication @ConfigurationPropertiesScan class ExampleApp fun main(args: Array) { run(args) } /** * This is the point where spring application gets run. * run(args, init) method is the important point for the testing configuration. * init allows us to override any dependency from the testing side that is being time related or configuration related. * Spring itself opens this configuration higher order function to the outside. */ fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext = runApplication(*args, init = init) ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/application/handlers/ProductCreator.kt ================================================ package stove.spring.standalone.example.application.handlers import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import stove.spring.standalone.example.domain.Products import stove.spring.standalone.example.infrastructure.Headers import stove.spring.standalone.example.infrastructure.http.SupplierHttpService import stove.spring.standalone.example.infrastructure.messaging.kafka.* import stove.spring.standalone.example.infrastructure.messaging.kafka.consumers.CreateProductCommand import java.time.Instant @Component class ProductCreator( private val supplierHttpService: SupplierHttpService, private val kafkaProducer: KafkaProducer ) { @Value("\${kafka.producer.product-created.topic-name}") lateinit var productCreatedTopic: String suspend fun create(req: ProductCreateRequest): String = suspendTransaction { val supplierPermission = supplierHttpService.getSupplierPermission(req.supplierId) if (!supplierPermission.isAllowed) { return@suspendTransaction "Supplier with the given id(${req.supplierId}) is not allowed for product creation" } Products.insert { it[id] = req.id it[name] = req.name it[supplierId] = req.supplierId it[Products.createdDate] = Instant.now() } kafkaProducer.send( KafkaOutgoingMessage( topic = productCreatedTopic, key = req.id.toString(), headers = mapOf(Headers.EVENT_TYPE to ProductCreatedEvent::class.simpleName!!), partition = 0, payload = req.mapToProductCreatedEvent() ) ) return@suspendTransaction "OK" } } fun CreateProductCommand.mapToCreateRequest(): ProductCreateRequest = ProductCreateRequest(this.id, this.name, this.supplierId) fun ProductCreateRequest.mapToProductCreatedEvent(): ProductCreatedEvent = ProductCreatedEvent( this.id, this.name, this.supplierId, Instant.now() ) data class ProductCreatedEvent( val id: Long, val name: String, val supplierId: Long, val createdDate: Instant, val type: String = ProductCreatedEvent::class.simpleName!! ) data class ProductCreateRequest( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/application/services/SupplierService.kt ================================================ package stove.spring.standalone.example.application.services data class SupplierPermission( val id: Long, val isAllowed: Boolean ) interface SupplierService { suspend fun getSupplierPermission(id: Long): SupplierPermission? } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/domain/ProductTable.kt ================================================ package stove.spring.standalone.example.domain import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.timestamp object Products : Table("products") { val id = long("id") val name = varchar("name", 255) val supplierId = long("supplier_id") val createdDate = timestamp("created_date") override val primaryKey = PrimaryKey(id) } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/Constants.kt ================================================ package stove.spring.standalone.example.infrastructure import org.slf4j.MDC import java.net.* object Defaults { val HOST_NAME: String = try { InetAddress.getLocalHost().hostName } catch ( @Suppress("SwallowedException") e: UnknownHostException ) { "stove-service-host" } const val AGENT_NAME = "stove-service" const val USER_EMAIL = "stove@trendyol.com" } object Headers { const val USER_EMAIL_KEY: String = "X-UserEmail" const val CORRELATION_ID_KEY: String = "X-CorrelationId" const val AGENT_NAME_KEY = "X-AgentName" const val PUBLISHED_DATE_KEY = "X-PublishedDate" const val MESSAGE_ID_KEY = "X-MessageId" const val HOST_KEY = "X-Host" const val EVENT_TYPE = "X-EventType" fun getOrDefault( key: String, defaultValue: String = Defaults.USER_EMAIL ): String = try { MDC.get(key) ?: MDC.get(key.lowercase()) ?: MDC.get(key.uppercase()) ?: defaultValue } catch ( @Suppress("SwallowedException") exception: IllegalStateException ) { defaultValue } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/ObjectMapperConfig.kt ================================================ package stove.spring.standalone.example.infrastructure import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.module.kotlin.KotlinModule import org.springframework.boot.autoconfigure.AutoConfigureBefore import org.springframework.boot.autoconfigure.jackson.* import org.springframework.context.annotation.* @Configuration @AutoConfigureBefore(JacksonAutoConfiguration::class) class ObjectMapperConfig { companion object { val default: ObjectMapper = ObjectMapper() .registerModule(KotlinModule.Builder().build()) .findAndRegisterModules() .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) } @Bean @Primary fun objectMapper(): ObjectMapper = default @Bean fun jacksonCustomizer(): Jackson2ObjectMapperBuilderCustomizer = Jackson2ObjectMapperBuilderCustomizer { builder -> builder.factory(default.factory) } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/api/ProductController.kt ================================================ package stove.spring.standalone.example.infrastructure.api import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactor.mono import org.springframework.http.codec.multipart.FilePart import org.springframework.web.bind.annotation.* import stove.spring.standalone.example.application.handlers.* @RestController @RequestMapping("/api") class ProductController( private val productCreator: ProductCreator ) { @GetMapping("/index") suspend fun get( @RequestParam(required = false) keyword: String ): String = "Hi from Stove framework with $keyword" @PostMapping("/product/create") suspend fun createProduct( @RequestBody productCreateRequest: ProductCreateRequest ): String = productCreator.create(productCreateRequest) @PostMapping("/product/import") suspend fun importFile( @RequestPart(name = "name") name: String, @RequestPart(name = "file") file: FilePart ): String { val content = file .content() .flatMap { mono { it.asInputStream().readAllBytes() } } .awaitSingle() .let { String(it) } return "File ${file.filename()} is imported with $name and content: $content" } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/http/SupplierHttpService.kt ================================================ package stove.spring.standalone.example.infrastructure.http import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* import org.springframework.stereotype.Component import stove.spring.standalone.example.application.services.* @Component class SupplierHttpService( private val supplierHttpClient: HttpClient ) : SupplierService { override suspend fun getSupplierPermission(id: Long): SupplierPermission = supplierHttpClient .get("/suppliers/$id/allowed") { contentType(ContentType.Application.Json) }.body() } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/http/WebClientConfiguration.kt ================================================ package stove.spring.standalone.example.infrastructure.http import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.jackson.* import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.* @Suppress("MagicNumber") @Configuration @EnableConfigurationProperties(WebClientConfigurationProperties::class) class WebClientConfiguration( private val webClientConfigurationProperties: WebClientConfigurationProperties ) { @Bean fun supplierHttpClient(): HttpClient = HttpClient(OkHttp) { install(ContentNegotiation) { jackson(contentType = io.ktor.http.ContentType.Application.Json) } defaultRequest { url(webClientConfigurationProperties.supplierHttp.url) } engine { config { followRedirects(true) connectTimeout(java.time.Duration.ofSeconds(30)) readTimeout(java.time.Duration.ofSeconds(30)) } } expectSuccess = true } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/http/WebClientConfigurationProperties.kt ================================================ package stove.spring.standalone.example.infrastructure.http import org.springframework.boot.context.properties.ConfigurationProperties import java.net.URI @ConfigurationProperties(prefix = "http-clients") data class WebClientConfigurationProperties( var supplierHttp: ClientConfigurationProperty = ClientConfigurationProperty() ) data class ClientConfigurationProperty( var url: String = "", val uri: URI = URI.create(url), var connectTimeout: Int = 0, var readTimeout: Long = 0 ) ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/KafkaProducer.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka import kotlinx.coroutines.future.await import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.common.header.internals.RecordHeader import org.slf4j.* import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Component data class KafkaOutgoingMessage( val topic: String, val key: K, val payload: V, val headers: Map, val partition: Int? = null ) @Component class KafkaProducer( private val kafkaTemplate: KafkaTemplate ) { private val logger: Logger = LoggerFactory.getLogger(KafkaProducer::class.java) suspend fun send(message: KafkaOutgoingMessage) { val recordHeaders = message.headers.map { RecordHeader(it.key, it.value.toByteArray()) } val record = ProducerRecord( message.topic, message.partition, message.key, message.payload, recordHeaders ) logger.info("Kafka message has published $message") kafkaTemplate.send(record).await() } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/configuration/ConsumerSettings.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.configuration import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.serialization.StringDeserializer import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.kafka.support.serializer.* import org.springframework.stereotype.Component import java.time.Duration interface ConsumerSettings : MapBasedSettings @Component @EnableConfigurationProperties(KafkaProperties::class) class DefaultConsumerSettings( val kafkaProperties: KafkaProperties ) : ConsumerSettings { companion object { const val AUTO_COMMIT_INTERVAL = 5L const val SESSION_TIMEOUT = 120L const val MAX_POLL_INTERVAL = 5L } @Value("\${kafka.config.thread-count.basic-listener}") private val basicListenerThreadCount: String = "100" /** * We gave some properties as parameterized from application yaml for the override of this param from the stove. * These are like below; * autoCreateTopics: we are sending as true this param for creating missing topics in initialize time. * heartbeatInSeconds: we should reduce heartbeat seconds the e2e environment, so we parameterized this field. * secureKafka: this is Kafka secure parameter we can set false in default yaml. * If we want to use it for stage and prod yaml environment for adding secure Kafka configs set isSecure:true * offset: we should override this field as earliest for the stove e2e environment. */ override fun settings(): Map { val props: MutableMap = HashMap() props[ConsumerConfig.CLIENT_ID_CONFIG] = kafkaProperties.createClientId() props[ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG] = kafkaProperties.autoCreateTopics props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java props[ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS] = JsonDeserializer::class.java props[ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS] = StringDeserializer::class.java props[JsonDeserializer.TRUSTED_PACKAGES] = "*" props[JsonDeserializer.REMOVE_TYPE_INFO_HEADERS] = false props[JsonDeserializer.VALUE_DEFAULT_TYPE] = Any::class.java props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = kafkaProperties.offset props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = true props[ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG] = ofSeconds(AUTO_COMMIT_INTERVAL) props[ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG] = ofSeconds(SESSION_TIMEOUT) props[ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG] = ofSeconds(kafkaProperties.heartbeatInSeconds.toLong()) props[ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG] = ofMinutes(MAX_POLL_INTERVAL) props[ConsumerConfig.MAX_POLL_RECORDS_CONFIG] = basicListenerThreadCount props[ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG] = kafkaProperties.defaultApiTimeout props[ConsumerConfig.DEFAULT_API_TIMEOUT_MS_CONFIG] = kafkaProperties.requestTimeout props[ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG] = kafkaProperties.interceptorClasses // if we want to add secure Kafka config we can add these config inside of if (kafkaProperties.isSecure) return props } private fun ofSeconds(seconds: Long) = Duration.ofSeconds(seconds).toMillis().toInt() private fun ofMinutes(minutes: Long) = Duration.ofMinutes(minutes).toMillis().toInt() } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/configuration/KafkaConsumerConfiguration.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.configuration import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.context.annotation.* import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.kafka.support.converter.StringJsonMessageConverter import org.springframework.util.backoff.FixedBackOff @EnableKafka @Configuration class KafkaConsumerConfiguration( private val objectMapper: ObjectMapper, private val interceptor: RecordInterceptor ) { @Bean fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, kafkaTemplate: KafkaTemplate ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConcurrency(1) factory.consumerFactory = consumerFactory factory.containerProperties.isDeliveryAttemptHeader = true val errorHandler = DefaultErrorHandler( DeadLetterPublishingRecoverer(kafkaTemplate), FixedBackOff(0, 0) ) factory.setCommonErrorHandler(errorHandler) factory.setRecordInterceptor(interceptor) return factory } @Bean fun kafkaRetryListenerContainerFactory( consumerRetryFactory: ConsumerFactory, kafkaTemplate: KafkaTemplate ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConcurrency(1) factory.containerProperties.isDeliveryAttemptHeader = true factory.consumerFactory = consumerRetryFactory val errorHandler = DefaultErrorHandler( DeadLetterPublishingRecoverer(kafkaTemplate), FixedBackOff(INTERVAL, 1) ) factory.setCommonErrorHandler(errorHandler) factory.setRecordInterceptor(interceptor) return factory } @Bean fun consumerFactory(consumerSettings: ConsumerSettings): ConsumerFactory = DefaultKafkaConsumerFactory(consumerSettings.settings()) @Bean fun consumerRetryFactory(consumerSettings: ConsumerSettings): ConsumerFactory = DefaultKafkaConsumerFactory(consumerSettings.settings()) @Bean fun stringJsonMessageConverter(): StringJsonMessageConverter = StringJsonMessageConverter(objectMapper) companion object { const val RETRY_LISTENER_BEAN_NAME = "kafkaRetryListenerContainerFactory" const val LISTENER_BEAN_NAME = "kafkaListenerContainerFactory" const val INTERVAL = 5000L } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/configuration/KafkaProducerConfiguration.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.configuration import org.apache.kafka.clients.producer.* import org.slf4j.LoggerFactory import org.springframework.context.annotation.* import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.core.* import org.springframework.kafka.support.ProducerListener @EnableKafka @Configuration class KafkaProducerConfiguration { private val logger = LoggerFactory.getLogger(javaClass) @Bean fun producer(producerFactory: ProducerFactory): Producer = producerFactory.createProducer() @Bean fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { val kafkaTemplate = KafkaTemplate(producerFactory) kafkaTemplate.setProducerListener( object : ProducerListener { override fun onError( producerRecord: ProducerRecord, recordMetadata: RecordMetadata?, exception: java.lang.Exception? ) { logger.error( "ProducerListener Topic: ${producerRecord.topic()}, Key: ${producerRecord.value()}", exception ) super.onError(producerRecord, recordMetadata, exception) } } ) return kafkaTemplate } @Bean fun producerFactory( producerSettings: ProducerSettings ): ProducerFactory = DefaultKafkaProducerFactory(producerSettings.settings()) } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/configuration/KafkaProperties.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.configuration import org.springframework.boot.context.properties.ConfigurationProperties import java.util.* @ConfigurationProperties(prefix = "kafka") data class KafkaProperties( var bootstrapServers: String = "", var acks: String = "1", var topicPrefix: String = "", var heartbeatInSeconds: Int = 30, var requestTimeout: String, var defaultApiTimeout: String, var compression: String = "zstd", var offset: String = "latest", var autoCreateTopics: Boolean = false, var secureKafka: Boolean = false, var interceptorClasses: List = emptyList() ) { val maxProducerConsumerBytes = "4194304" fun createClientId() = UUID.randomUUID().toString().substring(0, SUBSTRING_LENGTH) companion object { private const val SUBSTRING_LENGTH = 5 } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/configuration/MapBasedSettings.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.configuration interface MapBasedSettings { fun settings(): Map } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/configuration/ProducerSettings.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.configuration import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.kafka.support.serializer.JsonSerializer import org.springframework.stereotype.Component import stove.spring.standalone.example.infrastructure.messaging.kafka.interceptors.CustomProducerInterceptor interface ProducerSettings : MapBasedSettings @Component @EnableConfigurationProperties(KafkaProperties::class) class DefaultProducerSettings( private val kafkaProperties: KafkaProperties ) : ProducerSettings { override fun settings(): Map { val props: MutableMap = HashMap() props[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java props[ProducerConfig.INTERCEPTOR_CLASSES_CONFIG] = listOf(CustomProducerInterceptor::class.java.name) + kafkaProperties.interceptorClasses props[ProducerConfig.ACKS_CONFIG] = kafkaProperties.acks props[ProducerConfig.COMPRESSION_TYPE_CONFIG] = kafkaProperties.compression props[ProducerConfig.CLIENT_ID_CONFIG] = kafkaProperties.createClientId() props[ProducerConfig.MAX_REQUEST_SIZE_CONFIG] = kafkaProperties.maxProducerConsumerBytes props["default.api.timeout.ms"] = kafkaProperties.defaultApiTimeout props[ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG] = kafkaProperties.requestTimeout return props } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/consumers/FailingProductCreateConsumer.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.consumers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.slf4j.MDCContext import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Component import stove.spring.standalone.example.infrastructure.messaging.kafka.configuration.KafkaConsumerConfiguration data class BusinessException( override val message: String ) : RuntimeException(message) @Component @ConditionalOnProperty(prefix = "kafka.consumers", value = ["enabled"], havingValue = "true") class FailingProductCreateConsumer { private val logger = LoggerFactory.getLogger(javaClass) @KafkaListener( topics = ["#{@productFailingEventTopicConfig.topic}"], groupId = "#{@consumerConfig.groupId}", containerFactory = KafkaConsumerConfiguration.LISTENER_BEAN_NAME ) fun listen(record: ConsumerRecord<*, *>): Unit = runBlocking(MDCContext()) { logger.info("Received product failing event ${record.value()}") throw BusinessException("Failing product create event") } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/consumers/JobTopicConfig.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.consumers import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Configuration @Configuration @ConfigurationProperties(prefix = "kafka.consumers") class ConsumerConfig( var enabled: Boolean = false, var groupId: String = "", var retryTopicSuffix: String = "", var errorTopicSuffix: String = "" ) @Configuration @ConfigurationProperties(prefix = "kafka.consumers.product-create") class ProductCreateEventTopicConfig : TopicConfig() @Configuration @ConfigurationProperties(prefix = "kafka.consumers.product-failing") class ProductFailingEventTopicConfig : TopicConfig() abstract class TopicConfig( var topic: String = "", var retryTopic: String = "", var errorTopic: String = "" ) ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/consumers/ProductCreateConsumers.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.consumers import com.fasterxml.jackson.databind.ObjectMapper import kotlinx.coroutines.runBlocking import kotlinx.coroutines.slf4j.MDCContext import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Component import stove.spring.standalone.example.application.handlers.* import stove.spring.standalone.example.infrastructure.messaging.kafka.configuration.KafkaConsumerConfiguration @Component @ConditionalOnProperty(prefix = "kafka.consumers", value = ["enabled"], havingValue = "true") class ProductTransferConsumers( private val objectMapper: ObjectMapper, private val productCreator: ProductCreator ) { private val logger = LoggerFactory.getLogger(ProductTransferConsumers::class.java) @KafkaListener( topics = ["#{@productCreateEventTopicConfig.topic}"], groupId = "#{@consumerConfig.groupId}", containerFactory = KafkaConsumerConfiguration.LISTENER_BEAN_NAME ) @KafkaListener( topics = ["#{@productCreateEventTopicConfig.retryTopic}"], groupId = "#{@consumerConfig.groupId}_retry", containerFactory = KafkaConsumerConfiguration.RETRY_LISTENER_BEAN_NAME ) fun listen(record: ConsumerRecord<*, Any>) = runBlocking(MDCContext()) { logger.info("Received product transfer command ${record.value()}") val command = objectMapper.convertValue(record.value(), CreateProductCommand::class.java) productCreator.create(command.mapToCreateRequest()) } } data class CreateProductCommand( val id: Long, val name: String, val supplierId: Long ) ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/interceptors/CustomConsumerInterceptor.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.interceptors import org.apache.kafka.clients.consumer.* import org.apache.kafka.common.header.Header import org.slf4j.MDC import org.springframework.kafka.listener.RecordInterceptor import org.springframework.stereotype.Component import java.nio.charset.StandardCharsets /** * if we use RecordInterceptor we should change * it as ConsumerAwareRecordInterceptor for the stove e2e testing. */ @Component class CustomConsumerInterceptor : RecordInterceptor { override fun intercept( record: ConsumerRecord, consumer: Consumer ): ConsumerRecord? { val contextMap = HashMap() record .headers() .filter { it.key().lowercase().startsWith("x-") } .forEach { h: Header -> contextMap[h.key()] = String(h.value(), StandardCharsets.UTF_8) } MDC.setContextMap(contextMap) return record } } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/messaging/kafka/interceptors/CustomProducerInterceptor.kt ================================================ package stove.spring.standalone.example.infrastructure.messaging.kafka.interceptors import org.apache.kafka.clients.producer.* import stove.spring.standalone.example.infrastructure.* import java.time.Instant import java.util.* class CustomProducerInterceptor : ProducerInterceptor { companion object { private val DEFAULT_HOST_NAME_AS_BYTE: ByteArray = Defaults.HOST_NAME.toByteArray() } override fun onSend(record: ProducerRecord): ProducerRecord { val messageId = UUID.randomUUID().toString() record.headers().add(Headers.MESSAGE_ID_KEY, messageId.toByteArray()) record.headers().add(Headers.PUBLISHED_DATE_KEY, Instant.now().toString().toByteArray()) record.headers().add(Headers.HOST_KEY, DEFAULT_HOST_NAME_AS_BYTE) record.headers().add( Headers.CORRELATION_ID_KEY, Headers.getOrDefault(Headers.CORRELATION_ID_KEY, messageId).toByteArray() ) record.headers().add(Headers.AGENT_NAME_KEY, Defaults.AGENT_NAME.toByteArray()) record.headers().add( Headers.USER_EMAIL_KEY, Headers.getOrDefault(Headers.USER_EMAIL_KEY, Defaults.USER_EMAIL).toByteArray() ) return record } override fun configure(configs: MutableMap?) = Unit override fun onAcknowledgement( metadata: RecordMetadata?, exception: Exception? ) = Unit override fun close() = Unit } ================================================ FILE: examples/spring-standalone-example/src/main/kotlin/stove/spring/standalone/example/infrastructure/postgres/ExposedConfiguration.kt ================================================ package stove.spring.standalone.example.infrastructure.postgres import com.zaxxer.hikari.HikariDataSource import org.jetbrains.exposed.v1.jdbc.Database import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.* import javax.sql.DataSource @ConfigurationProperties("spring.datasource") data class DataSourceConfig( val url: String, val username: String, val password: String ) @Configuration class ExposedConfiguration { @Bean fun dataSource( dataSourceConfig: DataSourceConfig ): DataSource = HikariDataSource().apply { this.jdbcUrl = dataSourceConfig.url this.username = dataSourceConfig.username this.password = dataSourceConfig.password } @Bean fun database(dataSource: DataSource): Database = Database.connect(dataSource) } ================================================ FILE: examples/spring-standalone-example/src/main/resources/application.yml ================================================ spring: application: name: "stove" servlet: multipart: max-request-size: 10MB datasource: url: jdbc:postgresql://localhost:5432/stove username: postgres password: postgres hikari: maximum-pool-size: 10 minimum-idle: 2 server: port: 8001 http2: enabled: false http-clients: supplier-http: url: http://localhost:9099 connectTimeout: 2000 readTimeout: 20000 kafka: bootstrapServers: localhost:9092 topicPrefix: stove-standalone-example acks: 1 secureKafka: false autoCreateTopics: false offset: "latest" heartbeatInSeconds: 30 request-timeout: "20000" default-api-timeout: "20000" interceptorClasses: [] config: thread-count: basic-listener: 25 producer: prefix: stove-standalone-example product-created: topic-name: ${kafka.producer.prefix}.productCreated.1 consumers: retryTopicSuffix: retry errorTopicSuffix: error enabled: true groupId: stove-standalone-example product-create: topic: trendyol.stove.service.product.create.0 retryTopic: ${kafka.consumers.product-create.topic}.${kafka.consumers.retryTopicSuffix} errorTopic: ${kafka.consumers.product-create.topic}.${kafka.consumers.errorTopicSuffix} product-failing: topic: trendyol.stove.service.product.failing.0 retryTopic: ${kafka.consumers.product-failing.topic}.${kafka.consumers.retryTopicSuffix} errorTopic: ${kafka.consumers.product-failing.topic}.${kafka.consumers.errorTopicSuffix} ================================================ FILE: examples/spring-standalone-example/src/test/kotlin/com/stove/spring/standalone/example/e2e/CreateProductsTableMigration.kt ================================================ package com.stove.spring.standalone.example.e2e import com.trendyol.stove.postgres.PostgresSqlMigrationContext import com.trendyol.stove.postgres.PostgresqlMigration import org.slf4j.Logger import org.slf4j.LoggerFactory class CreateProductsTableMigration : PostgresqlMigration { private val logger: Logger = LoggerFactory.getLogger(CreateProductsTableMigration::class.java) override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { logger.info("Creating products table") connection.operations.execute( """ DROP TABLE IF EXISTS products; CREATE TABLE IF NOT EXISTS products ( id BIGINT PRIMARY KEY, name VARCHAR(255) NOT NULL, supplier_id BIGINT NOT NULL, created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); """.trimIndent() ) logger.info("Products table created") } } ================================================ FILE: examples/spring-standalone-example/src/test/kotlin/com/stove/spring/standalone/example/e2e/ExampleTest.kt ================================================ package com.stove.spring.standalone.example.e2e import arrow.core.some import com.trendyol.stove.http.* import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.string.shouldContain import org.jetbrains.exposed.v1.jdbc.Database import org.springframework.http.MediaType import stove.spring.standalone.example.application.handlers.* import stove.spring.standalone.example.application.services.SupplierPermission import stove.spring.standalone.example.infrastructure.messaging.kafka.consumers.CreateProductCommand import kotlin.time.Duration.Companion.seconds class ExampleTest : FunSpec({ test("bridge should work") { stove { using { this shouldNotBe null } } } test("index should be reachable") { stove { http { get("/api/index", queryParams = mapOf("keyword" to testCase.name.name)) { actual -> actual shouldContain "Hi from Stove framework with ${testCase.name.name}" println(actual) } get("/api/index") { actual -> actual shouldContain "Hi from Stove framework with" println(actual) } } } } test("should create new product when send product create request from api for the allowed supplier") { stove { val request = ProductCreateRequest(1L, name = "product name", 99L) val permission = SupplierPermission(request.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${request.supplierId}/allowed", statusCode = 200, responseBody = permission.some() ) } http { postAndExpectBodilessResponse(uri = "/api/product/create", body = request.some()) { actual -> actual.status shouldBe 200 } } kafka { shouldBePublished { actual.id == request.id && actual.name == request.name && actual.supplierId == request.supplierId } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${request.id}", mapper = { row -> ProductCreateRequest( row.long("id"), row.string("name"), row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe request.id products.first().name shouldBe request.name products.first().supplierId shouldBe request.supplierId } } } } test("should throw error when send product create request from api for for the not allowed supplier") { stove { val request = ProductCreateRequest(2L, name = "product name", 98L) val permission = SupplierPermission(request.supplierId, isAllowed = false) wiremock { mockGet( "/suppliers/${request.supplierId}/allowed", statusCode = 200, responseBody = permission.some() ) } http { postAndExpectJson(uri = "/api/product/create", body = request.some()) { actual -> actual shouldBe "Supplier with the given id(${request.supplierId}) is not allowed for product creation" } } } } test("should throw error when send product create event for the not allowed supplier") { stove { val command = CreateProductCommand(3L, name = "product name", 97L) val supplierPermission = SupplierPermission(command.supplierId, isAllowed = false) wiremock { mockGet( "/suppliers/${command.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } kafka { publish("trendyol.stove.service.product.create.0", command) shouldBeConsumed(10.seconds) { actual.id == command.id } } } } test("should create new product when send product create event for the allowed supplier") { stove { val command = CreateProductCommand(4L, name = "product name", 96L) val supplierPermission = SupplierPermission(command.supplierId, isAllowed = true) wiremock { mockGet( "/suppliers/${command.supplierId}/allowed", statusCode = 200, responseBody = supplierPermission.some() ) } kafka { publish("trendyol.stove.service.product.create.0", command) shouldBeConsumed { actual.id == command.id && actual.name == command.name && actual.supplierId == command.supplierId } shouldBePublished { actual.id == command.id && actual.name == command.name && actual.supplierId == command.supplierId && metadata.headers["X-UserEmail"] == "stove@trendyol.com" } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${command.id}", mapper = { row -> ProductCreateRequest( row.long("id"), row.string("name"), row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe command.id products.first().name shouldBe command.name products.first().supplierId shouldBe command.supplierId } } } } test("when failing event is published then it should be validated") { data class FailingEvent( val id: Long ) stove { kafka { publish("trendyol.stove.service.product.failing.0", FailingEvent(5L)) shouldBeFailed { actual.id == 5L } shouldBeFailed { actual == FailingEvent(5L) } } } } test("file import should work") { stove { http { postMultipartAndExpectResponse( "/api/product/import", body = listOf( StoveMultiPartContent.Text("name", "product name"), StoveMultiPartContent.File( "file", "file.txt", "file".toByteArray(), contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE ) ) ) { actual -> actual.body() shouldBe "File file.txt is imported with product name and content: file" } } } } }) ================================================ FILE: examples/spring-standalone-example/src/test/kotlin/com/stove/spring/standalone/example/e2e/ReportingIntegrationTest.kt ================================================ package com.stove.spring.standalone.example.e2e import arrow.core.some import com.trendyol.stove.http.http import com.trendyol.stove.kafka.* import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.reporting.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.comparables.shouldBeGreaterThan import io.kotest.matchers.string.shouldContain import stove.spring.standalone.example.application.handlers.* import stove.spring.standalone.example.application.services.SupplierPermission import java.util.* import kotlin.time.Duration.Companion.seconds class ReportingIntegrationTest : FunSpec({ test("report should capture HTTP and Kafka operations") { stove { val request = ProductCreateRequest(100L, "test product", 1L) val permission = SupplierPermission(request.supplierId, isAllowed = true) wiremock { mockGet("/suppliers/${permission.id}/allowed", 200, permission.some()) } http { postAndExpectBodilessResponse("/api/product/create", request.some()) { it.status shouldBe 200 } } kafka { shouldBePublished { actual.id == request.id } } } // Validate the report contents val report = Stove.reporter().currentTest() // Should have WireMock stub action report .entries() .any { it.system == "WireMock" && it.action.contains("GET /suppliers") } shouldBe true // Should have HTTP action report .entries() .any { it.system == "HTTP" && it.action.contains("POST /api/product") } shouldBe true // Should have Kafka assertion report .entries() .any { it.system == "Kafka" && it.action.contains("shouldBePublished") } shouldBe true } test("report should include Kafka MessageStore snapshot on failure") { try { stove { kafka { publish("orders.test", mapOf("id" to 1)) // This will fail - no consumer for this topic shouldBePublished>(atLeastIn = 1.seconds) { actual["nonexistent"] == true } } } } catch (_: Throwable) { // Expected - can be TimeoutCancellationException, AssertionError, or wrapped exception } // Get the snapshot val snapshot = Stove.getSystem(KafkaSystem::class).snapshot() snapshot shouldNotBe null snapshot.system shouldBe "Kafka" (snapshot.state["published"] as? List<*>)?.size?.shouldBeGreaterThan(0) } test("report should capture PostgreSQL operations") { stove { val request = ProductCreateRequest(200L, "postgres test", 1L) val permission = SupplierPermission(request.supplierId, isAllowed = true) wiremock { mockGet("/suppliers/${permission.id}/allowed", 200, permission.some()) } http { postAndExpectBodilessResponse("/api/product/create", request.some()) { it.status shouldBe 200 } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${request.id}", mapper = { row -> ProductCreateRequest( id = row.long("id"), name = row.string("name"), supplierId = row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe request.id } } } val report = Stove.reporter().currentTest() // Should have PostgreSQL action with result report .entries() .any { it.system == "PostgreSQL" && it.action.contains("Query") } shouldBe true } test("report should capture multiple system interactions") { stove { val request = ProductCreateRequest(300L, "multi-system test", 1L) val permission = SupplierPermission(request.supplierId, isAllowed = true) // WireMock wiremock { mockGet("/suppliers/${permission.id}/allowed", 200, permission.some()) } // HTTP http { postAndExpectBodilessResponse("/api/product/create", request.some()) { it.status shouldBe 200 } } // Kafka kafka { shouldBePublished { actual.id == request.id } } // PostgreSQL postgresql { shouldQuery( "SELECT * FROM products WHERE id = ${request.id}", mapper = { row -> ProductCreateRequest( id = row.long("id"), name = row.string("name"), supplierId = row.long("supplier_id") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe request.id } } } val report = Stove.reporter().currentTest() // Use entriesForThisTest() to filter only entries for this specific test val entries = report.entriesForThisTest() // Should have entries from all systems entries.filter { it.system == "WireMock" } shouldHaveSize 1 entries.filter { it.system == "HTTP" } shouldHaveSize 1 entries.filter { it.system == "Kafka" } shouldHaveSize 1 entries.filter { it.system == "PostgreSQL" } shouldHaveSize 1 } test("report should be renderable as JSON") { stove { http { get("/api/index") { it shouldContain "Hi from Stove framework" } } } val report = Stove.reporter().currentTest() val json = JsonReportRenderer.render(report, emptyList()) json shouldContain "testId" json shouldContain "testName" json shouldContain "entries" json shouldContain "summary" json shouldContain "HTTP" } test("report should be renderable as pretty console") { stove { http { get("/api/index") { it shouldContain "Hi from Stove framework" } } } val report = Stove.reporter().currentTest() val pretty = PrettyConsoleRenderer.render(report, emptyList()) pretty shouldContain "STOVE TEST EXECUTION REPORT" pretty shouldContain "TIMELINE" pretty shouldContain "HTTP" pretty shouldContain "GET /api/index" pretty shouldContain "╭" pretty shouldContain "╰" } }) ================================================ FILE: examples/spring-standalone-example/src/test/kotlin/com/stove/spring/standalone/example/e2e/StoveConfig.kt ================================================ package com.stove.spring.standalone.example.e2e import com.trendyol.stove.dashboard.DashboardSystemOptions import com.trendyol.stove.dashboard.dashboard import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.postgres.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.* import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import stove.spring.standalone.example.infrastructure.ObjectMapperConfig import stove.spring.standalone.example.run class StoveConfig : AbstractProjectConfig() { private val logger: Logger = LoggerFactory.getLogger("WireMockMonitor") private val appPort = PortFinder.findAvailablePort() init { stoveKafkaBridgePortDefault = PortFinder.findAvailablePortAsString() } override val extensions: List = listOf(StoveKotestExtension()) @Suppress("LongMethod") override suspend fun beforeProject(): Unit = Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$appPort" ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "spring.datasource.url=${cfg.jdbcUrl}", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } kafka { KafkaSystemOptions( useEmbeddedKafka = true, topicSuffixes = TopicSuffixes().copy(error = listOf(".error", ".DLT", "dlt")), serde = StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfig.default), containerOptions = KafkaContainerOptions(tag = "8.0.3") ) { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.isSecure=false", "kafka.interceptorClasses=${it.interceptorClass}" ) } } bridge() tracing { enableSpanReceiver() } dashboard { DashboardSystemOptions(appName = "spring-standalone-example") } wiremock { WireMockSystemOptions( port = 0, removeStubAfterRequestMatched = true, afterRequest = { e, _ -> logger.info(e.request.toString()) }, configureExposedConfiguration = { cfg -> listOf("http-clients.supplier-http.url=${cfg.baseUrl}") } ) } springBoot( runner = { parameters -> run(parameters) }, withParameters = listOf( "server.port=$appPort", "logging.level.root=info", "logging.level.org.springframework.web=info", "spring.profiles.active=default", "kafka.heartbeatInSeconds=2", "kafka.autoCreateTopics=true", "kafka.offset=earliest", "kafka.secureKafka=false" ) ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } ================================================ FILE: examples/spring-standalone-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.spring.standalone.example.e2e.StoveConfig ================================================ FILE: examples/spring-standalone-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: examples/spring-streams-example/build.gradle.kts ================================================ import com.google.protobuf.gradle.id import com.trendyol.stove.gradle.stoveTracing plugins { alias(libs.plugins.spring.plugin) alias(libs.plugins.spring.boot.three) alias(libs.plugins.spring.dependencyManagement) alias(libs.plugins.protobuf) idea application } dependencies { implementation(libs.spring.boot.three) implementation(libs.spring.boot.three.autoconfigure) annotationProcessor(libs.spring.boot.three.annotationProcessor) implementation(libs.spring.boot.three.kafka) implementation(libs.jackson.kotlin) implementation(libs.kafka) implementation(libs.kafka.streams) implementation(libs.kotlin.reflect) implementation(libs.google.protobuf.kotlin) implementation(libs.kafka.streams.protobuf.serde) } dependencies { testImplementation(projects.stove.testExtensions.stoveExtensionsKotest) testImplementation(projects.stove.lib.stoveKafka) testImplementation(projects.stove.lib.stoveTracing) testImplementation(projects.stove.lib.stoveDashboard) testImplementation(projects.stove.starters.spring.stoveSpring) testImplementation(libs.kotlinx.core) testImplementation(libs.testcontainers.kafka) } application { mainClass.set("stove.spring.streams.example.ExampleAppkt") } java.sourceSets["main"].java { srcDir("build/generated/source/proto/main/java") srcDir("build/generated/source/proto/main/kotlin") } tasks.withType { useJUnitPlatform() } protobuf { protoc { artifact = libs.protoc.get().toString() } generateProtoTasks { all().forEach { // If true, the descriptor set will contain line number information // and comments. Default is false. it.descriptorSetOptions.includeSourceInfo = true // If true, the descriptor set will contain all transitive imports and // is therefore self-contained. Default is false. it.descriptorSetOptions.includeImports = true it.builtins { id("kotlin") } } } } configurations.matching { it.name == "detekt" }.all { resolutionStrategy.eachDependency { if (requested.group == "org.jetbrains.kotlin") { @Suppress("UnstableApiUsage") useVersion(io.gitlab.arturbosch.detekt.getSupportedKotlinVersion()) } } } stoveTracing { serviceName = "spring-streams-example" otelAgentVersion = libs.versions.opentelemetry.instrumentation.get() } ================================================ FILE: examples/spring-streams-example/src/main/kotlin/stove/spring/streams/example/ExampleApp.kt ================================================ package stove.spring.streams.example import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.context.ConfigurableApplicationContext @SpringBootApplication class ExampleApp fun main(args: Array) { run(args) } /** * This is the point where spring application gets run. * run(args, init) method is the important point for the testing configuration. * init allows us to override any dependency from the testing side that is being time related or configuration related. * Spring itself opens this configuration higher order function to the outside. */ fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext = runApplication(*args, init = init) ================================================ FILE: examples/spring-streams-example/src/main/kotlin/stove/spring/streams/example/kafka/CustomDeserializationExceptionHandler.kt ================================================ package stove.spring.streams.example.kafka import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.streams.errors.* import org.slf4j.LoggerFactory class CustomDeserializationExceptionHandler : DeserializationExceptionHandler { companion object { private val logger = LoggerFactory.getLogger(CustomDeserializationExceptionHandler::class.java) } override fun handleError( context: ErrorHandlerContext, record: ConsumerRecord, exception: Exception? ): DeserializationExceptionHandler.Response { logger.error( "Deserialization exception in [${record.topic()}]: [${exception?.message}] Caused by: ${exception?.cause?.message}" ) return DeserializationExceptionHandler.Response.resume() } override fun configure(configs: MutableMap?) = Unit } ================================================ FILE: examples/spring-streams-example/src/main/kotlin/stove/spring/streams/example/kafka/CustomProductionExceptionHandler.kt ================================================ package stove.spring.streams.example.kafka import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.streams.errors.* import org.slf4j.LoggerFactory class CustomProductionExceptionHandler : ProductionExceptionHandler { companion object { private val logger = LoggerFactory.getLogger(CustomProductionExceptionHandler::class.java) } override fun handleError( context: ErrorHandlerContext?, record: ProducerRecord?, exception: Exception? ): ProductionExceptionHandler.Response? { logger.error( "Production exception in [${record?.topic()}]: [${exception?.message}] Caused by: ${exception?.cause?.message}" ) return ProductionExceptionHandler.Response.resume() } override fun configure(configs: MutableMap) = Unit } ================================================ FILE: examples/spring-streams-example/src/main/kotlin/stove/spring/streams/example/kafka/CustomSerDe.kt ================================================ package stove.spring.streams.example.kafka import com.google.protobuf.Message import io.confluent.kafka.schemaregistry.protobuf.ProtobufSchemaProvider import io.confluent.kafka.schemaregistry.testutil.MockSchemaRegistry import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @Component class CustomSerDe { @Value("\${kafka.schema-registry-url}") val schemaRegistryUrl = "" fun createSerdeForValues(): KafkaProtobufSerde = KafkaRegistry.createSerde(schemaRegistryUrl) } sealed class KafkaRegistry( open val url: String ) { object Mock : KafkaRegistry("mock://mock-registry") data class Defined( override val url: String ) : KafkaRegistry(url) companion object { fun createSerde(fromUrl: String): KafkaProtobufSerde = createSerde( if (fromUrl.contains(Mock.url)) Mock else Defined(fromUrl) ) fun createSerde(registry: KafkaRegistry = Mock): KafkaProtobufSerde { val schemaRegistryClient = when (registry) { is Mock -> MockSchemaRegistry.getClientForScope("mock-registry", listOf(ProtobufSchemaProvider())) is Defined -> MockSchemaRegistry.getClientForScope(registry.url, listOf(ProtobufSchemaProvider())) } val serde: KafkaProtobufSerde = KafkaProtobufSerde(schemaRegistryClient) val serdeConfig: MutableMap = HashMap() serdeConfig[SCHEMA_REGISTRY_URL_CONFIG] = registry.url serde.configure(serdeConfig, false) return serde } } } ================================================ FILE: examples/spring-streams-example/src/main/kotlin/stove/spring/streams/example/kafka/StreamsConfig.kt ================================================ package stove.spring.streams.example.kafka import org.apache.kafka.clients.consumer.ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG import org.apache.kafka.common.serialization.Serdes import org.apache.kafka.streams.StreamsConfig.* import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.* import org.springframework.kafka.annotation.* import org.springframework.kafka.config.KafkaStreamsConfiguration @Configuration @EnableKafka @EnableKafkaStreams class StreamsConfig { @Value("\${spring.kafka.streams.bootstrap-servers}") val bootstrapServers: String = "" @Value("\${kafka.interceptorClasses}") val interceptorClass = emptyList() @Bean(name = [KafkaStreamsDefaultConfiguration.DEFAULT_STREAMS_CONFIG_BEAN_NAME]) fun kStreamsConfig(): KafkaStreamsConfiguration { val props: MutableMap = HashMap() props[APPLICATION_ID_CONFIG] = "stove.example" props[BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers props[DEFAULT_KEY_SERDE_CLASS_CONFIG] = Serdes.String().javaClass.name props[DEFAULT_VALUE_SERDE_CLASS_CONFIG] = Serdes.String().javaClass.name props[COMMIT_INTERVAL_MS_CONFIG] = 0 props[DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG] = CustomDeserializationExceptionHandler::class.java props[PRODUCTION_EXCEPTION_HANDLER_CLASS_CONFIG] = CustomProductionExceptionHandler::class.java props[INTERCEPTOR_CLASSES_CONFIG] = interceptorClass return KafkaStreamsConfiguration(props) } } ================================================ FILE: examples/spring-streams-example/src/main/kotlin/stove/spring/streams/example/kafka/application/processor/ExampleJoin.kt ================================================ package stove.spring.streams.example.kafka.application.processor import com.google.protobuf.Message import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde import org.apache.kafka.common.serialization.* import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.* import org.springframework.beans.factory.annotation.Autowired import org.springframework.kafka.annotation.* import org.springframework.stereotype.Component import stove.example.protobuf.Input1Value.Input1 import stove.example.protobuf.Input2Value.Input2 import stove.example.protobuf.output import stove.spring.streams.example.kafka.CustomSerDe @Component @EnableKafka @EnableKafkaStreams class ExampleJoin( customSerDe: CustomSerDe ) { private val protobufSerde: KafkaProtobufSerde = customSerDe.createSerdeForValues() private val byteArraySerde: Serde = Serdes.ByteArray() private val stringSerde: Serde = Serdes.String() @Autowired fun buildPipeline(streamsBuilder: StreamsBuilder) { val input1: KTable = streamsBuilder .stream("input1", Consumed.with(stringSerde, protobufSerde)) .toTable(Materialized.with(stringSerde, protobufSerde)) val input2: KTable = streamsBuilder .stream("input2", Consumed.with(stringSerde, protobufSerde)) .toTable(Materialized.with(stringSerde, protobufSerde)) val joinedTable = input1.join( input2, { input1Message: Message, input2Message: Message -> protobufSerde.serializer().serialize( "output", output { this.firstName = Input1.parseFrom(input1Message.toByteArray()).firstName this.lastName = Input1.parseFrom(input1Message.toByteArray()).lastName this.bsn = Input2.parseFrom(input2Message.toByteArray()).bsn this.age = Input2.parseFrom(input2Message.toByteArray()).age } ) } ) joinedTable.toStream().to("output", Produced.with(stringSerde, byteArraySerde)) } } ================================================ FILE: examples/spring-streams-example/src/main/proto/Input1-value.proto ================================================ syntax = "proto3"; package stove.example.protobuf; message Input1 { string firstName = 1; string lastName = 2; } ================================================ FILE: examples/spring-streams-example/src/main/proto/Input2-value.proto ================================================ syntax = "proto3"; package stove.example.protobuf; message Input2 { string bsn = 1; int32 age = 2; } ================================================ FILE: examples/spring-streams-example/src/main/proto/Output-value.proto ================================================ syntax = "proto3"; package stove.example.protobuf; message Output { string firstName = 1; string lastName = 2; string bsn = 3; int32 age = 4; } ================================================ FILE: examples/spring-streams-example/src/main/resources/application.properties ================================================ spring.application.name= stove.example spring.kafka.consumer.bootstrap-servers = localhost:9092 spring.kafka.producer.bootstrap-servers = localhost:9092 spring.kafka.streams.bootstrap-servers = localhost:9092 spring.kafka.consumer.group-id= group_id spring.kafka.consumer.auto-offset-reset = earliest spring.kafka.consumer.key-deserializer= org.apache.kafka.common.serialization.StringDeserializer spring.kafka.consumer.value-deserializer = org.apache.kafka.common.serialization.StringDeserializer spring.kafka.producer.value-serializer= org.apache.kafka.common.serialization.ByteArraySerializer kafka.interceptorClasses= kafka.schema-registry-url= http://localhost:8089 spring.kafka.streams.cleanup.on-startup=true ================================================ FILE: examples/spring-streams-example/src/test/kotlin/com/stove/spring/streams/example/e2e/ExampleTest.kt ================================================ package com.stove.spring.streams.example.e2e import arrow.core.Option import com.google.protobuf.Message import com.trendyol.stove.kafka.kafka import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import org.apache.kafka.common.serialization.StringDeserializer import stove.example.protobuf.* import stove.example.protobuf.Input1Value.Input1 import stove.example.protobuf.Input2Value.Input2 import stove.example.protobuf.OutputValue.Output import java.util.* import kotlin.time.Duration.Companion.seconds class ExampleTest : FunSpec({ test("expect join") { /*------------------------- | Create test data --------------------------*/ val firstName = UUID.randomUUID().toString() val lastName = UUID.randomUUID().toString() val bsn = UUID.randomUUID().toString() val age = 18 // create input val input1Message = input1 { this.firstName = firstName this.lastName = lastName } val input2Message = input2 { this.bsn = bsn this.age = age } val outputMessage = output { this.firstName = firstName this.lastName = lastName this.bsn = bsn this.age = age } stove { kafka { /*------------------------- | publish kafka messages --------------------------*/ // inputs publish(INPUT_TOPIC, input1Message, Option("test")) publish(INPUT_TOPIC2, input2Message, Option("test")) /*--------------------------- | verify messages consumed ----------------------------*/ // Assert input1 message is consumed shouldBeConsumed { actual == input1Message } // Assert input2 message is consumed shouldBeConsumed { actual == input2Message } /*--------------------------- | verify messages published ----------------------------*/ // Assert joined message is correctly published shouldBePublished(atLeastIn = 20.seconds) { actual.bsn == bsn } // Assert joined message is correctly published // Similar to test above, but is able to run even if no messages are published consumer( "output", valueDeserializer = StoveKafkaValueDeserializer(), keyDeserializer = StringDeserializer() ) { record -> if (Output.parseFrom(record.value().toByteArray()) != outputMessage) throw AssertionError() } } } } }) { companion object { const val INPUT_TOPIC = "input1" const val INPUT_TOPIC2 = "input2" const val OUTPUT_TOPIC = "output" } } ================================================ FILE: examples/spring-streams-example/src/test/kotlin/com/stove/spring/streams/example/e2e/StoveConfig.kt ================================================ package com.stove.spring.streams.example.e2e import com.stove.spring.streams.example.e2e.ExampleTest.Companion.INPUT_TOPIC import com.stove.spring.streams.example.e2e.ExampleTest.Companion.INPUT_TOPIC2 import com.stove.spring.streams.example.e2e.ExampleTest.Companion.OUTPUT_TOPIC import com.trendyol.stove.dashboard.DashboardSystemOptions import com.trendyol.stove.dashboard.dashboard import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.kafka.* import com.trendyol.stove.spring.* import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.apache.kafka.clients.admin.NewTopic import stove.spring.streams.example.run class StoveConfig : AbstractProjectConfig() { private val appPort = PortFinder.findAvailablePort() override val extensions: List = listOf(StoveKotestExtension()) @Suppress("LongMethod") override suspend fun beforeProject(): Unit = Stove() .also { stoveKafkaBridgePortDefault = PortFinder.findAvailablePortAsString() }.with { kafka { KafkaSystemOptions( listenPublishedMessagesFromStove = false, serde = StoveProtobufSerde(), valueSerializer = StoveKafkaValueSerializer(), containerOptions = KafkaContainerOptions(tag = "8.0.3") ) { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.isSecure=false", "kafka.interceptorClasses=${it.interceptorClass}", "spring.kafka.streams.bootstrap-servers=${it.bootstrapServers}", "spring.kafka.producer.bootstrap-servers=${it.bootstrapServers}", "spring.kafka.consumer.bootstrap-servers=${it.bootstrapServers}" ) } } bridge() tracing { enableSpanReceiver() } dashboard { DashboardSystemOptions(appName = "spring-streams-example") } springBoot( runner = { parameters -> run(parameters) }, withParameters = listOf( "server.port=$appPort", "logging.level.root=info", "logging.level.org.springframework.web=info", "spring.profiles.active=default", "kafka.heartbeatInSeconds=2", "kafka.autoCreateTopics=true", "kafka.offset=earliest", "kafka.secureKafka=false", "kafka.topic.create-topics=true", "kafka.schema-registry-url=mock://mock-registry" ) ) }.run() .also { stove { kafka { adminOperations { createTopics( listOf( NewTopic(INPUT_TOPIC, 1, 1), NewTopic(INPUT_TOPIC2, 1, 1), NewTopic(OUTPUT_TOPIC, 1, 1) ) ) } } } } override suspend fun afterProject(): Unit = Stove.stop() } ================================================ FILE: examples/spring-streams-example/src/test/kotlin/com/stove/spring/streams/example/e2e/TestHelper.kt ================================================ package com.stove.spring.streams.example.e2e import arrow.core.* import com.google.protobuf.Message import com.trendyol.stove.serialization.StoveSerde import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde import kotlinx.serialization.ExperimentalSerializationApi import okio.ByteString.Companion.toByteString import org.apache.kafka.common.serialization.* import stove.spring.streams.example.kafka.KafkaRegistry import java.util.* class StoveKafkaValueSerializer : Serializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde(KafkaRegistry.Mock) override fun serialize( topic: String, data: T ): ByteArray = when (data) { is ByteArray -> data else -> protobufSerde.serializer().serialize(topic, data as Message) } } class StoveKafkaValueDeserializer : Deserializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde(KafkaRegistry.Mock) override fun deserialize( topic: String, data: ByteArray ): Message = protobufSerde.deserializer().deserialize(topic, data) } @Suppress("UNCHECKED_CAST") @OptIn(ExperimentalSerializationApi::class) class StoveProtobufSerde : StoveSerde { private val parseFromMethod = "parseFrom" private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde(KafkaRegistry.Mock) override fun serialize(value: Any): ByteArray = protobufSerde.serializer().serialize("any", value as Message) override fun deserialize(value: ByteArray, clazz: Class): T { val incoming: Message = protobufSerde.deserializer().deserialize("any", value) incoming.isAssignableFrom(clazz).also { isAssignableFrom -> require(isAssignableFrom) { "Expected '${clazz.simpleName}' but got '${incoming.descriptorForType.name}'. " + "This could be transient ser/de problem since the message stream is constantly checked if the expected message is arrived, " + "so you can ignore this error if you are sure that the message is the expected one." } } val parseFromMethod = clazz.getDeclaredMethod(parseFromMethod, ByteArray::class.java) val parsed = parseFromMethod(incoming, incoming.toByteArray()) as T return parsed } } private fun Message.isAssignableFrom(clazz: Class<*>): Boolean = this.descriptorForType.name == clazz.simpleName fun KafkaProtobufSerde.messageAsBase64( message: Any ): Option = Either .catch { deserializer() .deserialize( "any", Base64 .getDecoder() .decode(message.toString()) .toByteString() .toByteArray() ) }.getOrNull() .toOption() fun Message.onMatchingAssert( descriptor: String, assert: (message: Message) -> Boolean ): Boolean = descriptor == descriptorForType.name && assert(this) ================================================ FILE: examples/spring-streams-example/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.stove.spring.streams.example.e2e.StoveConfig ================================================ FILE: examples/spring-streams-example/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: go/stove-kafka/bridge.go ================================================ // Package stovekafka provides a Kafka message bridge for Stove e2e testing. // // It forwards produced/consumed Kafka messages via gRPC to Stove's // StoveKafkaObserverGrpcServer, enabling shouldBeConsumed and // shouldBePublished assertions in Kotlin tests. // // The core bridge is library-agnostic. Use the appropriate subpackage // for your Kafka client: // // - sarama — IBM/sarama interceptors // - franz — twmb/franz-go hooks // - segmentio — segmentio/kafka-go helpers // // Example with IBM/sarama: // // bridge, _ := stovekafka.NewBridgeFromEnv() // config.Producer.Interceptors = []sarama.ProducerInterceptor{ // &stovesarama.ProducerInterceptor{Bridge: bridge}, // } // // Example with franz-go: // // bridge, _ := stovekafka.NewBridgeFromEnv() // client, _ := kgo.NewClient(kgo.WithHooks(&franz.Hook{Bridge: bridge})) // // Example with kafka-go: // // bridge, _ := stovekafka.NewBridgeFromEnv() // _ = writer.WriteMessages(ctx, msgs...) // segmentio.ReportWritten(ctx, bridge, msgs...) package stovekafka import ( "context" "fmt" "log" "os" "github.com/google/uuid" "github.com/trendyol/stove/go/stove-kafka/stoveobserver" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) // PublishedMessage is a library-agnostic representation of a produced Kafka message. type PublishedMessage struct { Topic string Key string Value []byte Headers map[string]string } // ConsumedMessage is a library-agnostic representation of a consumed Kafka message. type ConsumedMessage struct { Topic string Key string Value []byte Partition int32 Offset int64 Headers map[string]string } const envBridgePort = "STOVE_KAFKA_BRIDGE_PORT" const envBridgeHost = "STOVE_KAFKA_BRIDGE_HOST" // Bridge wraps the gRPC client to the Stove Kafka observer server. // A nil Bridge is safe to use — all methods are no-ops. type Bridge struct { client stoveobserver.StoveKafkaObserverServiceClient conn *grpc.ClientConn } // NewBridge connects to the Stove observer on the given port. // Returns (nil, nil) if port is empty (production mode — zero overhead). func NewBridge(port string) (*Bridge, error) { if port == "" { return nil, nil } host := os.Getenv(envBridgeHost) if host == "" { host = "localhost" } target := fmt.Sprintf("%s:%s", host, port) conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, fmt.Errorf("stove bridge: failed to connect to %s: %w", target, err) } log.Printf("Stove Kafka bridge connected to %s", target) return &Bridge{ client: stoveobserver.NewStoveKafkaObserverServiceClient(conn), conn: conn, }, nil } // NewBridgeFromEnv reads the STOVE_KAFKA_BRIDGE_PORT environment variable. // Returns (nil, nil) if not set (production mode). func NewBridgeFromEnv() (*Bridge, error) { return NewBridge(os.Getenv(envBridgePort)) } // Close shuts down the gRPC connection. Safe to call on nil Bridge. func (b *Bridge) Close() error { if b == nil { return nil } return b.conn.Close() } // ReportPublished sends a published message to the Stove observer. // Safe to call on nil Bridge (no-op). func (b *Bridge) ReportPublished(ctx context.Context, msg *PublishedMessage) error { if b == nil { return nil } _, err := b.client.OnPublishedMessage(ctx, &stoveobserver.PublishedMessage{ Id: uuid.New().String(), Message: msg.Value, Topic: msg.Topic, Key: msg.Key, Headers: msg.Headers, }) if err != nil { log.Printf("stove bridge: failed to report published message: %v", err) } return err } // ReportConsumed sends a consumed message to the Stove observer. // Safe to call on nil Bridge (no-op). func (b *Bridge) ReportConsumed(ctx context.Context, msg *ConsumedMessage) error { if b == nil { return nil } _, err := b.client.OnConsumedMessage(ctx, &stoveobserver.ConsumedMessage{ Id: uuid.New().String(), Message: msg.Value, Topic: msg.Topic, Partition: msg.Partition, Offset: msg.Offset, Key: msg.Key, Headers: msg.Headers, }) if err != nil { log.Printf("stove bridge: failed to report consumed message: %v", err) } return err } // ReportCommitted sends a committed offset to the Stove observer. // Safe to call on nil Bridge (no-op). func (b *Bridge) ReportCommitted(ctx context.Context, topic string, partition int32, offset int64) error { if b == nil { return nil } _, err := b.client.OnCommittedMessage(ctx, &stoveobserver.CommittedMessage{ Id: uuid.New().String(), Topic: topic, Partition: partition, Offset: offset, }) if err != nil { log.Printf("stove bridge: failed to report committed message: %v", err) } return err } ================================================ FILE: go/stove-kafka/bridge_test.go ================================================ package stovekafka import ( "context" "testing" ) func TestNilBridge_ReportPublished(t *testing.T) { var b *Bridge err := b.ReportPublished(context.Background(), &PublishedMessage{ Topic: "test-topic", Key: "key", Value: []byte("value"), }) if err != nil { t.Fatalf("expected nil error from nil bridge, got %v", err) } } func TestNilBridge_ReportConsumed(t *testing.T) { var b *Bridge err := b.ReportConsumed(context.Background(), &ConsumedMessage{ Topic: "test-topic", Value: []byte("value"), }) if err != nil { t.Fatalf("expected nil error from nil bridge, got %v", err) } } func TestNilBridge_ReportCommitted(t *testing.T) { var b *Bridge err := b.ReportCommitted(context.Background(), "test-topic", 0, 1) if err != nil { t.Fatalf("expected nil error from nil bridge, got %v", err) } } func TestNilBridge_Close(t *testing.T) { var b *Bridge err := b.Close() if err != nil { t.Fatalf("expected nil error from nil bridge close, got %v", err) } } func TestNewBridge_EmptyPort(t *testing.T) { b, err := NewBridge("") if err != nil { t.Fatalf("expected nil error, got %v", err) } if b != nil { t.Fatalf("expected nil bridge for empty port, got %+v", b) } } func TestNewBridgeFromEnv_Unset(t *testing.T) { t.Setenv("STOVE_KAFKA_BRIDGE_PORT", "") b, err := NewBridgeFromEnv() if err != nil { t.Fatalf("expected nil error, got %v", err) } if b != nil { t.Fatalf("expected nil bridge when env unset, got %+v", b) } } ================================================ FILE: go/stove-kafka/franz/hooks.go ================================================ // Package franz provides Stove Kafka bridge hooks for twmb/franz-go. // // Register the hook when creating a franz-go client: // // bridge, _ := stovekafka.NewBridgeFromEnv() // // client, _ := kgo.NewClient( // kgo.SeedBrokers("localhost:9092"), // kgo.WithHooks(&franz.Hook{Bridge: bridge}), // ) package franz import ( "context" "github.com/twmb/franz-go/pkg/kgo" stovekafka "github.com/trendyol/stove/go/stove-kafka" ) // Hook implements franz-go's HookProduceRecordBuffered and HookFetchRecordBuffered. // When Bridge is nil (production mode), all methods return immediately with zero overhead. type Hook struct { Bridge *stovekafka.Bridge } // OnProduceRecordBuffered is called when a record is buffered for producing. // It reports the message to the Stove observer for shouldBePublished assertions. func (h *Hook) OnProduceRecordBuffered(r *kgo.Record) { if h.Bridge == nil { return } _ = h.Bridge.ReportPublished(context.Background(), &stovekafka.PublishedMessage{ Topic: r.Topic, Key: string(r.Key), Value: r.Value, Headers: recordHeaders(r.Headers), }) } // OnFetchRecordBuffered is called when a consumed record is buffered. // It reports the consumed message and pre-reports the commit (offset+1) // to the Stove observer for shouldBeConsumed assertions. func (h *Hook) OnFetchRecordBuffered(r *kgo.Record) { if h.Bridge == nil { return } _ = h.Bridge.ReportConsumed(context.Background(), &stovekafka.ConsumedMessage{ Topic: r.Topic, Key: string(r.Key), Value: r.Value, Partition: r.Partition, Offset: r.Offset, Headers: recordHeaders(r.Headers), }) _ = h.Bridge.ReportCommitted(context.Background(), r.Topic, r.Partition, r.Offset+1) } func recordHeaders(headers []kgo.RecordHeader) map[string]string { m := make(map[string]string, len(headers)) for _, h := range headers { m[h.Key] = string(h.Value) } return m } ================================================ FILE: go/stove-kafka/franz/hooks_test.go ================================================ package franz import ( "testing" "github.com/twmb/franz-go/pkg/kgo" ) func TestHook_NilBridge_OnProduceRecordBuffered(t *testing.T) { h := &Hook{Bridge: nil} h.OnProduceRecordBuffered(&kgo.Record{ Topic: "test-topic", Key: []byte("key"), Value: []byte("value"), }) } func TestHook_NilBridge_OnFetchRecordBuffered(t *testing.T) { h := &Hook{Bridge: nil} h.OnFetchRecordBuffered(&kgo.Record{ Topic: "test-topic", Partition: 0, Offset: 42, Key: []byte("key"), Value: []byte("value"), }) } func TestRecordHeaders(t *testing.T) { headers := []kgo.RecordHeader{ {Key: "h1", Value: []byte("v1")}, {Key: "h2", Value: []byte("v2")}, } m := recordHeaders(headers) if len(m) != 2 { t.Fatalf("expected 2 headers, got %d", len(m)) } if m["h1"] != "v1" || m["h2"] != "v2" { t.Fatalf("unexpected headers: %v", m) } } func TestRecordHeaders_Empty(t *testing.T) { m := recordHeaders(nil) if len(m) != 0 { t.Fatalf("expected 0 headers, got %d", len(m)) } } ================================================ FILE: go/stove-kafka/go.mod ================================================ module github.com/trendyol/stove/go/stove-kafka go 1.26.2 require ( github.com/IBM/sarama v1.48.1 github.com/google/uuid v1.6.0 github.com/segmentio/kafka-go v0.4.51 github.com/twmb/franz-go v1.21.1 google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect ) ================================================ FILE: go/stove-kafka/go.sum ================================================ github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318= github.com/IBM/sarama v1.47.0/go.mod h1:7gLLIU97nznOmA6TX++Qds+DRxH89P2XICY2KAQUzAY= github.com/IBM/sarama v1.48.0 h1:9LJS0VNeg/boXxT/GLAMDKX6uSQ1mr/5F/j4v9gSeBQ= github.com/IBM/sarama v1.48.0/go.mod h1:UhvwPF8zilmLOSd6O+ENzdycCJYwMww1U9DJOZpoCro= github.com/IBM/sarama v1.48.1 h1:x1dSWebprjjE7Wr7n8RVAxwa4mt4O9JejRxnZrGIXk0= github.com/IBM/sarama v1.48.1/go.mod h1:m/Q1aFezH82/AglfTpJbw/fO0ZybYXhPgTmvajiZX50= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc= github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twmb/franz-go v1.20.7 h1:P4MGSXJjjAPP3NRGPCks/Lrq+j+twWMVl1qYCVgNmWY= github.com/twmb/franz-go v1.20.7/go.mod h1:0bRX9HZVaoueqFWhPZNi2ODnJL7DNa6mK0HeCrC2bNU= github.com/twmb/franz-go v1.21.0 h1:J3uB/poWgHD6VIilER2uCPFAZHDRXVFT+11pBgRKod4= github.com/twmb/franz-go v1.21.0/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: go/stove-kafka/sarama/interceptors.go ================================================ // Package sarama provides Stove Kafka bridge interceptors for IBM/sarama. // // Wire the interceptors into your sarama.Config: // // bridge, _ := stovekafka.NewBridgeFromEnv() // // config := sarama.NewConfig() // config.Producer.Interceptors = []sarama.ProducerInterceptor{ // &stovesarama.ProducerInterceptor{Bridge: bridge}, // } // config.Consumer.Interceptors = []sarama.ConsumerInterceptor{ // &stovesarama.ConsumerInterceptor{Bridge: bridge}, // } package sarama import ( "context" "github.com/IBM/sarama" stovekafka "github.com/trendyol/stove/go/stove-kafka" ) // ProducerInterceptor implements sarama.ProducerInterceptor. // It forwards every sent message to the Stove observer via gRPC. // When Bridge is nil (production mode), OnSend returns immediately with zero overhead. type ProducerInterceptor struct { Bridge *stovekafka.Bridge } // OnSend is called when a message is about to be sent to Kafka. // It reports the message to the Stove observer for shouldBePublished assertions. // When Bridge is nil (production), returns immediately without any encoding or allocation. func (i *ProducerInterceptor) OnSend(msg *sarama.ProducerMessage) { if i.Bridge == nil { return } value, err := msg.Value.Encode() if err != nil { return } key, _ := encodeSaramaKey(msg.Key) _ = i.Bridge.ReportPublished(context.Background(), &stovekafka.PublishedMessage{ Topic: msg.Topic, Key: key, Value: value, Headers: producerHeaders(msg.Headers), }) } // ConsumerInterceptor implements sarama.ConsumerInterceptor. // It forwards every consumed message to the Stove observer via gRPC, // and pre-reports the commit (offset+1) since Sarama has no onCommit interceptor. // When Bridge is nil (production mode), OnConsume returns immediately with zero overhead. type ConsumerInterceptor struct { Bridge *stovekafka.Bridge } // OnConsume is called when a message is consumed from Kafka. // It reports the consumed message and a pre-committed offset (offset+1) to the observer. // This satisfies Stove's shouldBeConsumed which checks isCommitted(offset+1). // When Bridge is nil (production), returns immediately without any encoding or allocation. func (i *ConsumerInterceptor) OnConsume(msg *sarama.ConsumerMessage) { if i.Bridge == nil { return } _ = i.Bridge.ReportConsumed(context.Background(), &stovekafka.ConsumedMessage{ Topic: msg.Topic, Key: string(msg.Key), Value: msg.Value, Partition: msg.Partition, Offset: msg.Offset, Headers: consumerHeaders(msg.Headers), }) _ = i.Bridge.ReportCommitted(context.Background(), msg.Topic, msg.Partition, msg.Offset+1) } func encodeSaramaKey(key sarama.Encoder) (string, error) { if key == nil { return "", nil } keyBytes, err := key.Encode() if err != nil { return "", err } return string(keyBytes), nil } func producerHeaders(headers []sarama.RecordHeader) map[string]string { m := make(map[string]string, len(headers)) for _, h := range headers { m[string(h.Key)] = string(h.Value) } return m } func consumerHeaders(headers []*sarama.RecordHeader) map[string]string { m := make(map[string]string, len(headers)) for _, h := range headers { m[string(h.Key)] = string(h.Value) } return m } ================================================ FILE: go/stove-kafka/sarama/interceptors_test.go ================================================ package sarama import ( "testing" "github.com/IBM/sarama" ) func TestProducerInterceptor_NilBridge(t *testing.T) { i := &ProducerInterceptor{Bridge: nil} // Should not panic i.OnSend(&sarama.ProducerMessage{ Topic: "test-topic", Key: sarama.StringEncoder("key"), Value: sarama.StringEncoder("value"), }) } func TestConsumerInterceptor_NilBridge(t *testing.T) { i := &ConsumerInterceptor{Bridge: nil} // Should not panic i.OnConsume(&sarama.ConsumerMessage{ Topic: "test-topic", Partition: 0, Offset: 42, Value: []byte("value"), }) } func TestEncodeSaramaKey_Nil(t *testing.T) { key, err := encodeSaramaKey(nil) if err != nil { t.Fatalf("expected nil error, got %v", err) } if key != "" { t.Fatalf("expected empty string, got %q", key) } } func TestEncodeSaramaKey_String(t *testing.T) { key, err := encodeSaramaKey(sarama.StringEncoder("my-key")) if err != nil { t.Fatalf("expected nil error, got %v", err) } if key != "my-key" { t.Fatalf("expected %q, got %q", "my-key", key) } } func TestProducerHeaders(t *testing.T) { headers := []sarama.RecordHeader{ {Key: []byte("h1"), Value: []byte("v1")}, {Key: []byte("h2"), Value: []byte("v2")}, } m := producerHeaders(headers) if len(m) != 2 { t.Fatalf("expected 2 headers, got %d", len(m)) } if m["h1"] != "v1" || m["h2"] != "v2" { t.Fatalf("unexpected headers: %v", m) } } func TestProducerHeaders_Empty(t *testing.T) { m := producerHeaders(nil) if len(m) != 0 { t.Fatalf("expected 0 headers, got %d", len(m)) } } func TestConsumerHeaders(t *testing.T) { headers := []*sarama.RecordHeader{ {Key: []byte("h1"), Value: []byte("v1")}, {Key: []byte("h2"), Value: []byte("v2")}, } m := consumerHeaders(headers) if len(m) != 2 { t.Fatalf("expected 2 headers, got %d", len(m)) } if m["h1"] != "v1" || m["h2"] != "v2" { t.Fatalf("unexpected headers: %v", m) } } func TestConsumerHeaders_Empty(t *testing.T) { m := consumerHeaders(nil) if len(m) != 0 { t.Fatalf("expected 0 headers, got %d", len(m)) } } ================================================ FILE: go/stove-kafka/segmentio/bridge.go ================================================ // Package segmentio provides Stove Kafka bridge helpers for segmentio/kafka-go. // // kafka-go does not have interceptor interfaces. Call ReportWritten after // Writer.WriteMessages and ReportRead after Reader.ReadMessage/FetchMessage: // // bridge, _ := stovekafka.NewBridgeFromEnv() // // // After producing // _ = writer.WriteMessages(ctx, msgs...) // segmentio.ReportWritten(ctx, bridge, msgs...) // // // After consuming // msg, _ := reader.ReadMessage(ctx) // segmentio.ReportRead(ctx, bridge, msg) package segmentio import ( "context" kafka "github.com/segmentio/kafka-go" stovekafka "github.com/trendyol/stove/go/stove-kafka" ) // ReportWritten reports produced messages to the Stove bridge. // Safe to call with nil bridge (no-op, zero overhead). func ReportWritten(ctx context.Context, bridge *stovekafka.Bridge, msgs ...kafka.Message) { if bridge == nil { return } for _, msg := range msgs { _ = bridge.ReportPublished(ctx, toPublished(msg)) } } // ReportRead reports a consumed message and pre-reports the commit (offset+1) // to the Stove bridge. // Safe to call with nil bridge (no-op, zero overhead). func ReportRead(ctx context.Context, bridge *stovekafka.Bridge, msg kafka.Message) { if bridge == nil { return } _ = bridge.ReportConsumed(ctx, toConsumed(msg)) _ = bridge.ReportCommitted(ctx, msg.Topic, int32(msg.Partition), msg.Offset+1) } func toPublished(msg kafka.Message) *stovekafka.PublishedMessage { return &stovekafka.PublishedMessage{ Topic: msg.Topic, Key: string(msg.Key), Value: msg.Value, Headers: messageHeaders(msg.Headers), } } func toConsumed(msg kafka.Message) *stovekafka.ConsumedMessage { return &stovekafka.ConsumedMessage{ Topic: msg.Topic, Key: string(msg.Key), Value: msg.Value, Partition: int32(msg.Partition), Offset: msg.Offset, Headers: messageHeaders(msg.Headers), } } func messageHeaders(headers []kafka.Header) map[string]string { m := make(map[string]string, len(headers)) for _, h := range headers { m[h.Key] = string(h.Value) } return m } ================================================ FILE: go/stove-kafka/segmentio/bridge_test.go ================================================ package segmentio import ( "context" "testing" kafka "github.com/segmentio/kafka-go" ) func TestReportWritten_NilBridge(t *testing.T) { ReportWritten(context.Background(), nil, kafka.Message{ Topic: "test-topic", Key: []byte("key"), Value: []byte("value"), }) } func TestReportRead_NilBridge(t *testing.T) { ReportRead(context.Background(), nil, kafka.Message{ Topic: "test-topic", Partition: 0, Offset: 42, Key: []byte("key"), Value: []byte("value"), }) } func TestMessageHeaders(t *testing.T) { headers := []kafka.Header{ {Key: "h1", Value: []byte("v1")}, {Key: "h2", Value: []byte("v2")}, } m := messageHeaders(headers) if len(m) != 2 { t.Fatalf("expected 2 headers, got %d", len(m)) } if m["h1"] != "v1" || m["h2"] != "v2" { t.Fatalf("unexpected headers: %v", m) } } func TestMessageHeaders_Empty(t *testing.T) { m := messageHeaders(nil) if len(m) != 0 { t.Fatalf("expected 0 headers, got %d", len(m)) } } func TestToPublished(t *testing.T) { msg := kafka.Message{ Topic: "test-topic", Key: []byte("key"), Value: []byte("value"), Headers: []kafka.Header{{Key: "h1", Value: []byte("v1")}}, } pub := toPublished(msg) if pub.Topic != "test-topic" || pub.Key != "key" || string(pub.Value) != "value" { t.Fatalf("unexpected published message: %+v", pub) } if pub.Headers["h1"] != "v1" { t.Fatalf("unexpected headers: %v", pub.Headers) } } func TestToConsumed(t *testing.T) { msg := kafka.Message{ Topic: "test-topic", Partition: 3, Offset: 99, Key: []byte("key"), Value: []byte("value"), Headers: []kafka.Header{{Key: "h1", Value: []byte("v1")}}, } con := toConsumed(msg) if con.Topic != "test-topic" || con.Partition != 3 || con.Offset != 99 { t.Fatalf("unexpected consumed message: %+v", con) } if con.Key != "key" || string(con.Value) != "value" { t.Fatalf("unexpected key/value: %+v", con) } if con.Headers["h1"] != "v1" { t.Fatalf("unexpected headers: %v", con.Headers) } } ================================================ FILE: go/stove-kafka/stoveobserver/messages.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc v7.34.1 // source: messages.proto // buf:lint:ignore FILE_SAME_PACKAGE package stoveobserver import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type HealthCheckResponse_ServingStatus int32 const ( HealthCheckResponse_UNKNOWN HealthCheckResponse_ServingStatus = 0 HealthCheckResponse_SERVING HealthCheckResponse_ServingStatus = 1 HealthCheckResponse_NOT_SERVING HealthCheckResponse_ServingStatus = 2 HealthCheckResponse_SERVICE_UNKNOWN HealthCheckResponse_ServingStatus = 3 // Used only by the Watch method. ) // Enum value maps for HealthCheckResponse_ServingStatus. var ( HealthCheckResponse_ServingStatus_name = map[int32]string{ 0: "UNKNOWN", 1: "SERVING", 2: "NOT_SERVING", 3: "SERVICE_UNKNOWN", } HealthCheckResponse_ServingStatus_value = map[string]int32{ "UNKNOWN": 0, "SERVING": 1, "NOT_SERVING": 2, "SERVICE_UNKNOWN": 3, } ) func (x HealthCheckResponse_ServingStatus) Enum() *HealthCheckResponse_ServingStatus { p := new(HealthCheckResponse_ServingStatus) *p = x return p } func (x HealthCheckResponse_ServingStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (HealthCheckResponse_ServingStatus) Descriptor() protoreflect.EnumDescriptor { return file_messages_proto_enumTypes[0].Descriptor() } func (HealthCheckResponse_ServingStatus) Type() protoreflect.EnumType { return &file_messages_proto_enumTypes[0] } func (x HealthCheckResponse_ServingStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use HealthCheckResponse_ServingStatus.Descriptor instead. func (HealthCheckResponse_ServingStatus) EnumDescriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{6, 0} } type ConsumedMessage struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Message []byte `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"` Partition int32 `protobuf:"varint,4,opt,name=partition,proto3" json:"partition,omitempty"` Offset int64 `protobuf:"varint,5,opt,name=offset,proto3" json:"offset,omitempty"` Key string `protobuf:"bytes,6,opt,name=key,proto3" json:"key,omitempty"` Headers map[string]string `protobuf:"bytes,8,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ConsumedMessage) Reset() { *x = ConsumedMessage{} mi := &file_messages_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ConsumedMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*ConsumedMessage) ProtoMessage() {} func (x *ConsumedMessage) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ConsumedMessage.ProtoReflect.Descriptor instead. func (*ConsumedMessage) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{0} } func (x *ConsumedMessage) GetId() string { if x != nil { return x.Id } return "" } func (x *ConsumedMessage) GetMessage() []byte { if x != nil { return x.Message } return nil } func (x *ConsumedMessage) GetTopic() string { if x != nil { return x.Topic } return "" } func (x *ConsumedMessage) GetPartition() int32 { if x != nil { return x.Partition } return 0 } func (x *ConsumedMessage) GetOffset() int64 { if x != nil { return x.Offset } return 0 } func (x *ConsumedMessage) GetKey() string { if x != nil { return x.Key } return "" } func (x *ConsumedMessage) GetHeaders() map[string]string { if x != nil { return x.Headers } return nil } type PublishedMessage struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Message []byte `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"` Key string `protobuf:"bytes,4,opt,name=key,proto3" json:"key,omitempty"` Headers map[string]string `protobuf:"bytes,5,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PublishedMessage) Reset() { *x = PublishedMessage{} mi := &file_messages_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *PublishedMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*PublishedMessage) ProtoMessage() {} func (x *PublishedMessage) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use PublishedMessage.ProtoReflect.Descriptor instead. func (*PublishedMessage) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{1} } func (x *PublishedMessage) GetId() string { if x != nil { return x.Id } return "" } func (x *PublishedMessage) GetMessage() []byte { if x != nil { return x.Message } return nil } func (x *PublishedMessage) GetTopic() string { if x != nil { return x.Topic } return "" } func (x *PublishedMessage) GetKey() string { if x != nil { return x.Key } return "" } func (x *PublishedMessage) GetHeaders() map[string]string { if x != nil { return x.Headers } return nil } type CommittedMessage struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Topic string `protobuf:"bytes,2,opt,name=topic,proto3" json:"topic,omitempty"` Partition int32 `protobuf:"varint,3,opt,name=partition,proto3" json:"partition,omitempty"` Offset int64 `protobuf:"varint,4,opt,name=offset,proto3" json:"offset,omitempty"` Metadata string `protobuf:"bytes,5,opt,name=metadata,proto3" json:"metadata,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CommittedMessage) Reset() { *x = CommittedMessage{} mi := &file_messages_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *CommittedMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*CommittedMessage) ProtoMessage() {} func (x *CommittedMessage) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use CommittedMessage.ProtoReflect.Descriptor instead. func (*CommittedMessage) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{2} } func (x *CommittedMessage) GetId() string { if x != nil { return x.Id } return "" } func (x *CommittedMessage) GetTopic() string { if x != nil { return x.Topic } return "" } func (x *CommittedMessage) GetPartition() int32 { if x != nil { return x.Partition } return 0 } func (x *CommittedMessage) GetOffset() int64 { if x != nil { return x.Offset } return 0 } func (x *CommittedMessage) GetMetadata() string { if x != nil { return x.Metadata } return "" } type AcknowledgedMessage struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Topic string `protobuf:"bytes,2,opt,name=topic,proto3" json:"topic,omitempty"` Partition int32 `protobuf:"varint,3,opt,name=partition,proto3" json:"partition,omitempty"` Offset int64 `protobuf:"varint,4,opt,name=offset,proto3" json:"offset,omitempty"` Exception string `protobuf:"bytes,5,opt,name=exception,proto3" json:"exception,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AcknowledgedMessage) Reset() { *x = AcknowledgedMessage{} mi := &file_messages_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AcknowledgedMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*AcknowledgedMessage) ProtoMessage() {} func (x *AcknowledgedMessage) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AcknowledgedMessage.ProtoReflect.Descriptor instead. func (*AcknowledgedMessage) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{3} } func (x *AcknowledgedMessage) GetId() string { if x != nil { return x.Id } return "" } func (x *AcknowledgedMessage) GetTopic() string { if x != nil { return x.Topic } return "" } func (x *AcknowledgedMessage) GetPartition() int32 { if x != nil { return x.Partition } return 0 } func (x *AcknowledgedMessage) GetOffset() int64 { if x != nil { return x.Offset } return 0 } func (x *AcknowledgedMessage) GetException() string { if x != nil { return x.Exception } return "" } type Reply struct { state protoimpl.MessageState `protogen:"open.v1"` Status int32 `protobuf:"varint,3,opt,name=status,proto3" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Reply) Reset() { *x = Reply{} mi := &file_messages_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Reply) String() string { return protoimpl.X.MessageStringOf(x) } func (*Reply) ProtoMessage() {} func (x *Reply) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Reply.ProtoReflect.Descriptor instead. func (*Reply) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{4} } func (x *Reply) GetStatus() int32 { if x != nil { return x.Status } return 0 } type HealthCheckRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HealthCheckRequest) Reset() { *x = HealthCheckRequest{} mi := &file_messages_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HealthCheckRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*HealthCheckRequest) ProtoMessage() {} func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead. func (*HealthCheckRequest) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{5} } func (x *HealthCheckRequest) GetService() string { if x != nil { return x.Service } return "" } type HealthCheckResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status HealthCheckResponse_ServingStatus `protobuf:"varint,1,opt,name=status,proto3,enum=com.trendyol.stove.kafka.HealthCheckResponse_ServingStatus" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HealthCheckResponse) Reset() { *x = HealthCheckResponse{} mi := &file_messages_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HealthCheckResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*HealthCheckResponse) ProtoMessage() {} func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead. func (*HealthCheckResponse) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{6} } func (x *HealthCheckResponse) GetStatus() HealthCheckResponse_ServingStatus { if x != nil { return x.Status } return HealthCheckResponse_UNKNOWN } var File_messages_proto protoreflect.FileDescriptor const file_messages_proto_rawDesc = "" + "\n" + "\x0emessages.proto\x12\x18com.trendyol.stove.kafka\"\xa7\x02\n" + "\x0fConsumedMessage\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\amessage\x18\x02 \x01(\fR\amessage\x12\x14\n" + "\x05topic\x18\x03 \x01(\tR\x05topic\x12\x1c\n" + "\tpartition\x18\x04 \x01(\x05R\tpartition\x12\x16\n" + "\x06offset\x18\x05 \x01(\x03R\x06offset\x12\x10\n" + "\x03key\x18\x06 \x01(\tR\x03key\x12P\n" + "\aheaders\x18\b \x03(\v26.com.trendyol.stove.kafka.ConsumedMessage.HeadersEntryR\aheaders\x1a:\n" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf3\x01\n" + "\x10PublishedMessage\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\amessage\x18\x02 \x01(\fR\amessage\x12\x14\n" + "\x05topic\x18\x03 \x01(\tR\x05topic\x12\x10\n" + "\x03key\x18\x04 \x01(\tR\x03key\x12Q\n" + "\aheaders\x18\x05 \x03(\v27.com.trendyol.stove.kafka.PublishedMessage.HeadersEntryR\aheaders\x1a:\n" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8a\x01\n" + "\x10CommittedMessage\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05topic\x18\x02 \x01(\tR\x05topic\x12\x1c\n" + "\tpartition\x18\x03 \x01(\x05R\tpartition\x12\x16\n" + "\x06offset\x18\x04 \x01(\x03R\x06offset\x12\x1a\n" + "\bmetadata\x18\x05 \x01(\tR\bmetadata\"\x8f\x01\n" + "\x13AcknowledgedMessage\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05topic\x18\x02 \x01(\tR\x05topic\x12\x1c\n" + "\tpartition\x18\x03 \x01(\x05R\tpartition\x12\x16\n" + "\x06offset\x18\x04 \x01(\x03R\x06offset\x12\x1c\n" + "\texception\x18\x05 \x01(\tR\texception\"\x1f\n" + "\x05Reply\x12\x16\n" + "\x06status\x18\x03 \x01(\x05R\x06status\".\n" + "\x12HealthCheckRequest\x12\x18\n" + "\aservice\x18\x01 \x01(\tR\aservice\"\xbb\x01\n" + "\x13HealthCheckResponse\x12S\n" + "\x06status\x18\x01 \x01(\x0e2;.com.trendyol.stove.kafka.HealthCheckResponse.ServingStatusR\x06status\"O\n" + "\rServingStatus\x12\v\n" + "\aUNKNOWN\x10\x00\x12\v\n" + "\aSERVING\x10\x01\x12\x0f\n" + "\vNOT_SERVING\x10\x02\x12\x13\n" + "\x0fSERVICE_UNKNOWN\x10\x032\xa1\x04\n" + "\x19StoveKafkaObserverService\x12l\n" + "\vhealthCheck\x12,.com.trendyol.stove.kafka.HealthCheckRequest\x1a-.com.trendyol.stove.kafka.HealthCheckResponse\"\x00\x12a\n" + "\x11onConsumedMessage\x12).com.trendyol.stove.kafka.ConsumedMessage\x1a\x1f.com.trendyol.stove.kafka.Reply\"\x00\x12c\n" + "\x12onPublishedMessage\x12*.com.trendyol.stove.kafka.PublishedMessage\x1a\x1f.com.trendyol.stove.kafka.Reply\"\x00\x12c\n" + "\x12onCommittedMessage\x12*.com.trendyol.stove.kafka.CommittedMessage\x1a\x1f.com.trendyol.stove.kafka.Reply\"\x00\x12i\n" + "\x15onAcknowledgedMessage\x12-.com.trendyol.stove.kafka.AcknowledgedMessage\x1a\x1f.com.trendyol.stove.kafka.Reply\"\x00b\x06proto3" var ( file_messages_proto_rawDescOnce sync.Once file_messages_proto_rawDescData []byte ) func file_messages_proto_rawDescGZIP() []byte { file_messages_proto_rawDescOnce.Do(func() { file_messages_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc))) }) return file_messages_proto_rawDescData } var file_messages_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_messages_proto_goTypes = []any{ (HealthCheckResponse_ServingStatus)(0), // 0: com.trendyol.stove.kafka.HealthCheckResponse.ServingStatus (*ConsumedMessage)(nil), // 1: com.trendyol.stove.kafka.ConsumedMessage (*PublishedMessage)(nil), // 2: com.trendyol.stove.kafka.PublishedMessage (*CommittedMessage)(nil), // 3: com.trendyol.stove.kafka.CommittedMessage (*AcknowledgedMessage)(nil), // 4: com.trendyol.stove.kafka.AcknowledgedMessage (*Reply)(nil), // 5: com.trendyol.stove.kafka.Reply (*HealthCheckRequest)(nil), // 6: com.trendyol.stove.kafka.HealthCheckRequest (*HealthCheckResponse)(nil), // 7: com.trendyol.stove.kafka.HealthCheckResponse nil, // 8: com.trendyol.stove.kafka.ConsumedMessage.HeadersEntry nil, // 9: com.trendyol.stove.kafka.PublishedMessage.HeadersEntry } var file_messages_proto_depIdxs = []int32{ 8, // 0: com.trendyol.stove.kafka.ConsumedMessage.headers:type_name -> com.trendyol.stove.kafka.ConsumedMessage.HeadersEntry 9, // 1: com.trendyol.stove.kafka.PublishedMessage.headers:type_name -> com.trendyol.stove.kafka.PublishedMessage.HeadersEntry 0, // 2: com.trendyol.stove.kafka.HealthCheckResponse.status:type_name -> com.trendyol.stove.kafka.HealthCheckResponse.ServingStatus 6, // 3: com.trendyol.stove.kafka.StoveKafkaObserverService.healthCheck:input_type -> com.trendyol.stove.kafka.HealthCheckRequest 1, // 4: com.trendyol.stove.kafka.StoveKafkaObserverService.onConsumedMessage:input_type -> com.trendyol.stove.kafka.ConsumedMessage 2, // 5: com.trendyol.stove.kafka.StoveKafkaObserverService.onPublishedMessage:input_type -> com.trendyol.stove.kafka.PublishedMessage 3, // 6: com.trendyol.stove.kafka.StoveKafkaObserverService.onCommittedMessage:input_type -> com.trendyol.stove.kafka.CommittedMessage 4, // 7: com.trendyol.stove.kafka.StoveKafkaObserverService.onAcknowledgedMessage:input_type -> com.trendyol.stove.kafka.AcknowledgedMessage 7, // 8: com.trendyol.stove.kafka.StoveKafkaObserverService.healthCheck:output_type -> com.trendyol.stove.kafka.HealthCheckResponse 5, // 9: com.trendyol.stove.kafka.StoveKafkaObserverService.onConsumedMessage:output_type -> com.trendyol.stove.kafka.Reply 5, // 10: com.trendyol.stove.kafka.StoveKafkaObserverService.onPublishedMessage:output_type -> com.trendyol.stove.kafka.Reply 5, // 11: com.trendyol.stove.kafka.StoveKafkaObserverService.onCommittedMessage:output_type -> com.trendyol.stove.kafka.Reply 5, // 12: com.trendyol.stove.kafka.StoveKafkaObserverService.onAcknowledgedMessage:output_type -> com.trendyol.stove.kafka.Reply 8, // [8:13] is the sub-list for method output_type 3, // [3:8] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name } func init() { file_messages_proto_init() } func file_messages_proto_init() { if File_messages_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)), NumEnums: 1, NumMessages: 9, NumExtensions: 0, NumServices: 1, }, GoTypes: file_messages_proto_goTypes, DependencyIndexes: file_messages_proto_depIdxs, EnumInfos: file_messages_proto_enumTypes, MessageInfos: file_messages_proto_msgTypes, }.Build() File_messages_proto = out.File file_messages_proto_goTypes = nil file_messages_proto_depIdxs = nil } ================================================ FILE: go/stove-kafka/stoveobserver/messages_grpc.pb.go ================================================ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 // - protoc v7.34.1 // source: messages.proto // buf:lint:ignore FILE_SAME_PACKAGE package stoveobserver import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 const ( StoveKafkaObserverService_HealthCheck_FullMethodName = "/com.trendyol.stove.kafka.StoveKafkaObserverService/healthCheck" StoveKafkaObserverService_OnConsumedMessage_FullMethodName = "/com.trendyol.stove.kafka.StoveKafkaObserverService/onConsumedMessage" StoveKafkaObserverService_OnPublishedMessage_FullMethodName = "/com.trendyol.stove.kafka.StoveKafkaObserverService/onPublishedMessage" StoveKafkaObserverService_OnCommittedMessage_FullMethodName = "/com.trendyol.stove.kafka.StoveKafkaObserverService/onCommittedMessage" StoveKafkaObserverService_OnAcknowledgedMessage_FullMethodName = "/com.trendyol.stove.kafka.StoveKafkaObserverService/onAcknowledgedMessage" ) // StoveKafkaObserverServiceClient is the client API for StoveKafkaObserverService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type StoveKafkaObserverServiceClient interface { HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnConsumedMessage(ctx context.Context, in *ConsumedMessage, opts ...grpc.CallOption) (*Reply, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnPublishedMessage(ctx context.Context, in *PublishedMessage, opts ...grpc.CallOption) (*Reply, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnCommittedMessage(ctx context.Context, in *CommittedMessage, opts ...grpc.CallOption) (*Reply, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnAcknowledgedMessage(ctx context.Context, in *AcknowledgedMessage, opts ...grpc.CallOption) (*Reply, error) } type stoveKafkaObserverServiceClient struct { cc grpc.ClientConnInterface } func NewStoveKafkaObserverServiceClient(cc grpc.ClientConnInterface) StoveKafkaObserverServiceClient { return &stoveKafkaObserverServiceClient{cc} } func (c *stoveKafkaObserverServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(HealthCheckResponse) err := c.cc.Invoke(ctx, StoveKafkaObserverService_HealthCheck_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *stoveKafkaObserverServiceClient) OnConsumedMessage(ctx context.Context, in *ConsumedMessage, opts ...grpc.CallOption) (*Reply, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Reply) err := c.cc.Invoke(ctx, StoveKafkaObserverService_OnConsumedMessage_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *stoveKafkaObserverServiceClient) OnPublishedMessage(ctx context.Context, in *PublishedMessage, opts ...grpc.CallOption) (*Reply, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Reply) err := c.cc.Invoke(ctx, StoveKafkaObserverService_OnPublishedMessage_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *stoveKafkaObserverServiceClient) OnCommittedMessage(ctx context.Context, in *CommittedMessage, opts ...grpc.CallOption) (*Reply, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Reply) err := c.cc.Invoke(ctx, StoveKafkaObserverService_OnCommittedMessage_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *stoveKafkaObserverServiceClient) OnAcknowledgedMessage(ctx context.Context, in *AcknowledgedMessage, opts ...grpc.CallOption) (*Reply, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Reply) err := c.cc.Invoke(ctx, StoveKafkaObserverService_OnAcknowledgedMessage_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // StoveKafkaObserverServiceServer is the server API for StoveKafkaObserverService service. // All implementations must embed UnimplementedStoveKafkaObserverServiceServer // for forward compatibility. type StoveKafkaObserverServiceServer interface { HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnConsumedMessage(context.Context, *ConsumedMessage) (*Reply, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnPublishedMessage(context.Context, *PublishedMessage) (*Reply, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnCommittedMessage(context.Context, *CommittedMessage) (*Reply, error) // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE OnAcknowledgedMessage(context.Context, *AcknowledgedMessage) (*Reply, error) mustEmbedUnimplementedStoveKafkaObserverServiceServer() } // UnimplementedStoveKafkaObserverServiceServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedStoveKafkaObserverServiceServer struct{} func (UnimplementedStoveKafkaObserverServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) { return nil, status.Error(codes.Unimplemented, "method HealthCheck not implemented") } func (UnimplementedStoveKafkaObserverServiceServer) OnConsumedMessage(context.Context, *ConsumedMessage) (*Reply, error) { return nil, status.Error(codes.Unimplemented, "method OnConsumedMessage not implemented") } func (UnimplementedStoveKafkaObserverServiceServer) OnPublishedMessage(context.Context, *PublishedMessage) (*Reply, error) { return nil, status.Error(codes.Unimplemented, "method OnPublishedMessage not implemented") } func (UnimplementedStoveKafkaObserverServiceServer) OnCommittedMessage(context.Context, *CommittedMessage) (*Reply, error) { return nil, status.Error(codes.Unimplemented, "method OnCommittedMessage not implemented") } func (UnimplementedStoveKafkaObserverServiceServer) OnAcknowledgedMessage(context.Context, *AcknowledgedMessage) (*Reply, error) { return nil, status.Error(codes.Unimplemented, "method OnAcknowledgedMessage not implemented") } func (UnimplementedStoveKafkaObserverServiceServer) mustEmbedUnimplementedStoveKafkaObserverServiceServer() { } func (UnimplementedStoveKafkaObserverServiceServer) testEmbeddedByValue() {} // UnsafeStoveKafkaObserverServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to StoveKafkaObserverServiceServer will // result in compilation errors. type UnsafeStoveKafkaObserverServiceServer interface { mustEmbedUnimplementedStoveKafkaObserverServiceServer() } func RegisterStoveKafkaObserverServiceServer(s grpc.ServiceRegistrar, srv StoveKafkaObserverServiceServer) { // If the following call panics, it indicates UnimplementedStoveKafkaObserverServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&StoveKafkaObserverService_ServiceDesc, srv) } func _StoveKafkaObserverService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(HealthCheckRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(StoveKafkaObserverServiceServer).HealthCheck(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: StoveKafkaObserverService_HealthCheck_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(StoveKafkaObserverServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest)) } return interceptor(ctx, in, info, handler) } func _StoveKafkaObserverService_OnConsumedMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ConsumedMessage) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(StoveKafkaObserverServiceServer).OnConsumedMessage(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: StoveKafkaObserverService_OnConsumedMessage_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(StoveKafkaObserverServiceServer).OnConsumedMessage(ctx, req.(*ConsumedMessage)) } return interceptor(ctx, in, info, handler) } func _StoveKafkaObserverService_OnPublishedMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(PublishedMessage) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(StoveKafkaObserverServiceServer).OnPublishedMessage(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: StoveKafkaObserverService_OnPublishedMessage_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(StoveKafkaObserverServiceServer).OnPublishedMessage(ctx, req.(*PublishedMessage)) } return interceptor(ctx, in, info, handler) } func _StoveKafkaObserverService_OnCommittedMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(CommittedMessage) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(StoveKafkaObserverServiceServer).OnCommittedMessage(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: StoveKafkaObserverService_OnCommittedMessage_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(StoveKafkaObserverServiceServer).OnCommittedMessage(ctx, req.(*CommittedMessage)) } return interceptor(ctx, in, info, handler) } func _StoveKafkaObserverService_OnAcknowledgedMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(AcknowledgedMessage) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(StoveKafkaObserverServiceServer).OnAcknowledgedMessage(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: StoveKafkaObserverService_OnAcknowledgedMessage_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(StoveKafkaObserverServiceServer).OnAcknowledgedMessage(ctx, req.(*AcknowledgedMessage)) } return interceptor(ctx, in, info, handler) } // StoveKafkaObserverService_ServiceDesc is the grpc.ServiceDesc for StoveKafkaObserverService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var StoveKafkaObserverService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "com.trendyol.stove.kafka.StoveKafkaObserverService", HandlerType: (*StoveKafkaObserverServiceServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "healthCheck", Handler: _StoveKafkaObserverService_HealthCheck_Handler, }, { MethodName: "onConsumedMessage", Handler: _StoveKafkaObserverService_OnConsumedMessage_Handler, }, { MethodName: "onPublishedMessage", Handler: _StoveKafkaObserverService_OnPublishedMessage_Handler, }, { MethodName: "onCommittedMessage", Handler: _StoveKafkaObserverService_OnCommittedMessage_Handler, }, { MethodName: "onAcknowledgedMessage", Handler: _StoveKafkaObserverService_OnAcknowledgedMessage_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "messages.proto", } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] kotlin = "2.3.21" kotlinx = "1.10.2" exposed = "1.2.0" hikari = "7.0.2" spring-boot = "2.7.18" spring-boot-3x = "3.5.14" spring-boot-4x = "4.0.6" spring-dependency-management = "1.1.7" spring-kafka = "2.9.13" spring-kafka-3x = "3.3.15" spring-kafka-4x = "4.0.5" couchbase = "3.11.2" jackson = "2.21.3" jackson3 = "3.1.3" arrow = "2.2.2.1" io-reactor = "3.8.5" io-reactor-extensions = "1.3.0" slf4j = "2.0.17" kafka = "4.2.0" kafka-kotlin = "0.4.1" kafka-embedded = "4.2.0" kafka-streams-registry = "8.2.0" kover = "0.9.8" ktor = "3.4.3" koin = "4.2.1" quarkus = "3.35.2" r2dbc-postgresql = "0.8.13.RELEASE" elastic = "9.4.0" mongodb = "5.7.0" wiremock = "3.13.2" testcontainers = "2.0.5" cassandra-driver = "4.19.2" mysql = "9.7.0" spotless = "8.4.0" detekt = "1.23.8" wire = "6.2.0" io-grpc = "1.81.0" io-grpc-kotlin = "1.5.0" google-protobuf = "4.34.1" hoplite = "2.9.0" junit-jupiter = "6.0.3" kotest = "6.1.11" mockito = "6.3.0" kotlinx-serialization = "1.11.0" kotlinBinaryCompatibilityValidator = "0.18.1" snakeyaml = "2.6" micronaut = "4.10.23" micronaut-platform = "4.10.13" micronaut-micrometer = "1.3.1" ktlint = "1.8.0" opentelemetry = "1.62.0" opentelemetry-semconv = "1.41.0" opentelemetry-instrumentation = "2.27.0" bytebuddy = "1.18.8" mordant = "3.0.2" [libraries] # exposed exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-java-time = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" } # hikari hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlinx-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version.ref = "kotlinx" } kotlinx-reactive = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactive", version.ref = "kotlinx" } kotlinx-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } kotlinx-jdk8 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8", version.ref = "kotlinx" } kotlinx-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-slf4j", version.ref = "kotlinx" } kotlinx-io-reactor = { module = "io.projectreactor:reactor-core", version.ref = "io-reactor" } kotlinx-io-reactor-extensions = { module = "io.projectreactor.kotlin:reactor-kotlin-extensions", version.ref = "io-reactor-extensions" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "kotlinx-serialization" } # Micronaut snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" } micronaut-platform = { module = "io.micronaut.platform:micronaut-platform", version.ref = "micronaut-platform" } micronaut-kotlin-runtime = { module = "io.micronaut.kotlin:micronaut-kotlin-runtime", version = "4.7.0" } micronaut-serde-jackson = { module = "io.micronaut.serde:micronaut-serde-jackson", version = "2.16.2" } micronaut-http-client = { module = "io.micronaut:micronaut-http-client", version.ref = "micronaut" } micronaut-http-server-netty = { module = "io.micronaut:micronaut-http-server-netty", version.ref = "micronaut" } micronaut-inject = { module = "io.micronaut:micronaut-inject", version.ref = "micronaut" } micronaut-inject-kotlin = { module = "io.micronaut:micronaut-inject-kotlin", version.ref = "micronaut" } micronaut-core = { module = "io.micronaut:micronaut-core", version.ref = "micronaut" } micronaut-test-kotest = { module = "io.micronaut.test:micronaut-test-kotest5", version = "4.10.3" } micronaut-micrometer-core = { module = "io.micronaut.configuration:micronaut-micrometer-core", version.ref = "micronaut-micrometer" } micronaut-data-r2dbc = { module = "io.micronaut.data:micronaut-data-r2dbc", version = "4.14.4" } # Arrow arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } # Kafka kafka = { module = "org.apache.kafka:kafka-clients", version.ref = "kafka" } kafkaKotlin = { module = "io.github.nomisrev:kotlin-kafka", version.ref = "kafka-kotlin" } kafka-streams = { module = "org.apache.kafka:kafka-streams", version.ref = "kafka" } kafka-streams-protobuf-serde = { module = "io.confluent:kafka-streams-protobuf-serde", version.ref = "kafka-streams-registry" } kafka-embedded = { module = "io.github.embeddedkafka:embedded-kafka_2.13", version.ref = "kafka-embedded" } # Couchbase couchbase-kotlin = { module = "com.couchbase.client:kotlin-client", version.ref = "couchbase" } couchbase-client = { module = "com.couchbase.client:java-client", version.ref = "couchbase" } couchbase-client-metrics = { module = "com.couchbase.client:metrics-micrometer", version.ref = "couchbase" } # Jackson jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } # Jackson 3 jackson3-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson3" } # Slfj4 slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } # Ktor ktor-server-host-common = { module = "io.ktor:ktor-server-host-common", version.ref = "ktor" } ktor-server = { module = "io.ktor:ktor-server", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-di = { module = "io.ktor:ktor-server-di", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-plugins-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-jackson-json = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } # koin koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } koin-logger-slf4j = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } # Quarkus quarkus = { module = "io.quarkus:quarkus-bom", version.ref = "quarkus" } quarkus-core = { module = "io.quarkus:quarkus-core", version.ref = "quarkus" } quarkus-rest = { module = "io.quarkus:quarkus-rest", version.ref = "quarkus" } quarkus-rest-jackson = { module = "io.quarkus:quarkus-rest-jackson", version.ref = "quarkus" } quarkus-arc = { module = "io.quarkus:quarkus-arc", version.ref = "quarkus" } quarkus-kotlin = { module = "io.quarkus:quarkus-kotlin", version.ref = "quarkus" } quarkus-agroal = { module = "io.quarkus:quarkus-agroal", version.ref = "quarkus" } quarkus-jdbc-postgresql = { module = "io.quarkus:quarkus-jdbc-postgresql", version.ref = "quarkus" } quarkus-flyway = { module = "io.quarkus:quarkus-flyway", version.ref = "quarkus" } quarkus-messaging-kafka = { module = "io.quarkus:quarkus-messaging-kafka", version.ref = "quarkus" } # r2dbc r2dbc-postgresql = { module = "io.r2dbc:r2dbc-postgresql", version.ref = "r2dbc-postgresql" } kotliquery = { module = "com.github.seratch:kotliquery", version = "1.9.1" } h2Database = { module = "com.h2database:h2", version = "2.4.240" } # postgres postgresql = { module = "org.postgresql:postgresql", version = "42.7.11" } mysql-connector = { module = "com.mysql:mysql-connector-j", version.ref = "mysql" } # elastic elastic = { module = "co.elastic.clients:elasticsearch-java", version.ref = "elastic" } elastic-rest-client = { module = "org.elasticsearch.client:elasticsearch-rest-client", version = "9.4.0" } # mongo mongodb-kotlin-coroutine = { module = "org.mongodb:mongodb-driver-kotlin-coroutine", version.ref = "mongodb" } # misc lettuce-core = { module = "io.lettuce:lettuce-core", version = "7.5.1.RELEASE" } logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.32" } microsoft-sqlserver-jdbc = { module = "com.microsoft.sqlserver:mssql-jdbc", version = "13.4.0.jre11" } ### Testing wiremock-standalone = { module = "org.wiremock:wiremock-standalone", version.ref = "wiremock" } testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } testcontainers-jdbc = { module = "org.testcontainers:testcontainers-jdbc", version.ref = "testcontainers" } testcontainers-kafka = { module = "org.testcontainers:testcontainers-kafka", version.ref = "testcontainers" } testcontainers-couchbase = { module = "org.testcontainers:testcontainers-couchbase", version.ref = "testcontainers" } testcontainers-postgres = { module = "org.testcontainers:testcontainers-postgresql", version.ref = "testcontainers" } testcontainers-elasticsearch = { module = "org.testcontainers:testcontainers-elasticsearch", version.ref = "testcontainers" } testcontainers-mongodb = { module = "org.testcontainers:testcontainers-mongodb", version.ref = "testcontainers" } testcontainers-mssql = { module = "org.testcontainers:testcontainers-mssqlserver", version.ref = "testcontainers" } testcontainers-mysql = { module = "org.testcontainers:testcontainers-mysql", version.ref = "testcontainers" } testcontainers-redis = { module = "com.redis.testcontainers:testcontainers-redis", version = "1.6.4" } testcontainers-cassandra = { module = "org.testcontainers:testcontainers-cassandra", version.ref = "testcontainers" } # Cassandra cassandra-driver-core = { module = "org.apache.cassandra:java-driver-core", version.ref = "cassandra-driver" } # spring-boot 2x spring-boot = { module = "org.springframework.boot:spring-boot", version.ref = "spring-boot" } spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } spring-boot-annotationProcessor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" } spring-boot-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka" } # spring-boot 3x spring-boot-three = { module = "org.springframework.boot:spring-boot", version.ref = "spring-boot-3x" } spring-boot-three-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot-3x" } spring-boot-three-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot-3x" } spring-boot-three-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot-3x" } spring-boot-three-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot-3x" } spring-boot-three-annotationProcessor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot-3x" } spring-boot-three-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka-3x" } spring-boot-three-data-r2dbc = { module = "org.springframework.boot:spring-boot-starter-data-r2dbc", version.ref = "spring-boot-3x" } spring-boot-three-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "spring-boot-3x" } # spring-boot 4x spring-boot-four = { module = "org.springframework.boot:spring-boot", version.ref = "spring-boot-4x" } spring-boot-four-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot-4x" } spring-boot-four-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot-4x" } spring-boot-four-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot-4x" } spring-boot-four-annotationProcessor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot-4x" } spring-boot-four-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka-4x" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } wire-grpc-server = { module = "com.squareup.wiregrpcserver:server", version = "1.0.0-alpha04" } wire-grpc-server-generator = { module = "com.squareup.wiregrpcserver:server-generator", version = "1.0.0-alpha04" } wire-grpc-client = { module = "com.squareup.wire:wire-grpc-client", version.ref = "wire" } wire-grpc-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } io-grpc = { module = "io.grpc:grpc-core", version.ref = "io-grpc" } io-grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "io-grpc" } io-grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "io-grpc" } io-grpc-netty = { module = "io.grpc:grpc-netty", version.ref = "io-grpc" } io-grpc-kotlin = { module = "io.grpc:grpc-kotlin-stub", version.ref = "io-grpc-kotlin" } grpc-protoc-gen-java = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "io-grpc" } grpc-protoc-gen-kotlin = { module = "io.grpc:protoc-gen-grpc-kotlin", version.ref = "io-grpc-kotlin" } google-protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "google-protobuf" } google-protobuf-util = { module = "com.google.protobuf:protobuf-java-util", version.ref = "google-protobuf" } protoc = { module = "com.google.protobuf:protoc", version.ref = "google-protobuf" } hoplite-yaml = { module = "com.sksamuel.hoplite:hoplite-yaml", version.ref = "hoplite" } google-gson = { module = "com.google.code.gson:gson", version = "2.14.0" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version = "3.2.4" } pprint = { module = "io.exoquery:pprint-kotlin", version = "3.0.0" } ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } mordant = { module = "com.github.ajalt.mordant:mordant", version.ref = "mordant" } # OpenTelemetry opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api", version.ref = "opentelemetry" } opentelemetry-sdk = { module = "io.opentelemetry:opentelemetry-sdk", version.ref = "opentelemetry" } opentelemetry-sdk-trace = { module = "io.opentelemetry:opentelemetry-sdk-trace", version.ref = "opentelemetry" } opentelemetry-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", version.ref = "opentelemetry-semconv" } opentelemetry-extension-trace-propagators = { module = "io.opentelemetry:opentelemetry-extension-trace-propagators", version.ref = "opentelemetry" } opentelemetry-instrumentation-annotations = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations", version.ref = "opentelemetry-instrumentation" } opentelemetry-javaagent = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent", version.ref = "opentelemetry-instrumentation" } opentelemetry-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporter-otlp", version.ref = "opentelemetry" } opentelemetry-proto = { module = "io.opentelemetry.proto:opentelemetry-proto", version = "1.9.0-alpha" } # ByteBuddy bytebuddy = { module = "net.bytebuddy:byte-buddy", version.ref = "bytebuddy" } bytebuddy-agent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "bytebuddy" } # testing mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-assertions-core = { module = "io.kotest:kotest-property", version.ref = "kotest" } kotest-arrow = { module = "io.kotest:kotest-assertions-arrow", version.ref = "kotest" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" } [plugins] allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } spring-plugin = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-boot-three = { id = "org.springframework.boot", version.ref = "spring-boot-3x" } spring-boot-four = { id = "org.springframework.boot", version.ref = "spring-boot-4x" } spring-dependencyManagement = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } wire = { id = "com.squareup.wire", version.ref = "wire" } testLogger = { id = "com.adarshr.test-logger", version = "4.0.0" } protobuf = { id = "com.google.protobuf", version = "0.10.0" } quarkus = { id = "io.quarkus", version.ref = "quarkus" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlinBinaryCompatibilityValidator" } google-ksp = { id = "com.google.devtools.ksp", version = "2.3.7" } micronaut-application = { id = "io.micronaut.application", version = "4.6.2" } micronaut-aot = { id = "io.micronaut.aot", version = "4.6.2" } micronaut-library = { id = "io.micronaut.library", version = "4.6.2" } maven-publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.configuration-cache.parallel=true org.gradle.daemon=true org.gradle.parallel=true # Increased heap and metaspace for parallel test execution org.gradle.jvmargs=-XX:+UseParallelGC -Xmx3g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError # Number of parallel workers (adjust based on CI runner cores) org.gradle.workers.max=4 projectDescription=The easiest way of e2e testing in Kotlin projectUrl=https://github.com/Trendyol/stove licenceUrl=https://github.com/Trendyol/stove/blob/master/LICENCE licence=Apache-2.0 license snapshot=1.0.0 version=0.24.0 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: jreleaser.yml ================================================ project: name: stove description: The easiest way of e2e testing in Kotlin license: Apache-2.0 links: homepage: https://github.com/Trendyol/stove documentation: https://github.com/Trendyol/stove bugTracker: https://github.com/Trendyol/stove/issues authors: - Oguzhan Soykan inceptionYear: "2022" release: github: owner: Trendyol name: stove tagName: "v{{projectVersion}}" releaseName: "Stove v{{projectVersion}}" overwrite: false skipTag: false changelog: enabled: true formatted: ALWAYS preset: conventional-commits contributors: enabled: false hide: categories: - merge contributors: - "[bot]" - "GitHub" ================================================ FILE: lib/stove/api/stove.api ================================================ public abstract interface class com/trendyol/stove/containers/ContainerOptions { public abstract fun getCompatibleSubstitute ()Ljava/lang/String; public abstract fun getContainerFn ()Lkotlin/jvm/functions/Function1; public abstract fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public abstract fun getRegistry ()Ljava/lang/String; public abstract fun getTag ()Ljava/lang/String; public abstract fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; } public final class com/trendyol/stove/containers/ContainerOptions$DefaultImpls { public static fun getImageWithTag (Lcom/trendyol/stove/containers/ContainerOptions;)Ljava/lang/String; } public final class com/trendyol/stove/containers/ExecResult { public fun (ILjava/lang/String;Ljava/lang/String;)V public final fun component1 ()I public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun copy (ILjava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/containers/ExecResult; public static synthetic fun copy$default (Lcom/trendyol/stove/containers/ExecResult;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/containers/ExecResult; public fun equals (Ljava/lang/Object;)Z public final fun getExitCode ()I public final fun getStderr ()Ljava/lang/String; public final fun getStdout ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/containers/ProvidedRegistryKt { public static final fun getDEFAULT_REGISTRY ()Ljava/lang/String; public static final fun setDEFAULT_REGISTRY (Ljava/lang/String;)V public static final fun withProvidedRegistry (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public static synthetic fun withProvidedRegistry$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/containers/StoveContainer : com/trendyol/stove/system/abstractions/SystemRuntime { public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public static synthetic fun execCommand$default (Lcom/trendyol/stove/containers/StoveContainer;[Ljava/lang/String;JILjava/lang/Object;)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public abstract fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public final class com/trendyol/stove/containers/StoveContainer$DefaultImpls { public static fun execCommand (Lcom/trendyol/stove/containers/StoveContainer;[Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public static synthetic fun execCommand$default (Lcom/trendyol/stove/containers/StoveContainer;[Ljava/lang/String;JILjava/lang/Object;)Lcom/trendyol/stove/containers/ExecResult; public static fun getContainerIdAccess (Lcom/trendyol/stove/containers/StoveContainer;)Ljava/lang/String; public static fun getDockerClientAccess (Lcom/trendyol/stove/containers/StoveContainer;)Lkotlin/Lazy; public static fun inspect (Lcom/trendyol/stove/containers/StoveContainer;)Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public static fun pause (Lcom/trendyol/stove/containers/StoveContainer;)V public static fun unpause (Lcom/trendyol/stove/containers/StoveContainer;)V } public final class com/trendyol/stove/containers/StoveContainerInspectInformation { public fun (Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Ljava/lang/String;JLjava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()J public final fun component11 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Map; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Z public final fun component6 ()Z public final fun component7 ()Z public final fun component8 ()Ljava/lang/String; public final fun component9 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Ljava/lang/String;JLjava/lang/String;)Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public static synthetic fun copy$default (Lcom/trendyol/stove/containers/StoveContainerInspectInformation;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Ljava/lang/String;JLjava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ljava/lang/String; public final fun getExitCode ()J public final fun getFinishedAt ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getLabels ()Ljava/util/Map; public final fun getName ()Ljava/lang/String; public final fun getPaused ()Z public final fun getRestarting ()Z public final fun getRunning ()Z public final fun getStartedAt ()Ljava/lang/String; public final fun getState ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface class com/trendyol/stove/database/migrations/DatabaseMigration { public abstract fun execute (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getOrder ()I } public final class com/trendyol/stove/database/migrations/MigrationCollection { public fun ()V public final fun register (Lkotlin/reflect/KClass;)Lcom/trendyol/stove/database/migrations/MigrationCollection; public final fun register (Lkotlin/reflect/KClass;Lcom/trendyol/stove/database/migrations/DatabaseMigration;)Lcom/trendyol/stove/database/migrations/MigrationCollection; public final fun replace (Lkotlin/reflect/KClass;Lcom/trendyol/stove/database/migrations/DatabaseMigration;)Lcom/trendyol/stove/database/migrations/MigrationCollection; public final fun run (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/database/migrations/MigrationPriority : java/lang/Enum { public static final field HIGHEST Lcom/trendyol/stove/database/migrations/MigrationPriority; public static final field LOWEST Lcom/trendyol/stove/database/migrations/MigrationPriority; public static fun getEntries ()Lkotlin/enums/EnumEntries; public final fun getValue ()I public static fun valueOf (Ljava/lang/String;)Lcom/trendyol/stove/database/migrations/MigrationPriority; public static fun values ()[Lcom/trendyol/stove/database/migrations/MigrationPriority; } public abstract interface class com/trendyol/stove/database/migrations/SupportsMigrations { public abstract fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; } public final class com/trendyol/stove/database/migrations/SupportsMigrations$DefaultImpls { public static fun migrations (Lcom/trendyol/stove/database/migrations/SupportsMigrations;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; } public final class com/trendyol/stove/functional/ExtensionsKt { public static final fun evert (Larrow/core/Option;)Lcom/trendyol/stove/functional/Try; public static final fun evert (Lcom/trendyol/stove/functional/Try;)Larrow/core/Option; public static final fun flatten (Larrow/core/Option;)Larrow/core/Option; public static final fun flatten (Larrow/core/Option;)Ljava/util/List; public static final fun flatten (Lcom/trendyol/stove/functional/Try;)Larrow/core/Option; public static final fun flatten (Ljava/lang/Iterable;)Ljava/util/List; public static final fun get (Larrow/core/Option;)Ljava/lang/Object; } public final class com/trendyol/stove/functional/Failure : com/trendyol/stove/functional/Try { public fun (Ljava/lang/Throwable;)V public final fun component1 ()Ljava/lang/Throwable; public final fun copy (Ljava/lang/Throwable;)Lcom/trendyol/stove/functional/Failure; public static synthetic fun copy$default (Lcom/trendyol/stove/functional/Failure;Ljava/lang/Throwable;ILjava/lang/Object;)Lcom/trendyol/stove/functional/Failure; public fun equals (Ljava/lang/Object;)Z public synthetic fun get ()Ljava/lang/Object; public fun get ()Ljava/lang/Void; public final fun getException ()Ljava/lang/Throwable; public fun getFailed ()Lcom/trendyol/stove/functional/Try; public synthetic fun getOrNull ()Ljava/lang/Object; public fun getOrNull ()Ljava/lang/Void; public fun hashCode ()I public fun isFailure ()Z public fun isSuccess ()Z public fun toEither ()Larrow/core/Either; public fun toOption ()Larrow/core/Option; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/functional/Reflect { public static final field Companion Lcom/trendyol/stove/functional/Reflect$Companion; public fun (Ljava/lang/Object;)V public final fun getInstance ()Ljava/lang/Object; } public final class com/trendyol/stove/functional/Reflect$Companion { } public final class com/trendyol/stove/functional/Reflect$OnGoingReflect { public fun (Lcom/trendyol/stove/functional/Reflect;Ljava/lang/Object;Ljava/lang/String;)V public final fun then (Ljava/lang/Object;)V } public final class com/trendyol/stove/functional/Success : com/trendyol/stove/functional/Try { public fun (Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/Object; public final fun copy (Ljava/lang/Object;)Lcom/trendyol/stove/functional/Success; public static synthetic fun copy$default (Lcom/trendyol/stove/functional/Success;Ljava/lang/Object;ILjava/lang/Object;)Lcom/trendyol/stove/functional/Success; public fun equals (Ljava/lang/Object;)Z public fun get ()Ljava/lang/Object; public fun getFailed ()Lcom/trendyol/stove/functional/Try; public fun getOrNull ()Ljava/lang/Object; public final fun getValue ()Ljava/lang/Object; public fun hashCode ()I public fun isFailure ()Z public fun isSuccess ()Z public fun toEither ()Larrow/core/Either; public fun toOption ()Larrow/core/Option; public fun toString ()Ljava/lang/String; } public abstract class com/trendyol/stove/functional/Try { public static final field Companion Lcom/trendyol/stove/functional/Try$Companion; public final fun filter (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public final fun filterNot (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public final fun filterOrElse (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public final fun flatMap (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public final fun fold (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public final fun forEach (Lkotlin/jvm/functions/Function1;)V public abstract fun get ()Ljava/lang/Object; public abstract fun getFailed ()Lcom/trendyol/stove/functional/Try; public abstract fun getOrNull ()Ljava/lang/Object; public abstract fun isFailure ()Z public abstract fun isSuccess ()Z public final fun map (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public abstract fun toEither ()Larrow/core/Either; public abstract fun toOption ()Larrow/core/Option; public final fun transform (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public final fun zip (Lcom/trendyol/stove/functional/Try;)Lcom/trendyol/stove/functional/Try; public final fun zip (Lcom/trendyol/stove/functional/Try;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/functional/Try; } public final class com/trendyol/stove/functional/Try$Companion { public final fun invoke (Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/functional/Try; } public final class com/trendyol/stove/functional/TryKt { public static final fun filterNotNull (Lcom/trendyol/stove/functional/Try;)Lcom/trendyol/stove/functional/Try; public static final fun flatten (Lcom/trendyol/stove/functional/Try;)Lcom/trendyol/stove/functional/Try; public static final fun getOrElse (Lcom/trendyol/stove/functional/Try;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public static final fun orElse (Lcom/trendyol/stove/functional/Try;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/functional/Try; public static final fun recover (Lcom/trendyol/stove/functional/Try;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; public static final fun recoverWith (Lcom/trendyol/stove/functional/Try;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/functional/Try; } public abstract class com/trendyol/stove/http/StoveHttpResponse { public synthetic fun (ILjava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getHeaders ()Ljava/util/Map; public fun getStatus ()I } public final class com/trendyol/stove/http/StoveHttpResponse$Bodiless : com/trendyol/stove/http/StoveHttpResponse { public fun (ILjava/util/Map;)V public final fun component1 ()I public final fun component2 ()Ljava/util/Map; public final fun copy (ILjava/util/Map;)Lcom/trendyol/stove/http/StoveHttpResponse$Bodiless; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveHttpResponse$Bodiless;ILjava/util/Map;ILjava/lang/Object;)Lcom/trendyol/stove/http/StoveHttpResponse$Bodiless; public fun equals (Ljava/lang/Object;)Z public fun getHeaders ()Ljava/util/Map; public fun getStatus ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/http/StoveHttpResponse$WithBody : com/trendyol/stove/http/StoveHttpResponse { public fun (ILjava/util/Map;Lkotlin/jvm/functions/Function1;)V public final fun component1 ()I public final fun component2 ()Ljava/util/Map; public final fun component3 ()Lkotlin/jvm/functions/Function1; public final fun copy (ILjava/util/Map;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/http/StoveHttpResponse$WithBody; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveHttpResponse$WithBody;ILjava/util/Map;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/http/StoveHttpResponse$WithBody; public fun equals (Ljava/lang/Object;)Z public final fun getBody ()Lkotlin/jvm/functions/Function1; public fun getHeaders ()Ljava/util/Map; public fun getStatus ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/messaging/FailedObservedMessage : com/trendyol/stove/messaging/ObservedMessage { public fun (Ljava/lang/Object;Lcom/trendyol/stove/messaging/MessageMetadata;Ljava/lang/Throwable;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Lcom/trendyol/stove/messaging/MessageMetadata; public final fun component3 ()Ljava/lang/Throwable; public final fun copy (Ljava/lang/Object;Lcom/trendyol/stove/messaging/MessageMetadata;Ljava/lang/Throwable;)Lcom/trendyol/stove/messaging/FailedObservedMessage; public static synthetic fun copy$default (Lcom/trendyol/stove/messaging/FailedObservedMessage;Ljava/lang/Object;Lcom/trendyol/stove/messaging/MessageMetadata;Ljava/lang/Throwable;ILjava/lang/Object;)Lcom/trendyol/stove/messaging/FailedObservedMessage; public fun equals (Ljava/lang/Object;)Z public fun getActual ()Ljava/lang/Object; public fun getMetadata ()Lcom/trendyol/stove/messaging/MessageMetadata; public final fun getReason ()Ljava/lang/Throwable; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/messaging/FailedParsedMessage : com/trendyol/stove/messaging/ParsedMessage { public fun (Larrow/core/Option;Lcom/trendyol/stove/messaging/MessageMetadata;Ljava/lang/Throwable;)V public fun getMessage ()Larrow/core/Option; public fun getMetadata ()Lcom/trendyol/stove/messaging/MessageMetadata; public final fun getReason ()Ljava/lang/Throwable; } public final class com/trendyol/stove/messaging/Failure { public fun (Lcom/trendyol/stove/messaging/ObservedMessage;Ljava/lang/Throwable;)V public final fun component1 ()Lcom/trendyol/stove/messaging/ObservedMessage; public final fun component2 ()Ljava/lang/Throwable; public final fun copy (Lcom/trendyol/stove/messaging/ObservedMessage;Ljava/lang/Throwable;)Lcom/trendyol/stove/messaging/Failure; public static synthetic fun copy$default (Lcom/trendyol/stove/messaging/Failure;Lcom/trendyol/stove/messaging/ObservedMessage;Ljava/lang/Throwable;ILjava/lang/Object;)Lcom/trendyol/stove/messaging/Failure; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Lcom/trendyol/stove/messaging/ObservedMessage; public final fun getReason ()Ljava/lang/Throwable; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/messaging/MessageMetadata { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/util/Map; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lcom/trendyol/stove/messaging/MessageMetadata; public static synthetic fun copy$default (Lcom/trendyol/stove/messaging/MessageMetadata;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/trendyol/stove/messaging/MessageMetadata; public fun equals (Ljava/lang/Object;)Z public final fun getHeaders ()Ljava/util/Map; public final fun getKey ()Ljava/lang/String; public final fun getTopic ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public class com/trendyol/stove/messaging/ObservedMessage { public fun (Ljava/lang/Object;Lcom/trendyol/stove/messaging/MessageMetadata;)V public fun getActual ()Ljava/lang/Object; public fun getMetadata ()Lcom/trendyol/stove/messaging/MessageMetadata; } public abstract interface class com/trendyol/stove/messaging/ParsedMessage { public abstract fun getMessage ()Larrow/core/Option; public abstract fun getMetadata ()Lcom/trendyol/stove/messaging/MessageMetadata; } public final class com/trendyol/stove/messaging/SuccessfulParsedMessage : com/trendyol/stove/messaging/ParsedMessage { public fun (Larrow/core/Option;Lcom/trendyol/stove/messaging/MessageMetadata;)V public fun getMessage ()Larrow/core/Option; public fun getMetadata ()Lcom/trendyol/stove/messaging/MessageMetadata; } public final class com/trendyol/stove/reporting/AssertionResult : java/lang/Enum { public static final field Companion Lcom/trendyol/stove/reporting/AssertionResult$Companion; public static final field FAILED Lcom/trendyol/stove/reporting/AssertionResult; public static final field PASSED Lcom/trendyol/stove/reporting/AssertionResult; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lcom/trendyol/stove/reporting/AssertionResult; public static fun values ()[Lcom/trendyol/stove/reporting/AssertionResult; } public final class com/trendyol/stove/reporting/AssertionResult$Companion { public final fun of (Z)Lcom/trendyol/stove/reporting/AssertionResult; } public final class com/trendyol/stove/reporting/JsonReportEntry { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/Map;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Ljava/lang/String; public final fun component11 ()Ljava/lang/Object; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/lang/Object; public final fun component6 ()Ljava/lang/Object; public final fun component7 ()Ljava/util/Map; public final fun component8 ()Ljava/lang/Object; public final fun component9 ()Ljava/lang/Object; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/Map;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;)Lcom/trendyol/stove/reporting/JsonReportEntry; public static synthetic fun copy$default (Lcom/trendyol/stove/reporting/JsonReportEntry;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/Map;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/JsonReportEntry; public fun equals (Ljava/lang/Object;)Z public final fun getAction ()Ljava/lang/String; public final fun getActual ()Ljava/lang/Object; public final fun getError ()Ljava/lang/Object; public final fun getExpected ()Ljava/lang/Object; public final fun getInput ()Ljava/lang/Object; public final fun getMetadata ()Ljava/util/Map; public final fun getOutput ()Ljava/lang/Object; public final fun getResult ()Ljava/lang/String; public final fun getSystem ()Ljava/lang/String; public final fun getTestId ()Ljava/lang/String; public final fun getTimestamp ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/reporting/JsonReportRenderer : com/trendyol/stove/reporting/ReportRenderer { public static final field INSTANCE Lcom/trendyol/stove/reporting/JsonReportRenderer; public fun render (Lcom/trendyol/stove/reporting/TestReport;Ljava/util/List;)Ljava/lang/String; } public final class com/trendyol/stove/reporting/JsonSummary { public fun (III)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()I public final fun copy (III)Lcom/trendyol/stove/reporting/JsonSummary; public static synthetic fun copy$default (Lcom/trendyol/stove/reporting/JsonSummary;IIIILjava/lang/Object;)Lcom/trendyol/stove/reporting/JsonSummary; public fun equals (Ljava/lang/Object;)Z public final fun getFailed ()I public final fun getPassed ()I public final fun getTotal ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/reporting/JsonTestReport { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/Map;Lcom/trendyol/stove/reporting/JsonSummary;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/util/List; public final fun component5 ()Ljava/util/Map; public final fun component6 ()Lcom/trendyol/stove/reporting/JsonSummary; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/Map;Lcom/trendyol/stove/reporting/JsonSummary;)Lcom/trendyol/stove/reporting/JsonTestReport; public static synthetic fun copy$default (Lcom/trendyol/stove/reporting/JsonTestReport;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/Map;Lcom/trendyol/stove/reporting/JsonSummary;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/JsonTestReport; public fun equals (Ljava/lang/Object;)Z public final fun getEntries ()Ljava/util/List; public final fun getSummary ()Lcom/trendyol/stove/reporting/JsonSummary; public final fun getSystemSnapshots ()Ljava/util/Map; public final fun getTestId ()Ljava/lang/String; public final fun getTestName ()Ljava/lang/String; public final fun getTimestamp ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/reporting/PrettyConsoleRenderer : com/trendyol/stove/reporting/ReportRenderer { public static final field INSTANCE Lcom/trendyol/stove/reporting/PrettyConsoleRenderer; public fun render (Lcom/trendyol/stove/reporting/TestReport;Ljava/util/List;)Ljava/lang/String; } public final class com/trendyol/stove/reporting/ReportEntry { public static final field Companion Lcom/trendyol/stove/reporting/ReportEntry$Companion; public fun (Ljava/time/Instant;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/reporting/AssertionResult;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;)V public synthetic fun (Ljava/time/Instant;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/reporting/AssertionResult;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/time/Instant; public final fun component10 ()Larrow/core/Option; public final fun component11 ()Larrow/core/Option; public final fun component12 ()Larrow/core/Option; public final fun component13 ()Larrow/core/Option; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lcom/trendyol/stove/reporting/AssertionResult; public final fun component6 ()Larrow/core/Option; public final fun component7 ()Larrow/core/Option; public final fun component8 ()Ljava/util/Map; public final fun component9 ()Larrow/core/Option; public final fun copy (Ljava/time/Instant;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/reporting/AssertionResult;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;)Lcom/trendyol/stove/reporting/ReportEntry; public static synthetic fun copy$default (Lcom/trendyol/stove/reporting/ReportEntry;Ljava/time/Instant;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/reporting/AssertionResult;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/ReportEntry; public fun equals (Ljava/lang/Object;)Z public final fun getAction ()Ljava/lang/String; public final fun getActual ()Larrow/core/Option; public final fun getError ()Larrow/core/Option; public final fun getExecutionTrace ()Larrow/core/Option; public final fun getExpected ()Larrow/core/Option; public final fun getHasTrace ()Z public final fun getInput ()Larrow/core/Option; public final fun getMetadata ()Ljava/util/Map; public final fun getOutput ()Larrow/core/Option; public final fun getResult ()Lcom/trendyol/stove/reporting/AssertionResult; public final fun getSummary ()Ljava/lang/String; public final fun getSystem ()Ljava/lang/String; public final fun getTestId ()Ljava/lang/String; public final fun getTimestamp ()Ljava/time/Instant; public final fun getTraceId ()Larrow/core/Option; public fun hashCode ()I public final fun isFailed ()Z public final fun isPassed ()Z public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/reporting/ReportEntry$Companion { public final fun action (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;)Lcom/trendyol/stove/reporting/ReportEntry; public static synthetic fun action$default (Lcom/trendyol/stove/reporting/ReportEntry$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/ReportEntry; public final fun failure (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;)Lcom/trendyol/stove/reporting/ReportEntry; public static synthetic fun failure$default (Lcom/trendyol/stove/reporting/ReportEntry$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Larrow/core/Option;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/ReportEntry; public final fun success (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;)Lcom/trendyol/stove/reporting/ReportEntry; public static synthetic fun success$default (Lcom/trendyol/stove/reporting/ReportEntry$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/ReportEntry; } public abstract interface class com/trendyol/stove/reporting/ReportEventListener { public fun onEntryRecorded (Lcom/trendyol/stove/reporting/ReportEntry;)V public fun onTestEnded (Ljava/lang/String;)V public fun onTestFailed (Ljava/lang/String;Ljava/lang/String;)V public fun onTestStarted (Lcom/trendyol/stove/reporting/StoveTestContext;)V } public final class com/trendyol/stove/reporting/ReportEventListener$DefaultImpls { public static fun onEntryRecorded (Lcom/trendyol/stove/reporting/ReportEventListener;Lcom/trendyol/stove/reporting/ReportEntry;)V public static fun onTestEnded (Lcom/trendyol/stove/reporting/ReportEventListener;Ljava/lang/String;)V public static fun onTestFailed (Lcom/trendyol/stove/reporting/ReportEventListener;Ljava/lang/String;Ljava/lang/String;)V public static fun onTestStarted (Lcom/trendyol/stove/reporting/ReportEventListener;Lcom/trendyol/stove/reporting/StoveTestContext;)V } public abstract interface class com/trendyol/stove/reporting/ReportRenderer { public abstract fun render (Lcom/trendyol/stove/reporting/TestReport;Ljava/util/List;)Ljava/lang/String; } public abstract interface class com/trendyol/stove/reporting/Reports { public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun report$default (Lcom/trendyol/stove/reporting/Reports;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; } public final class com/trendyol/stove/reporting/Reports$DefaultImpls { public static fun getReportSystemName (Lcom/trendyol/stove/reporting/Reports;)Ljava/lang/String; public static fun getReporter (Lcom/trendyol/stove/reporting/Reports;)Lcom/trendyol/stove/reporting/StoveReporter; public static fun report (Lcom/trendyol/stove/reporting/Reports;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun report$default (Lcom/trendyol/stove/reporting/Reports;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static fun snapshot (Lcom/trendyol/stove/reporting/Reports;)Lcom/trendyol/stove/reporting/SystemSnapshot; } public abstract interface class com/trendyol/stove/reporting/SpanEventListener { public fun onSpanRecorded (Lcom/trendyol/stove/tracing/SpanInfo;)V } public final class com/trendyol/stove/reporting/SpanEventListener$DefaultImpls { public static fun onSpanRecorded (Lcom/trendyol/stove/reporting/SpanEventListener;Lcom/trendyol/stove/tracing/SpanInfo;)V } public abstract interface class com/trendyol/stove/reporting/SpanListenerRegistry { public abstract fun addSpanListener (Lcom/trendyol/stove/reporting/SpanEventListener;)V } public final class com/trendyol/stove/reporting/StoveReporter { public static final field Companion Lcom/trendyol/stove/reporting/StoveReporter$Companion; public fun ()V public fun (Z)V public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addListener (Lcom/trendyol/stove/reporting/ReportEventListener;)V public final fun clear ()V public final fun clear (Ljava/lang/String;)V public final fun collectSnapshots ()Ljava/util/List; public final fun currentTest ()Lcom/trendyol/stove/reporting/TestReport; public final fun currentTestId ()Ljava/lang/String; public final fun currentTestOrNull ()Lcom/trendyol/stove/reporting/TestReport; public final fun dump (Lcom/trendyol/stove/reporting/ReportRenderer;)Ljava/lang/String; public final fun dumpIfFailed (Lcom/trendyol/stove/reporting/ReportRenderer;)Ljava/lang/String; public static synthetic fun dumpIfFailed$default (Lcom/trendyol/stove/reporting/StoveReporter;Lcom/trendyol/stove/reporting/ReportRenderer;ILjava/lang/Object;)Ljava/lang/String; public final fun endTest ()V public final fun hasFailures ()Z public final fun isEnabled ()Z public final fun printIfFailed (Lcom/trendyol/stove/reporting/ReportRenderer;)V public static synthetic fun printIfFailed$default (Lcom/trendyol/stove/reporting/StoveReporter;Lcom/trendyol/stove/reporting/ReportRenderer;ILjava/lang/Object;)V public final fun record (Lcom/trendyol/stove/reporting/ReportEntry;)V public final fun removeListener (Lcom/trendyol/stove/reporting/ReportEventListener;)V public final fun reportFailure (Ljava/lang/String;)V public final fun startTest (Lcom/trendyol/stove/reporting/StoveTestContext;)V } public final class com/trendyol/stove/reporting/StoveReporter$Companion { } public final class com/trendyol/stove/reporting/StoveTestContext : kotlin/coroutines/AbstractCoroutineContextElement { public static final field Key Lcom/trendyol/stove/reporting/StoveTestContext$Key; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/util/List; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lcom/trendyol/stove/reporting/StoveTestContext; public static synthetic fun copy$default (Lcom/trendyol/stove/reporting/StoveTestContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/StoveTestContext; public fun equals (Ljava/lang/Object;)Z public final fun getSpecName ()Ljava/lang/String; public final fun getTestId ()Ljava/lang/String; public final fun getTestName ()Ljava/lang/String; public final fun getTestPath ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/reporting/StoveTestContext$Key : kotlin/coroutines/CoroutineContext$Key { } public final class com/trendyol/stove/reporting/StoveTestContextHolder { public static final field INSTANCE Lcom/trendyol/stove/reporting/StoveTestContextHolder; public final fun clear ()V public final fun get ()Lcom/trendyol/stove/reporting/StoveTestContext; public final fun set (Lcom/trendyol/stove/reporting/StoveTestContext;)V } public final class com/trendyol/stove/reporting/StoveTestContextKt { public static final fun currentStoveTestContext (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/reporting/StoveTestErrorException : java/lang/Exception { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/trendyol/stove/reporting/StoveTestFailureException : java/lang/AssertionError { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/trendyol/stove/reporting/SystemSnapshot { public fun (Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Map; public final fun component3 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)Lcom/trendyol/stove/reporting/SystemSnapshot; public static synthetic fun copy$default (Lcom/trendyol/stove/reporting/SystemSnapshot;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/reporting/SystemSnapshot; public fun equals (Ljava/lang/Object;)Z public final fun getState ()Ljava/util/Map; public final fun getSummary ()Ljava/lang/String; public final fun getSystem ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/reporting/TestReport { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun clear ()V public final fun entries ()Ljava/util/List; public final fun entriesForThisTest ()Ljava/util/List; public final fun failures ()Ljava/util/List; public final fun failuresForThisTest ()Ljava/util/List; public final fun getTestId ()Ljava/lang/String; public final fun getTestName ()Ljava/lang/String; public final fun hasFailures ()Z public final fun record (Lcom/trendyol/stove/reporting/ReportEntry;)V } public final class com/trendyol/stove/reporting/TestReportKt { public static final fun failures (Ljava/util/List;)Ljava/util/List; public static final fun forSystem (Ljava/util/List;Ljava/lang/String;)Ljava/util/List; public static final fun forTest (Ljava/util/List;Ljava/lang/String;)Ljava/util/List; public static final fun passed (Ljava/util/List;)Ljava/util/List; } public abstract interface class com/trendyol/stove/reporting/TraceProvider { public abstract fun getTraceVisualizationForCurrentTest (J)Larrow/core/Option; public static synthetic fun getTraceVisualizationForCurrentTest$default (Lcom/trendyol/stove/reporting/TraceProvider;JILjava/lang/Object;)Larrow/core/Option; } public final class com/trendyol/stove/reporting/TraceProvider$DefaultImpls { public static synthetic fun getTraceVisualizationForCurrentTest$default (Lcom/trendyol/stove/reporting/TraceProvider;JILjava/lang/Object;)Larrow/core/Option; } public final class com/trendyol/stove/serialization/E2eObjectMapperConfig { public static final field INSTANCE Lcom/trendyol/stove/serialization/E2eObjectMapperConfig; public final fun createObjectMapperWithDefaults ()Lcom/fasterxml/jackson/databind/ObjectMapper; } public final class com/trendyol/stove/serialization/IsoInstantDeserializer : com/fasterxml/jackson/databind/JsonDeserializer { public fun ()V public synthetic fun deserialize (Lcom/fasterxml/jackson/core/JsonParser;Lcom/fasterxml/jackson/databind/DeserializationContext;)Ljava/lang/Object; public fun deserialize (Lcom/fasterxml/jackson/core/JsonParser;Lcom/fasterxml/jackson/databind/DeserializationContext;)Ljava/time/Instant; } public final class com/trendyol/stove/serialization/IsoInstantSerializer : com/fasterxml/jackson/databind/JsonSerializer { public fun ()V public synthetic fun serialize (Ljava/lang/Object;Lcom/fasterxml/jackson/core/JsonGenerator;Lcom/fasterxml/jackson/databind/SerializerProvider;)V public fun serialize (Ljava/time/Instant;Lcom/fasterxml/jackson/core/JsonGenerator;Lcom/fasterxml/jackson/databind/SerializerProvider;)V } public final class com/trendyol/stove/serialization/StoveGson { public static final field INSTANCE Lcom/trendyol/stove/serialization/StoveGson; public final fun anyByteArraySerde (Lcom/google/gson/Gson;)Lcom/trendyol/stove/serialization/StoveSerde; public static synthetic fun anyByteArraySerde$default (Lcom/trendyol/stove/serialization/StoveGson;Lcom/google/gson/Gson;ILjava/lang/Object;)Lcom/trendyol/stove/serialization/StoveSerde; public final fun anyJsonStringSerde (Lcom/google/gson/Gson;)Lcom/trendyol/stove/serialization/StoveSerde; public static synthetic fun anyJsonStringSerde$default (Lcom/trendyol/stove/serialization/StoveGson;Lcom/google/gson/Gson;ILjava/lang/Object;)Lcom/trendyol/stove/serialization/StoveSerde; public final fun byConfiguring (Lkotlin/jvm/functions/Function1;)Lcom/google/gson/Gson; public final fun getDefault ()Lcom/google/gson/Gson; } public final class com/trendyol/stove/serialization/StoveGsonByteArraySerializer : com/trendyol/stove/serialization/StoveSerde { public fun (Lcom/google/gson/Gson;)V public synthetic fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserialize ([BLjava/lang/Class;)Ljava/lang/Object; public synthetic fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public fun deserializeEither ([BLjava/lang/Class;)Larrow/core/Either; public synthetic fun serialize (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Ljava/lang/Object;)[B } public final class com/trendyol/stove/serialization/StoveGsonStringSerializer : com/trendyol/stove/serialization/StoveSerde { public fun (Lcom/google/gson/Gson;)V public synthetic fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserialize (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public synthetic fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public fun deserializeEither (Ljava/lang/String;Ljava/lang/Class;)Larrow/core/Either; public synthetic fun serialize (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Ljava/lang/Object;)Ljava/lang/String; } public final class com/trendyol/stove/serialization/StoveJackson { public static final field INSTANCE Lcom/trendyol/stove/serialization/StoveJackson; public final fun anyByteArraySerde (Lcom/fasterxml/jackson/databind/ObjectMapper;)Lcom/trendyol/stove/serialization/StoveSerde; public static synthetic fun anyByteArraySerde$default (Lcom/trendyol/stove/serialization/StoveJackson;Lcom/fasterxml/jackson/databind/ObjectMapper;ILjava/lang/Object;)Lcom/trendyol/stove/serialization/StoveSerde; public final fun anyJsonStringSerde (Lcom/fasterxml/jackson/databind/ObjectMapper;)Lcom/trendyol/stove/serialization/StoveSerde; public static synthetic fun anyJsonStringSerde$default (Lcom/trendyol/stove/serialization/StoveJackson;Lcom/fasterxml/jackson/databind/ObjectMapper;ILjava/lang/Object;)Lcom/trendyol/stove/serialization/StoveSerde; public final fun byConfiguring (Lkotlin/jvm/functions/Function1;)Lcom/fasterxml/jackson/databind/ObjectMapper; public final fun getDefault ()Lcom/fasterxml/jackson/databind/ObjectMapper; } public final class com/trendyol/stove/serialization/StoveJacksonByteArraySerializer : com/trendyol/stove/serialization/StoveSerde { public fun (Lcom/fasterxml/jackson/databind/ObjectMapper;)V public synthetic fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserialize ([BLjava/lang/Class;)Ljava/lang/Object; public synthetic fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public fun deserializeEither ([BLjava/lang/Class;)Larrow/core/Either; public synthetic fun serialize (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Ljava/lang/Object;)[B } public final class com/trendyol/stove/serialization/StoveJacksonStringSerializer : com/trendyol/stove/serialization/StoveSerde { public fun (Lcom/fasterxml/jackson/databind/ObjectMapper;)V public synthetic fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserialize (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public synthetic fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public fun deserializeEither (Ljava/lang/String;Ljava/lang/Class;)Larrow/core/Either; public synthetic fun serialize (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Ljava/lang/Object;)Ljava/lang/String; } public final class com/trendyol/stove/serialization/StoveKotlinx { public static final field INSTANCE Lcom/trendyol/stove/serialization/StoveKotlinx; public final fun anyByteArraySerde (Lkotlinx/serialization/json/Json;)Lcom/trendyol/stove/serialization/StoveSerde; public static synthetic fun anyByteArraySerde$default (Lcom/trendyol/stove/serialization/StoveKotlinx;Lkotlinx/serialization/json/Json;ILjava/lang/Object;)Lcom/trendyol/stove/serialization/StoveSerde; public final fun anyJsonStringSerde (Lkotlinx/serialization/json/Json;)Lcom/trendyol/stove/serialization/StoveSerde; public static synthetic fun anyJsonStringSerde$default (Lcom/trendyol/stove/serialization/StoveKotlinx;Lkotlinx/serialization/json/Json;ILjava/lang/Object;)Lcom/trendyol/stove/serialization/StoveSerde; public final fun byConfiguring (Lkotlin/jvm/functions/Function1;)Lkotlinx/serialization/json/Json; public final fun getDefault ()Lkotlinx/serialization/json/Json; } public final class com/trendyol/stove/serialization/StoveKotlinxByteArraySerializer : com/trendyol/stove/serialization/StoveSerde { public fun (Lkotlinx/serialization/json/Json;)V public synthetic fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserialize ([BLjava/lang/Class;)Ljava/lang/Object; public synthetic fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public fun deserializeEither ([BLjava/lang/Class;)Larrow/core/Either; public synthetic fun serialize (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Ljava/lang/Object;)[B } public final class com/trendyol/stove/serialization/StoveKotlinxStringSerializer : com/trendyol/stove/serialization/StoveSerde { public fun (Lkotlinx/serialization/json/Json;)V public synthetic fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserialize (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public synthetic fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public fun deserializeEither (Ljava/lang/String;Ljava/lang/Class;)Larrow/core/Either; public synthetic fun serialize (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Ljava/lang/Object;)Ljava/lang/String; } public abstract interface class com/trendyol/stove/serialization/StoveSerde { public static final field Companion Lcom/trendyol/stove/serialization/StoveSerde$Companion; public abstract fun deserialize (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; public fun deserializeEither (Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; public abstract fun serialize (Ljava/lang/Object;)Ljava/lang/Object; } public final class com/trendyol/stove/serialization/StoveSerde$Companion { public final fun getGson ()Lcom/trendyol/stove/serialization/StoveGson; public final fun getJackson ()Lcom/trendyol/stove/serialization/StoveJackson; public final fun getKotlinx ()Lcom/trendyol/stove/serialization/StoveKotlinx; } public final class com/trendyol/stove/serialization/StoveSerde$DefaultImpls { public static fun deserializeEither (Lcom/trendyol/stove/serialization/StoveSerde;Ljava/lang/Object;Ljava/lang/Class;)Larrow/core/Either; } public abstract class com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem : java/lang/RuntimeException { public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem$BecauseOfDeserialization : com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem { public fun (Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem$BecauseOfDeserializationButExpected : com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem { public fun (Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem$BecauseOfSerialization : com/trendyol/stove/serialization/StoveSerde$StoveSerdeProblem { public fun (Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public abstract class com/trendyol/stove/system/BridgeSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/system/abstractions/PluggedSystem { protected field ctx Ljava/lang/Object; public fun (Lcom/trendyol/stove/system/Stove;)V public fun afterRun (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun close ()V public final fun ensureContextInitialized ()V public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; public fun getByType (Lkotlin/reflect/KType;)Ljava/lang/Object; protected final fun getCtx ()Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; protected final fun setCtx (Ljava/lang/Object;)V public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun then ()Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/system/BridgeSystemKt { public static final fun bridge (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/BridgeSystem; public static final fun bridge-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/BridgeSystem;)Lcom/trendyol/stove/system/Stove; public static final fun withBridgeSystem (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/BridgeSystem;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/system/PortFinder { public static final field INSTANCE Lcom/trendyol/stove/system/PortFinder; public static final fun findAvailablePort ()I public static final fun findAvailablePortAsString ()Ljava/lang/String; public static final fun findAvailablePortFrom (I)I public static final fun findAvailablePortFromAsString (I)Ljava/lang/String; public static final fun isPortAvailable (I)Z } public final class com/trendyol/stove/system/PropertiesFile { public static final field Companion Lcom/trendyol/stove/system/PropertiesFile$Companion; public static final field REUSE_ENABLED Ljava/lang/String; public fun ()V public final fun detectAndLogStatus ()V public final fun enable ()V } public final class com/trendyol/stove/system/PropertiesFile$Companion { } public final class com/trendyol/stove/system/ProvidedApplicationOptions { public fun ()V public fun (Lcom/trendyol/stove/system/ReadinessStrategy;)V public synthetic fun (Lcom/trendyol/stove/system/ReadinessStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/ReadinessStrategy; public final fun copy (Lcom/trendyol/stove/system/ReadinessStrategy;)Lcom/trendyol/stove/system/ProvidedApplicationOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/system/ProvidedApplicationOptions;Lcom/trendyol/stove/system/ReadinessStrategy;ILjava/lang/Object;)Lcom/trendyol/stove/system/ProvidedApplicationOptions; public fun equals (Ljava/lang/Object;)Z public final fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/ProvidedApplicationUnderTest : com/trendyol/stove/system/abstractions/ApplicationUnderTest { public fun (Lcom/trendyol/stove/system/ProvidedApplicationOptions;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/system/ProvidedApplicationUnderTestKt { public static final fun providedApplication-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun providedApplication-ypJx7X8$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } public final class com/trendyol/stove/system/ReadinessChecker { public static final field INSTANCE Lcom/trendyol/stove/system/ReadinessChecker; public final fun check (Lcom/trendyol/stove/system/ReadinessStrategy;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/ReadinessStrategy { } public final class com/trendyol/stove/system/ReadinessStrategy$FixedDelay : com/trendyol/stove/system/ReadinessStrategy { public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-UwyO8pc ()J public final fun copy-LRDsOJo (J)Lcom/trendyol/stove/system/ReadinessStrategy$FixedDelay; public static synthetic fun copy-LRDsOJo$default (Lcom/trendyol/stove/system/ReadinessStrategy$FixedDelay;JILjava/lang/Object;)Lcom/trendyol/stove/system/ReadinessStrategy$FixedDelay; public fun equals (Ljava/lang/Object;)Z public final fun getDelay-UwyO8pc ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/ReadinessStrategy$HttpGet : com/trendyol/stove/system/ReadinessStrategy { public synthetic fun (Ljava/lang/String;JIJLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;JIJLjava/util/Set;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2-UwyO8pc ()J public final fun component3 ()I public final fun component4-UwyO8pc ()J public final fun component5 ()Ljava/util/Set; public final fun copy-7Q0yyfQ (Ljava/lang/String;JIJLjava/util/Set;)Lcom/trendyol/stove/system/ReadinessStrategy$HttpGet; public static synthetic fun copy-7Q0yyfQ$default (Lcom/trendyol/stove/system/ReadinessStrategy$HttpGet;Ljava/lang/String;JIJLjava/util/Set;ILjava/lang/Object;)Lcom/trendyol/stove/system/ReadinessStrategy$HttpGet; public fun equals (Ljava/lang/Object;)Z public final fun getExpectedStatusCodes ()Ljava/util/Set; public final fun getRetries ()I public final fun getRetryDelay-UwyO8pc ()J public final fun getTimeout-UwyO8pc ()J public final fun getUrl ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/ReadinessStrategy$Probe : com/trendyol/stove/system/ReadinessStrategy { public synthetic fun (IJLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (IJLkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2-UwyO8pc ()J public final fun component3 ()Lkotlin/jvm/functions/Function1; public final fun copy-8Mi8wO0 (IJLkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/system/ReadinessStrategy$Probe; public static synthetic fun copy-8Mi8wO0$default (Lcom/trendyol/stove/system/ReadinessStrategy$Probe;IJLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/system/ReadinessStrategy$Probe; public fun equals (Ljava/lang/Object;)Z public final fun getCheck ()Lkotlin/jvm/functions/Function1; public final fun getRetries ()I public final fun getRetryDelay-UwyO8pc ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/ReadinessStrategy$TcpPort : com/trendyol/stove/system/ReadinessStrategy { public synthetic fun (IIJILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (IIJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()I public final fun component3-UwyO8pc ()J public final fun copy-SxA4cEA (IIJ)Lcom/trendyol/stove/system/ReadinessStrategy$TcpPort; public static synthetic fun copy-SxA4cEA$default (Lcom/trendyol/stove/system/ReadinessStrategy$TcpPort;IIJILjava/lang/Object;)Lcom/trendyol/stove/system/ReadinessStrategy$TcpPort; public fun equals (Ljava/lang/Object;)Z public final fun getPort ()I public final fun getRetries ()I public final fun getRetryDelay-UwyO8pc ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/ReportingDsl { public fun (Lcom/trendyol/stove/system/StoveOptionsDsl;)V public final fun disabled ()Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun dumpOnFailure (Z)Lcom/trendyol/stove/system/StoveOptionsDsl; public static synthetic fun dumpOnFailure$default (Lcom/trendyol/stove/system/ReportingDsl;ZILjava/lang/Object;)Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun enabled (Z)Lcom/trendyol/stove/system/StoveOptionsDsl; public static synthetic fun enabled$default (Lcom/trendyol/stove/system/ReportingDsl;ZILjava/lang/Object;)Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun failureRenderer (Lcom/trendyol/stove/reporting/ReportRenderer;)Lcom/trendyol/stove/system/StoveOptionsDsl; } public final class com/trendyol/stove/system/Stove : com/trendyol/stove/system/abstractions/ReadyStove, java/lang/AutoCloseable { public static final field Companion Lcom/trendyol/stove/system/Stove$Companion; public fun ()V public fun (Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addReportListener (Lcom/trendyol/stove/reporting/ReportEventListener;)V public final fun allRegisteredSystems ()Ljava/util/Collection; public final fun allSystems ()Ljava/util/Collection; public final fun applicationUnderTest (Lcom/trendyol/stove/system/abstractions/ApplicationUnderTest;)Lcom/trendyol/stove/system/Stove; public final fun applicationUnderTestContext ()Ljava/lang/Object; public fun close ()V public final fun endTest ()V public final fun getActiveSystems ()Ljava/util/Map; public final fun getKeepDependenciesRunning ()Z public final fun getKeyedSystems ()Ljava/util/Map; public final fun getOptions ()Lcom/trendyol/stove/system/StoveOptions; public final fun getRunMigrationsAlways ()Z public final fun recordReport (Lcom/trendyol/stove/reporting/ReportEntry;)V public final fun registerForDispose (Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable; public final fun removeReportListener (Lcom/trendyol/stove/reporting/ReportEventListener;)V public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun startTest (Lcom/trendyol/stove/reporting/StoveTestContext;)V public final fun with (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/system/Stove$Companion { public final fun getSystem (Lkotlin/reflect/KClass;)Lcom/trendyol/stove/system/abstractions/PluggedSystem; public final fun getSystemOrNone (Lkotlin/reflect/KClass;)Larrow/core/Option; public final fun instanceInitialized ()Z public final fun options ()Lcom/trendyol/stove/system/StoveOptions; public final fun reporter ()Lcom/trendyol/stove/reporting/StoveReporter; public final fun stop ()V } public final class com/trendyol/stove/system/StoveKt { public static final fun stove (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/system/StoveOptions { public fun ()V public fun (ZLcom/trendyol/stove/system/abstractions/StateStorageFactory;ZZZZLcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;ZZLjava/lang/String;)V public synthetic fun (ZLcom/trendyol/stove/system/abstractions/StateStorageFactory;ZZZZLcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component10 ()Z public final fun component11 ()Z public final fun component12 ()Ljava/lang/String; public final fun component2 ()Lcom/trendyol/stove/system/abstractions/StateStorageFactory; public final fun component3 ()Z public final fun component4 ()Z public final fun component5 ()Z public final fun component6 ()Z public final fun component7 ()Lcom/trendyol/stove/reporting/ReportRenderer; public final fun component8 ()Lcom/trendyol/stove/reporting/ReportRenderer; public final fun component9 ()Lcom/trendyol/stove/reporting/ReportRenderer; public final fun copy (ZLcom/trendyol/stove/system/abstractions/StateStorageFactory;ZZZZLcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;ZZLjava/lang/String;)Lcom/trendyol/stove/system/StoveOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/system/StoveOptions;ZLcom/trendyol/stove/system/abstractions/StateStorageFactory;ZZZZLcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;Lcom/trendyol/stove/reporting/ReportRenderer;ZZLjava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/system/StoveOptions; public fun equals (Ljava/lang/Object;)Z public final fun getDefaultRenderer ()Lcom/trendyol/stove/reporting/ReportRenderer; public final fun getDumpReportOnStop ()Z public final fun getDumpReportOnTestFailure ()Z public final fun getFailureRenderer ()Lcom/trendyol/stove/reporting/ReportRenderer; public final fun getFileRenderer ()Lcom/trendyol/stove/reporting/ReportRenderer; public final fun getKeepDependenciesRunning ()Z public final fun getReportFilePath ()Ljava/lang/String; public final fun getReportToConsole ()Z public final fun getReportToFile ()Z public final fun getReportingEnabled ()Z public final fun getRunMigrationsAlways ()Z public final fun getStateStorageFactory ()Lcom/trendyol/stove/system/abstractions/StateStorageFactory; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/StoveOptionsDsl { public static final field Companion Lcom/trendyol/stove/system/StoveOptionsDsl$Companion; public fun ()V public final fun dumpReportOnTestFailure (Z)Lcom/trendyol/stove/system/StoveOptionsDsl; public static synthetic fun dumpReportOnTestFailure$default (Lcom/trendyol/stove/system/StoveOptionsDsl;ZILjava/lang/Object;)Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun enableReuseForTestContainers ()V public final fun failureRenderer (Lcom/trendyol/stove/reporting/ReportRenderer;)Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun isRunningLocally ()Z public final fun keepDependenciesRunning ()Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun reporting (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun reportingEnabled (Z)Lcom/trendyol/stove/system/StoveOptionsDsl; public static synthetic fun reportingEnabled$default (Lcom/trendyol/stove/system/StoveOptionsDsl;ZILjava/lang/Object;)Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun runMigrationsAlways ()Lcom/trendyol/stove/system/StoveOptionsDsl; public final fun stateStorage (Lcom/trendyol/stove/system/abstractions/StateStorageFactory;)Lcom/trendyol/stove/system/StoveOptionsDsl; } public final class com/trendyol/stove/system/StoveOptionsDsl$Companion { } public final class com/trendyol/stove/system/ValidationDsl { public static final synthetic fun box-impl (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/ValidationDsl; public static fun constructor-impl (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/Stove; public fun equals (Ljava/lang/Object;)Z public static fun equals-impl (Lcom/trendyol/stove/system/Stove;Ljava/lang/Object;)Z public static final fun equals-impl0 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/Stove;)Z public final fun getStove ()Lcom/trendyol/stove/system/Stove; public fun hashCode ()I public static fun hashCode-impl (Lcom/trendyol/stove/system/Stove;)I public fun toString ()Ljava/lang/String; public static fun toString-impl (Lcom/trendyol/stove/system/Stove;)Ljava/lang/String; public final synthetic fun unbox-impl ()Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/system/WithDsl { public static final fun applicationUnderTest-impl (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/ApplicationUnderTest;)V public static final synthetic fun box-impl (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/WithDsl; public static fun constructor-impl (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/Stove; public fun equals (Ljava/lang/Object;)Z public static fun equals-impl (Lcom/trendyol/stove/system/Stove;Ljava/lang/Object;)Z public static final fun equals-impl0 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/Stove;)Z public final fun getStove ()Lcom/trendyol/stove/system/Stove; public fun hashCode ()I public static fun hashCode-impl (Lcom/trendyol/stove/system/Stove;)I public fun toString ()Ljava/lang/String; public static fun toString-impl (Lcom/trendyol/stove/system/Stove;)Ljava/lang/String; public final synthetic fun unbox-impl ()Lcom/trendyol/stove/system/Stove; } public abstract interface class com/trendyol/stove/system/abstractions/AfterRunAware { public abstract fun afterRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/abstractions/AfterRunAwareWithContext { public abstract fun afterRun (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/abstractions/ApplicationUnderTest { public abstract fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/abstractions/BeforeRunAware { public abstract fun beforeRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration { public abstract fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; } public abstract interface class com/trendyol/stove/system/abstractions/ExposedConfiguration { } public abstract interface class com/trendyol/stove/system/abstractions/ExposesConfiguration { public abstract fun configuration ()Ljava/util/List; } public abstract interface class com/trendyol/stove/system/abstractions/PluggedSystem : com/trendyol/stove/system/abstractions/ThenSystemContinuation, java/lang/AutoCloseable { } public final class com/trendyol/stove/system/abstractions/PluggedSystem$DefaultImpls { public static fun executeWithReuseCheck (Lcom/trendyol/stove/system/abstractions/PluggedSystem;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun then (Lcom/trendyol/stove/system/abstractions/PluggedSystem;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/system/abstractions/ProvidedRuntime : com/trendyol/stove/system/abstractions/SystemRuntime { public static final field INSTANCE Lcom/trendyol/stove/system/abstractions/ProvidedRuntime; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface class com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public abstract fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public abstract fun getRunMigrationsForProvided ()Z } public abstract interface class com/trendyol/stove/system/abstractions/ReadyStove { public abstract fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/abstractions/RunAware { public abstract fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/system/abstractions/RunnableSystemWithContext : com/trendyol/stove/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/system/abstractions/BeforeRunAware, com/trendyol/stove/system/abstractions/RunAware, java/lang/AutoCloseable { public fun close ()V } public final class com/trendyol/stove/system/abstractions/RunnableSystemWithContext$DefaultImpls { public static fun close (Lcom/trendyol/stove/system/abstractions/RunnableSystemWithContext;)V } public abstract interface class com/trendyol/stove/system/abstractions/StateStorage { public abstract fun capture (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun isSubsequentRun ()Z } public abstract interface class com/trendyol/stove/system/abstractions/StateStorageFactory { public static final field Companion Lcom/trendyol/stove/system/abstractions/StateStorageFactory$Companion; public fun DefaultStateStorage (Lcom/trendyol/stove/system/StoveOptions;Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;)Lcom/trendyol/stove/system/abstractions/StateStorage; public fun createWithKey (Lcom/trendyol/stove/system/StoveOptions;Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;Ljava/lang/String;)Lcom/trendyol/stove/system/abstractions/StateStorage; public abstract fun invoke (Lcom/trendyol/stove/system/StoveOptions;Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;)Lcom/trendyol/stove/system/abstractions/StateStorage; } public final class com/trendyol/stove/system/abstractions/StateStorageFactory$Companion { public final fun Default ()Lcom/trendyol/stove/system/abstractions/StateStorageFactory; } public final class com/trendyol/stove/system/abstractions/StateStorageFactory$DefaultImpls { public static fun DefaultStateStorage (Lcom/trendyol/stove/system/abstractions/StateStorageFactory;Lcom/trendyol/stove/system/StoveOptions;Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;)Lcom/trendyol/stove/system/abstractions/StateStorage; public static fun createWithKey (Lcom/trendyol/stove/system/abstractions/StateStorageFactory;Lcom/trendyol/stove/system/StoveOptions;Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;Ljava/lang/String;)Lcom/trendyol/stove/system/abstractions/StateStorage; } public final class com/trendyol/stove/system/abstractions/StateWithProcess { public fun (Ljava/lang/Object;J)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()J public final fun copy (Ljava/lang/Object;J)Lcom/trendyol/stove/system/abstractions/StateWithProcess; public static synthetic fun copy$default (Lcom/trendyol/stove/system/abstractions/StateWithProcess;Ljava/lang/Object;JILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/StateWithProcess; public fun equals (Ljava/lang/Object;)Z public final fun getProcessId ()J public final fun getState ()Ljava/lang/Object; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/system/abstractions/SystemConfigurationException : java/lang/Throwable { public fun (Lkotlin/reflect/KClass;Ljava/lang/String;)V } public abstract interface class com/trendyol/stove/system/abstractions/SystemKey { } public final class com/trendyol/stove/system/abstractions/SystemKeyKt { public static final fun keyDisplayName (Lcom/trendyol/stove/system/abstractions/SystemKey;)Ljava/lang/String; } public final class com/trendyol/stove/system/abstractions/SystemNotInitializedException : java/lang/Throwable { public fun (Lkotlin/reflect/KClass;)V } public final class com/trendyol/stove/system/abstractions/SystemNotRegisteredException : java/lang/Throwable { public fun (Lkotlin/reflect/KClass;Ljava/lang/String;)V public synthetic fun (Lkotlin/reflect/KClass;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public abstract interface class com/trendyol/stove/system/abstractions/SystemOptions { } public abstract interface class com/trendyol/stove/system/abstractions/SystemRuntime { } public abstract interface class com/trendyol/stove/system/abstractions/ThenSystemContinuation { public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getStove ()Lcom/trendyol/stove/system/Stove; public fun then ()Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/system/abstractions/ThenSystemContinuation$DefaultImpls { public static fun executeWithReuseCheck (Lcom/trendyol/stove/system/abstractions/ThenSystemContinuation;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun then (Lcom/trendyol/stove/system/abstractions/ThenSystemContinuation;)Lcom/trendyol/stove/system/Stove; } public abstract interface class com/trendyol/stove/system/abstractions/ValidatedSystem { public abstract fun validate (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface annotation class com/trendyol/stove/system/annotations/StoveDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/system/application/ApplicationConfigurationsKt { public static final fun toConfigurationMap (Ljava/util/List;)Ljava/util/Map; } public final class com/trendyol/stove/system/application/ArgsMapperBuilder { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun arg (Ljava/lang/String;Ljava/lang/String;)V public final fun arg (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public static synthetic fun arg$default (Lcom/trendyol/stove/system/application/ArgsMapperBuilder;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public final fun build ()Lcom/trendyol/stove/system/application/ArgsProvider; public final fun map (Ljava/lang/String;Ljava/lang/String;)V public final fun to (Ljava/lang/String;Ljava/lang/String;)V } public abstract interface class com/trendyol/stove/system/application/ArgsProvider { public static final field Companion Lcom/trendyol/stove/system/application/ArgsProvider$Companion; public abstract fun provide (Ljava/util/Map;)Ljava/util/List; } public final class com/trendyol/stove/system/application/ArgsProvider$Companion { public final fun empty ()Lcom/trendyol/stove/system/application/ArgsProvider; } public final class com/trendyol/stove/system/application/ArgsProviderKt { public static final fun argsMapper (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/system/application/ArgsProvider; public static synthetic fun argsMapper$default (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/system/application/ArgsProvider; } public final class com/trendyol/stove/system/application/EnvMapperBuilder { public fun ()V public final fun build ()Lcom/trendyol/stove/system/application/EnvProvider; public final fun env (Ljava/lang/String;Ljava/lang/String;)V public final fun env (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public final fun map (Ljava/lang/String;Ljava/lang/String;)V public final fun to (Ljava/lang/String;Ljava/lang/String;)V } public abstract interface class com/trendyol/stove/system/application/EnvProvider { public static final field Companion Lcom/trendyol/stove/system/application/EnvProvider$Companion; public abstract fun provide (Ljava/util/Map;)Ljava/util/Map; } public final class com/trendyol/stove/system/application/EnvProvider$Companion { public final fun empty ()Lcom/trendyol/stove/system/application/EnvProvider; } public final class com/trendyol/stove/system/application/EnvProviderKt { public static final fun envMapper (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/system/application/EnvProvider; } public final class com/trendyol/stove/tracing/ExceptionInfo { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/util/List; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lcom/trendyol/stove/tracing/ExceptionInfo; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/ExceptionInfo;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/ExceptionInfo; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Ljava/lang/String; public final fun getStackTrace ()Ljava/util/List; public final fun getType ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/SpanInfo { public static final field Companion Lcom/trendyol/stove/tracing/SpanInfo$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLcom/trendyol/stove/tracing/SpanStatus;Ljava/util/Map;Lcom/trendyol/stove/tracing/ExceptionInfo;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLcom/trendyol/stove/tracing/SpanStatus;Ljava/util/Map;Lcom/trendyol/stove/tracing/ExceptionInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Lcom/trendyol/stove/tracing/ExceptionInfo; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/lang/String; public final fun component6 ()J public final fun component7 ()J public final fun component8 ()Lcom/trendyol/stove/tracing/SpanStatus; public final fun component9 ()Ljava/util/Map; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLcom/trendyol/stove/tracing/SpanStatus;Ljava/util/Map;Lcom/trendyol/stove/tracing/ExceptionInfo;)Lcom/trendyol/stove/tracing/SpanInfo; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/SpanInfo;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLcom/trendyol/stove/tracing/SpanStatus;Ljava/util/Map;Lcom/trendyol/stove/tracing/ExceptionInfo;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/SpanInfo; public fun equals (Ljava/lang/Object;)Z public final fun getAttributes ()Ljava/util/Map; public final fun getDurationMs ()J public final fun getDurationNanos ()J public final fun getEndTimeNanos ()J public final fun getException ()Lcom/trendyol/stove/tracing/ExceptionInfo; public final fun getOperationName ()Ljava/lang/String; public final fun getParentSpanId ()Ljava/lang/String; public final fun getServiceName ()Ljava/lang/String; public final fun getSpanId ()Ljava/lang/String; public final fun getStartTimeNanos ()J public final fun getStatus ()Lcom/trendyol/stove/tracing/SpanStatus; public final fun getTraceId ()Ljava/lang/String; public fun hashCode ()I public final fun isFailed ()Z public final fun isSuccess ()Z public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/SpanInfo$Companion { } public final class com/trendyol/stove/tracing/SpanNode { public fun (Lcom/trendyol/stove/tracing/SpanInfo;Ljava/util/List;)V public synthetic fun (Lcom/trendyol/stove/tracing/SpanInfo;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/tracing/SpanInfo; public final fun component2 ()Ljava/util/List; public final fun copy (Lcom/trendyol/stove/tracing/SpanInfo;Ljava/util/List;)Lcom/trendyol/stove/tracing/SpanNode; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/SpanNode;Lcom/trendyol/stove/tracing/SpanInfo;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/SpanNode; public fun equals (Ljava/lang/Object;)Z public final fun findFailurePoint ()Lcom/trendyol/stove/tracing/SpanNode; public final fun flatten ()Ljava/util/List; public final fun getChildren ()Ljava/util/List; public final fun getDepth ()I public final fun getHasFailedDescendants ()Z public final fun getSpan ()Lcom/trendyol/stove/tracing/SpanInfo; public final fun getSpanCount ()I public final fun getTotalDurationMs ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/SpanStatus : java/lang/Enum { public static final field ERROR Lcom/trendyol/stove/tracing/SpanStatus; public static final field OK Lcom/trendyol/stove/tracing/SpanStatus; public static final field UNSET Lcom/trendyol/stove/tracing/SpanStatus; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lcom/trendyol/stove/tracing/SpanStatus; public static fun values ()[Lcom/trendyol/stove/tracing/SpanStatus; } public final class com/trendyol/stove/tracing/SpanTree { public static final field INSTANCE Lcom/trendyol/stove/tracing/SpanTree; public final fun build (Ljava/util/List;)Lcom/trendyol/stove/tracing/SpanNode; public final fun filterSpans (Lcom/trendyol/stove/tracing/SpanNode;Lkotlin/jvm/functions/Function1;)Ljava/util/List; public final fun findSpan (Lcom/trendyol/stove/tracing/SpanNode;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/tracing/SpanNode; } public final class com/trendyol/stove/tracing/TraceContext { public static final field BAGGAGE_TEST_ID_KEY Ljava/lang/String; public static final field Companion Lcom/trendyol/stove/tracing/TraceContext$Companion; public static final field STOVE_TEST_ID_HEADER Ljava/lang/String; public static final field TRACEPARENT_HEADER Ljava/lang/String; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceContext; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/TraceContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/TraceContext; public fun equals (Ljava/lang/Object;)Z public final fun getRootSpanId ()Ljava/lang/String; public final fun getTestId ()Ljava/lang/String; public final fun getTraceId ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; public final fun toTraceparent ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/TraceContext$Companion { public final fun clear ()V public final fun current ()Lcom/trendyol/stove/tracing/TraceContext; public final fun generateSpanId ()Ljava/lang/String; public final fun generateTraceId ()Ljava/lang/String; public final fun parseTraceparent (Ljava/lang/String;)Lkotlin/Pair; public final fun sanitizeToAscii (Ljava/lang/String;)Ljava/lang/String; public final fun start (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceContext; public final fun use (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public final fun withCurrentPropagation (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun withPropagation (Lcom/trendyol/stove/tracing/TraceContext;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/tracing/TraceTreeRenderer { public static final field INSTANCE Lcom/trendyol/stove/tracing/TraceTreeRenderer; public final fun render (Lcom/trendyol/stove/tracing/SpanNode;ZLjava/util/List;)Ljava/lang/String; public static synthetic fun render$default (Lcom/trendyol/stove/tracing/TraceTreeRenderer;Lcom/trendyol/stove/tracing/SpanNode;ZLjava/util/List;ILjava/lang/Object;)Ljava/lang/String; public final fun renderColored (Lcom/trendyol/stove/tracing/SpanNode;ZLjava/util/List;)Ljava/lang/String; public static synthetic fun renderColored$default (Lcom/trendyol/stove/tracing/TraceTreeRenderer;Lcom/trendyol/stove/tracing/SpanNode;ZLjava/util/List;ILjava/lang/Object;)Ljava/lang/String; public final fun renderCompact (Lcom/trendyol/stove/tracing/SpanNode;)Ljava/lang/String; public final fun renderSummary (Lcom/trendyol/stove/tracing/SpanNode;)Ljava/lang/String; } public final class com/trendyol/stove/tracing/TraceVisualization { public static final field Companion Lcom/trendyol/stove/tracing/TraceVisualization$Companion; public fun (Ljava/lang/String;Ljava/lang/String;IILjava/util/List;Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()I public final fun component4 ()I public final fun component5 ()Ljava/util/List; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;IILjava/util/List;Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceVisualization; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/TraceVisualization;Ljava/lang/String;Ljava/lang/String;IILjava/util/List;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/TraceVisualization; public fun equals (Ljava/lang/Object;)Z public final fun getColoredTree ()Ljava/lang/String; public final fun getFailedSpans ()I public final fun getSpans ()Ljava/util/List; public final fun getTestId ()Ljava/lang/String; public final fun getTotalSpans ()I public final fun getTraceId ()Ljava/lang/String; public final fun getTree ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/TraceVisualization$Companion { public final fun from (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lcom/trendyol/stove/tracing/TraceVisualization; } public final class com/trendyol/stove/tracing/VisualSpan { public static final field Companion Lcom/trendyol/stove/tracing/VisualSpan$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;Ljava/util/Map;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()D public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/util/Map; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;Ljava/util/Map;)Lcom/trendyol/stove/tracing/VisualSpan; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/VisualSpan;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/VisualSpan; public fun equals (Ljava/lang/Object;)Z public final fun getAttributes ()Ljava/util/Map; public final fun getDurationMs ()D public final fun getOperationName ()Ljava/lang/String; public final fun getParentSpanId ()Ljava/lang/String; public final fun getServiceName ()Ljava/lang/String; public final fun getSpanId ()Ljava/lang/String; public final fun getStatus ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/VisualSpan$Companion { public final fun from (Lcom/trendyol/stove/tracing/SpanInfo;)Lcom/trendyol/stove/tracing/VisualSpan; } ================================================ FILE: lib/stove/build.gradle.kts ================================================ plugins { `java-test-fixtures` alias(libs.plugins.kotlinx.serialization) } dependencies { api(libs.arrow.core) api(libs.kotlinx.core) api(libs.jackson.kotlin) api(libs.jackson.databind) api(libs.google.gson) api(libs.kotlinx.serialization.json) api(libs.testcontainers) { version { require(libs.testcontainers.asProvider().get().version!!) } } implementation(libs.mordant) // OTel API for setting trace context and baggage so the Java Agent // creates child spans with Stove's trace ID and propagates test metadata. // No-op when agent is not present. implementation(libs.opentelemetry.api) } dependencies { testImplementation(libs.kotest.arrow) testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.framework.engine) testImplementation(libs.kotest.assertions.core) testFixturesImplementation(libs.kotest.runner.junit5) } val javaComponent = components["java"] as AdhocComponentWithVariants javaComponent.withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() } javaComponent.withVariantsFromConfiguration(configurations["testFixturesRuntimeElements"]) { skip() } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/containers/ContainerOptions.kt ================================================ package com.trendyol.stove.containers import org.testcontainers.utility.DockerImageName typealias ContainerFn = TIn.() -> Unit typealias UseContainerFn = (DockerImageName) -> TContainer /** * Container options to run */ interface ContainerOptions { val registry: String val image: String val tag: String val imageWithTag: String get() = "$image:$tag" val compatibleSubstitute: String? val useContainerFn: UseContainerFn val containerFn: ContainerFn } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/containers/ProvidedRegistry.kt ================================================ package com.trendyol.stove.containers import org.testcontainers.utility.DockerImageName /** * Can be set globally */ @Suppress("ktlint:standard:property-naming") var DEFAULT_REGISTRY = "docker.io" /** * Allows a docker image to be sourced from a different registry. [DEFAULT_REGISTRY] * Example: * ```kotlin * withProvidedRegistry("couchbase/server", registry) { * CouchbaseContainer(it).withBucket(bucketDefinition) * } * ``` */ fun withProvidedRegistry( imageName: String, registry: String = DEFAULT_REGISTRY, compatibleSubstitute: String? = null, containerBuilder: (DockerImageName) -> T ): T { val trimmedRegistry = registry.trim('/') val trimmedImage = imageName.trim('/') // Skip prepending the registry when the image already contains a registry // (e.g. "mcr.microsoft.com/mssql/server") or when the registry is blank. val fullImage = if (trimmedRegistry.isBlank() || containsRegistry(trimmedImage)) { trimmedImage } else { "$trimmedRegistry/$trimmedImage" } return containerBuilder( DockerImageName .parse(fullImage) .asCompatibleSubstituteFor(compatibleSubstitute ?: imageName) ) } /** * Heuristic: an image name contains a registry if the part before the first `/` * includes a dot (e.g. `mcr.microsoft.com`, `ghcr.io`, `registry.example.com`) * or a colon for port (e.g. `localhost:5000`). */ private fun containsRegistry(imageName: String): Boolean { val firstSegment = imageName.substringBefore('/') return firstSegment != imageName && (firstSegment.contains('.') || firstSegment.contains(':')) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/containers/StoveContainer.kt ================================================ package com.trendyol.stove.containers import arrow.core.* import com.github.dockerjava.api.DockerClient import com.github.dockerjava.api.async.ResultCallback import com.github.dockerjava.api.model.* import com.trendyol.stove.system.abstractions.SystemRuntime import org.testcontainers.DockerClientFactory import org.testcontainers.utility.DockerImageName import java.io.ByteArrayOutputStream import java.util.concurrent.* /** * Interface for Stove-managed Docker containers with extended functionality. * * This interface wraps Testcontainers and provides additional capabilities like: * - Pausing/unpausing containers for fault injection tests * - Executing commands inside running containers * - Inspecting container state * * ## Implemented By * * All Stove database and infrastructure containers implement this interface: * - PostgreSQL, MongoDB, Couchbase, Elasticsearch, MSSQL, Redis containers * - Kafka containers * * ## Pause/Unpause for Fault Injection * * Simulate network partitions or service unavailability: * * ```kotlin * stove { * // Pause the database to simulate outage * postgresql { * pause() * } * * // Test application behavior during outage * http { * getResponse("/health") { response -> * response.status shouldBe 503 * } * } * * // Restore the database * postgresql { * unpause() * } * * // Verify recovery * http { * getResponse("/health") { response -> * response.status shouldBe 200 * } * } * } * ``` * * ## Execute Commands Inside Container * * Run commands inside the container for debugging or setup: * * ```kotlin * postgresql { * val result = execCommand("psql", "-U", "test", "-c", "SELECT 1") * result.exitCode shouldBe 0 * result.stdout shouldContain "1" * } * ``` * * ## Inspect Container State * * Check container health and status: * * ```kotlin * couchbase { * val info = inspect() * info.running shouldBe true * info.paused shouldBe false * } * ``` * * @see SystemRuntime * @see ExecResult * @see StoveContainerInspectInformation */ interface StoveContainer : SystemRuntime { val imageNameAccess: DockerImageName val containerIdAccess: String get() = dockerClientAccess.value .listContainersCmd() .exec() .firstOrNone { it.image == imageNameAccess.asCanonicalNameString() } .getOrElse { error("Container with image ${imageNameAccess.asCanonicalNameString()} not found") } .id val dockerClientAccess: Lazy get() = lazy { DockerClientFactory.lazyClient() } /** * Pauses the container. This method is idempotent - if the container is already paused, it does nothing. */ fun pause() { if (!inspect().paused) { dockerClientAccess.value.pauseContainerCmd(containerIdAccess).exec() } } /** * Unpauses the container. This method is idempotent - if the container is not paused, it does nothing. */ fun unpause() { if (inspect().paused) { dockerClientAccess.value.unpauseContainerCmd(containerIdAccess).exec() } } /** * Executes a command inside the running container using Docker client directly. * This method works even when the testcontainer instance wasn't started (e.g., on subsequent runs with reuse). * * @param command The command and its arguments to execute * @param timeoutSeconds Maximum time to wait for command completion (default: 60 seconds) * @return [ExecResult] containing exit code, stdout, and stderr */ fun execCommand( vararg command: String, timeoutSeconds: Long = 60 ): ExecResult { val docker = dockerClientAccess.value val containerId = containerIdAccess // Create exec instance val execCreate = docker .execCreateCmd(containerId) .withAttachStdout(true) .withAttachStderr(true) .withCmd(*command) .exec() val stdout = ByteArrayOutputStream() val stderr = ByteArrayOutputStream() val latch = CountDownLatch(1) // Start exec and capture output docker .execStartCmd(execCreate.id) .exec(object : ResultCallback.Adapter() { override fun onNext(frame: Frame) { when (frame.streamType) { StreamType.STDOUT -> { stdout.write(frame.payload) } StreamType.STDERR -> { stderr.write(frame.payload) } else -> {} // Ignore other stream types } } override fun onComplete() { latch.countDown() } override fun onError(throwable: Throwable) { latch.countDown() } }) if (!latch.await(timeoutSeconds, TimeUnit.SECONDS)) { return ExecResult( exitCode = -1, stdout = stdout.toString(Charsets.UTF_8), stderr = "Command timed out after $timeoutSeconds seconds" ) } val execInspect = docker.inspectExecCmd(execCreate.id).exec() val exitCode = execInspect.exitCodeLong?.toInt() ?: -1 return ExecResult( exitCode = exitCode, stdout = stdout.toString(Charsets.UTF_8), stderr = stderr.toString(Charsets.UTF_8) ) } fun inspect(): StoveContainerInspectInformation = dockerClientAccess.value .inspectContainerCmd(containerIdAccess) .exec() .let { StoveContainerInspectInformation( id = it.id, labels = it.config.labels ?: emptyMap(), name = it.name, state = it.state.toString(), running = it.state.running ?: false, paused = it.state.paused ?: false, restarting = it.state.restarting ?: false, startedAt = it.state.startedAt.toString(), finishedAt = it.state.finishedAt.toString(), exitCode = it.state.exitCodeLong ?: 0, error = it.state.error.toString() ) } } /** * Result of executing a command in a container. */ data class ExecResult( val exitCode: Int, val stdout: String, val stderr: String ) data class StoveContainerInspectInformation( val id: String, val labels: Map, val name: String, val state: String, val running: Boolean, val paused: Boolean, val restarting: Boolean, val startedAt: String, val finishedAt: String, val exitCode: Long, val error: String ) ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/database/migrations/DatabaseMigration.kt ================================================ package com.trendyol.stove.database.migrations import com.trendyol.stove.system.abstractions.AfterRunAware /** * Interface for database schema migrations and test data setup. * * Migrations run after the database container starts and before tests execute. * Use migrations for: * - Creating database schemas and tables * - Setting up indexes * - Seeding reference data * - Any setup that requires a running database * * ## Module-Specific Type Aliases * * Each Stove module provides a convenience type alias so you don't need to * remember the generic `DatabaseMigration` form: * * | Module | Type Alias | Resolves To | * |-----------------|---------------------------|------------------------------------------------| * | stove-postgres | `PostgresqlMigration` | `DatabaseMigration`| * | stove-mysql | `MySqlMigration` | `DatabaseMigration` | * | stove-mssql | `MsSqlMigration` | `DatabaseMigration` | * | stove-mongodb | `MongodbMigration` | `DatabaseMigration` | * | stove-couchbase | `CouchbaseMigration` | `DatabaseMigration` | * | stove-elasticsearch | `ElasticsearchMigration` | `DatabaseMigration` | * | stove-redis | `RedisMigration` | `DatabaseMigration` | * | stove-kafka | `KafkaMigration` | `DatabaseMigration` | * * ## Creating a Migration * * ```kotlin * // Using the module-specific type alias (recommended): * class CreateUsersTableMigration : PostgresqlMigration { * override val order: Int = MigrationPriority.HIGHEST.value * * override suspend fun execute(connection: PostgresSqlMigrationContext) { * connection.operations.execute(""" * CREATE TABLE IF NOT EXISTS users ( * id SERIAL PRIMARY KEY, * name VARCHAR(255) NOT NULL, * email VARCHAR(255) UNIQUE NOT NULL * ) * """) * } * } * * // Or using the generic interface directly: * class SeedTestDataMigration : DatabaseMigration { * override val order: Int = 100 // Run after schema creation * * override suspend fun execute(connection: PostgresSqlMigrationContext) { * connection.operations.execute( * "INSERT INTO users (name, email) VALUES ('Test User', 'test@example.com')" * ) * } * } * ``` * * ## Registering Migrations * * ```kotlin * postgresql { * PostgresqlOptions( * configureExposedConfiguration = { /* ... */ } * ).migrations { * register() * register() * register() * } * } * ``` * * ## Migration Order * * Migrations execute in ascending order of the [order] property: * - Use [MigrationPriority.HIGHEST] for schema creation * - Use [MigrationPriority.LOWEST] for cleanup or final setup * - Use intermediate values (e.g., 1, 2, 3, 100) for ordered execution * * ## Important Notes * * - Migrations cannot have constructor dependencies (use `object` or no-arg constructors) * - Migrations run after [AfterRunAware.afterRun] * - Connection is managed by Stove - don't close it manually * - Use idempotent statements (`IF NOT EXISTS`) for safety * * @param TConnection The database connection type (e.g., `Connection` for JDBC, `MongoClient` for MongoDB) * @see MigrationPriority * @see AfterRunAware.afterRun */ interface DatabaseMigration { /** * Executes the migration using the provided connection. * * The [connection] is already established and ready for use. * Do not close or dispose the connection - Stove manages its lifecycle. * * @param connection An active database connection. */ suspend fun execute(connection: TConnection) /** * The execution order of this migration. * * Lower values execute first. Use [MigrationPriority] constants * or specific integer values for fine-grained control. * * @see MigrationPriority */ val order: Int } /** * Predefined priority values for migration ordering. * * ## Usage * * ```kotlin * class SchemaCreation : PostgresqlMigration { * override val order = MigrationPriority.HIGHEST.value // Runs first * // ... * } * * class DataSeeding : PostgresqlMigration { * override val order = MigrationPriority.LOWEST.value // Runs last * // ... * } * * class MiddleMigration : PostgresqlMigration { * override val order = 50 // Custom priority * // ... * } * ``` */ enum class MigrationPriority( /** * The integer value representing this priority level. */ val value: Int ) { /** * Lowest priority - migration runs last. * * Use for cleanup, finalization, or dependent migrations. */ LOWEST(Int.MAX_VALUE), /** * Highest priority - migration runs first. * * Use for schema creation, essential setup, or migrations * that others depend on. */ HIGHEST(Int.MIN_VALUE) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/database/migrations/MigrationCollection.kt ================================================ package com.trendyol.stove.database.migrations import com.trendyol.stove.system.annotations.StoveDsl import kotlin.reflect.KClass import kotlin.reflect.full.createInstance /** * A registry for database migrations that manages registration, ordering, and execution. * * This class stores and executes [DatabaseMigration]s in the correct order. * Migrations are deduplicated by class type and executed sorted by their [DatabaseMigration.order]. * * ## Registering Migrations * * ```kotlin * postgresql { * PostgresqlOptions( * configureExposedConfiguration = { /* ... */ } * ).migrations { * // Simple registration (uses no-arg constructor) * register() * register() * * // Registration with custom instance * register { * SeedDataMigration(testDataPath = "/test-data.sql") * } * } * } * ``` * * ## Replacing Migrations * * Useful for test-specific migrations that override default behavior: * * ```kotlin * .migrations { * register() * * // Replace with test-specific seed data * replace() * * // Or replace with custom instance * replace { * MinimalSeedMigration() * } * } * ``` * * ## Execution Order * * Migrations execute in ascending order of [DatabaseMigration.order]: * * ```kotlin * class SchemaCreation : DatabaseMigration { * override val order = MigrationPriority.HIGHEST.value // -2147483648 * } * * class DataSeeding : DatabaseMigration { * override val order = 100 // After schema * } * * class IndexCreation : DatabaseMigration { * override val order = MigrationPriority.LOWEST.value // 2147483647 * } * ``` * * @param TConnection The database connection type (e.g., `Connection`, `MongoClient`). * @see DatabaseMigration * @see MigrationPriority */ @StoveDsl class MigrationCollection { private val types: MutableMap, DatabaseMigration> = mutableMapOf() /** * Registers a migration by its class, creating an instance using reflection. * * The migration class must have a no-argument constructor. * If a migration of this type is already registered, it won't be replaced. * * @param clazz The migration class to register. * @return This collection for fluent chaining. */ fun > register(clazz: KClass): MigrationCollection = types .putIfAbsent(clazz, clazz.createInstance() as DatabaseMigration) .let { this } /** * Registers a migration with a specific instance. * * Use this when your migration requires constructor parameters * or custom initialization. * * @param clazz The migration class (used as the registry key). * @param migrator The migration instance to register. * @return This collection for fluent chaining. */ fun > register( clazz: KClass, migrator: DatabaseMigration ): MigrationCollection = types .put(clazz, migrator) .let { this } /** * Registers a migration using a factory function. * * ```kotlin * register { * ConfigurableMigration(batchSize = 1000) * } * ``` * * @param instance Factory function that creates the migration instance. * @return This collection for fluent chaining. */ inline fun > register( instance: () -> DatabaseMigration ): MigrationCollection = this.register(T::class, instance()).let { this } /** * Replaces an existing migration with a new instance. * * @param clazz The migration class to replace (registry key). * @param migrator The new migration instance. * @return This collection for fluent chaining. */ fun > replace( clazz: KClass, migrator: DatabaseMigration ): MigrationCollection = types .replace(clazz, migrator) .let { this } /** * Registers a migration using its reified type parameter. * * This is the most common way to register migrations: * * ```kotlin * migrations { * register() * register() * } * ``` * * @return This collection for fluent chaining. */ inline fun > register(): MigrationCollection = this.register(T::class).let { this } /** * Replaces an existing migration using a factory function. * * ```kotlin * replace { * TestMigration() * } * ``` * * @param instance Factory function that creates the replacement migration. * @return This collection for fluent chaining. */ inline fun > replace( instance: () -> DatabaseMigration ): MigrationCollection = this.replace(T::class, instance()).let { this } /** * Replaces one migration type with another. * * The new migration class must have a no-argument constructor. * * ```kotlin * // Replace production migration with test-specific one * replace() * ``` * * @param TOld The migration type to replace. * @param TNew The new migration type. * @return This collection for fluent chaining. */ inline fun < reified TOld : DatabaseMigration, reified TNew : DatabaseMigration > replace(): MigrationCollection = this.replace(TOld::class, TNew::class.createInstance()).let { this } /** * Executes all registered migrations in order. * * Migrations are sorted by [DatabaseMigration.order] (ascending) * and executed sequentially. * * @param connection The active database connection for executing migrations. */ suspend fun run(connection: TConnection): Unit = types .map { it.value }.sortedBy { it.order }.forEach { it.execute(connection) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/database/migrations/SupportsMigrations.kt ================================================ package com.trendyol.stove.database.migrations import com.trendyol.stove.system.annotations.StoveDsl /** * Interface for system options that support migrations. * * Implement this interface to add migration support to your system options. * The [TContext] type parameter represents the context passed to migrations * (e.g., database connection, admin client, etc.). * * Example implementation: * ```kotlin * class MySystemOptions( * override val configureExposedConfiguration: (MyExposedConfig) -> List * ) : SystemOptions, SupportsMigrations { * * override val migrationCollection: MigrationCollection = MigrationCollection() * } * ``` * * Usage: * ```kotlin * mySystem { * MySystemOptions( * configureExposedConfiguration = { cfg -> listOf(...) } * ).migrations { * register() * } * } * ``` * * @param TContext The type of context passed to migrations (e.g., database connection) * @param TSelf The concrete type of the implementing class (for fluent API) */ interface SupportsMigrations> { /** * The collection of migrations to run. */ val migrationCollection: MigrationCollection /** * Configures migrations for this system. * * Example: * ```kotlin * options.migrations { * register() * register() * } * ``` * * @param migration Configuration block for the migration collection * @return This options instance for fluent chaining */ @Suppress("UNCHECKED_CAST") fun migrations( migration: @StoveDsl MigrationCollection.() -> Unit ): TSelf { migration(migrationCollection) return this as TSelf } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/functional/Extensions.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.functional import arrow.core.* /** Extracts a [T] element if exists, otherwise throws [NoSuchElementException] */ fun Option.get(): T = this.getOrElse { throw NoSuchElementException("get() on Option does not exist") } /** * Extracts an [Option] nested in the [Try] to a not nested [Option]. * * @return [Option] nested in a [Success] or [None] if this is a [Failure]. */ fun Try>.flatten(): Option = when (this) { is Success -> value is Failure -> None } /** * Returns [Some] if this [Some] contains a [Success]. Otherwise, returns [None]. * * @return [Some] if this [Some] contains a [Success]. Otherwise, returns [None]. */ fun Option>.flatten(): Option = if (isNone()) None else get().toOption() /** * Returns nested [List] if this is [Some]. Otherwise, returns an empty [List]. * * @return Nested [List] if this is [Some]. Otherwise, returns an empty [List]. */ fun Option>.flatten(): List = if (isNone()) emptyList() else get().toList() /** * Returns [List] of values of each [Some] in this [Iterable]. * * @return [List] of values of each [Some] in this [Iterable]. */ fun Iterable>.flatten(): List = flatMap { it.toList() } /** * Moves inner [Option] outside of the outer [Try]. * * @return [Try] nested in an [Option] for an [Option] nested in a [Try]. * * @since 1.4.0 */ fun Try>.evert(): Option> = when (this) { is Success -> value.map { Success(it) } is Failure -> Some(this) } /** * Moves inner [Try] outside of the outer [Option]. * * @return [Option] nested in a [Try] for a [Try] nested in an [Option]. * * @since 1.4.0 */ fun Option>.evert(): Try> = when (this) { is Some -> value.map { Some(it) } is None -> Success(None) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/functional/Reflect.kt ================================================ package com.trendyol.stove.functional import kotlin.reflect.KProperty class Reflect( val instance: T ) { inner class OnGoingReflect( private val instance: T, private val property: String ) { infix fun then(value: R) { val prop = instance::class.java.getDeclaredField(property) prop.isAccessible = true prop.set(instance, value) } } inline fun on(propertySelector: T.() -> KProperty): OnGoingReflect = OnGoingReflect(instance, propertySelector(instance).name) inline fun on(property: String): OnGoingReflect = OnGoingReflect(instance, property) companion object { inline operator fun invoke( instance: T, block: Reflect.() -> Unit ): Reflect { val ref = Reflect(instance) block(ref) return ref } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/functional/Try.kt ================================================ @file:Suppress("unused", "TooGenericExceptionCaught") package com.trendyol.stove.functional import arrow.core.* import arrow.core.Either.Left import arrow.core.Either.Right // https://github.com/sczerwinski/kotlin-util /** * Representation of an operation that might successfully return a value or throw an exception. * * An instance of [Try] may be either a [Success] or a [Failure]. * * This type is based on `scala.util.Try`. * * _Note: Only non-fatal exceptions are caught by the combinators on `Try` (see [NonFatal]). Serious * system errors, on the other hand, will be thrown._ * * @param T Type of the value of a successful operation. */ sealed class Try { /** * Returns `true` if this is a [Success] or `false` if this is [Failure]. * * @return `true` if this is a [Success] or `false` if this is [Failure]. */ abstract val isSuccess: Boolean /** * Returns `true` if this is a [Failure] or `false` if this is [Success]. * * @return `true` if this is a [Failure] or `false` if this is [Success]. */ abstract val isFailure: Boolean /** * Returns a [Success] with an exception it this is a [Failure] or a [Failure] if this is a * [Success]. * * @return A [Success] with an exception it this is a [Failure] or a [Failure] if this is a * [Success]. */ abstract val failed: Try /** * Gets the value of a [Success] or throw an exception from a [Failure]. * * @return Value of a [Success]. * * @throws Throwable If this is a [Failure]. */ abstract fun get(): T /** * Gets the value of a [Success] or `null` if this is a [Failure]. * * @return Value of a [Success] or `null`. */ abstract fun getOrNull(): T? /** * Runs [action] if this is a [Success]. Returns [Unit] without any action if this is a [Failure]. * * @param action Action to be run on a value of a [Success]. */ inline fun forEach(action: (T) -> Unit) { if (isSuccess) action(get()) } /** * Maps value of a [Success] using [transform] or returns the same [Try] if this is a [Failure]. * * @param transform Function transforming value of a [Success]. * * @return [Try] with a value mapped using [transform] or this object if this is a [Failure]. */ inline fun map(transform: (T) -> R): Try = when (this) { is Success -> Try { transform(value) } is Failure -> this } /** * Maps value of a [Success] to a new [Try] using [transform] or returns the same [Try] if this is * a [Failure]. * * @param transform Function transforming value of a [Success] to a [Try]. * * @return [Try] returned by [transform] or this object if this is a [Failure]. */ inline fun flatMap(transform: (T) -> Try): Try = when (this) { is Success -> { try { transform(value) } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } is Failure -> { this } } /** * Returns the same [Success] if the [predicate] is satisfied for the value. Otherwise, returns a * [Failure]. * * @param predicate Predicate function. * * @return The same [Success] if the [predicate] is satisfied for the value. Otherwise, returns a * [Failure]. */ inline fun filter(predicate: (T) -> Boolean): Try = when (this) { is Success -> { try { if (predicate(value)) { this } else { throw NoSuchElementException("Predicate not satisfied for $value") } } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } is Failure -> { this } } /** * Returns the same [Success] if the [predicate] is not satisfied for the value. Otherwise, * returns a [Failure]. * * @param predicate Predicate function. * * @return The same [Success] if the [predicate] is not satisfied for the value. Otherwise, * returns a [Failure]. */ inline fun filterNot(predicate: (T) -> Boolean): Try = when (this) { is Success -> { try { if (!predicate(value)) { this } else { throw NoSuchElementException("Predicate not satisfied for $value") } } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } is Failure -> { this } } /** * Returns the same [Success] cast to type [R] if it is [R]. Otherwise, returns a [Failure]. * * @param R Required type of the optional value. * * @return The same [Success] cast to type [R] if it is [R]. Otherwise, returns a [Failure]. */ inline fun filterIsInstance(): Try = when (this) { is Success -> { try { if (value is R) { Success(value) } else { throw NoSuchElementException("Not an instance of ${R::class}") } } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } is Failure -> { this } } /** * Returns the same [Success] if the [predicate] is satisfied for the value. Otherwise, returns a * [Failure] containing the given [throwable]. * * @param predicate Predicate function. * @param throwable Function providing a throwable to be used when the [predicate] is not * satisfied. * * @return The same [Success] if the [predicate] is satisfied for the value. Otherwise, returns a * [Failure] containing the given [throwable]. * * @since 1.2 */ inline fun filterOrElse( predicate: (T) -> Boolean, throwable: (T) -> Throwable ): Try = when (this) { is Success -> { try { if (predicate(value)) this else throw throwable(value) } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } is Failure -> { this } } /** * Transforms a [Success] using [successTransform] or a [Failure] using [failureTransform]. * * @param successTransform Function transforming value of a [Success] to a new [Try]. * @param failureTransform Function transforming exception from a [Failure] to a new [Try]. * * @return Result of applying [successTransform] on [Success] or [failureTransform] on [Failure] . */ inline fun fold( successTransform: (T) -> R, failureTransform: (Throwable) -> R ): R = when (this) { is Success -> { try { successTransform(value) } catch (exception: Throwable) { if (NonFatal(exception)) failureTransform(exception) else throw exception } } is Failure -> { failureTransform(exception) } } /** * Transforms a [Success] using [successTransform] or a [Failure] using [failureTransform]. * * @param successTransform Function transforming value of a [Success] to a new [Try]. * @param failureTransform Function transforming exception from a [Failure] to a new [Try]. * * @return New [Try] being a result of a transformation of a [Success] with [successTransform] or * a [Failure] with [failureTransform]. */ inline fun transform( successTransform: (T) -> Try, failureTransform: (Throwable) -> Try ): Try = try { when (this) { is Success -> successTransform(value) is Failure -> failureTransform(exception) } } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } /** * Returns [Success] containing a `Pair` of values of this and [other] [Try] if both instances of * [Try] are [Success]. Otherwise, returns first [Failure]. * * @param other Other [Try]. * * @return [Success] containing a `Pair` of values of this and [other] [Try] if both instances of * [Try] are [Success]. Otherwise, returns first [Failure]. * * @since 1.1 */ infix fun zip(other: Try): Try> = Try { get() to other.get() } /** * Returns [Success] containing the result of applying [transform] to both values of this and * [other] [Try] if both instances of [Try] are [Success]. Otherwise, returns first [Failure]. * * @param other Other [Try]. * @param transform Function transforming values of both instances of [Success]. * * @return [Success] containing the result of applying [transform] to both values of this and * [other] [Try] if both instances of [Try] are [Success]. Otherwise, returns first [Failure]. * * @since 1.1 */ inline fun zip( other: Try, transform: (T, T1) -> R ): Try = Try { transform(get(), other.get()) } /** * Converts this [Try] to [Either]. * * @return [Left] if this is [Failure] or [Right] if this is [Success]. */ abstract fun toEither(): Either /** * Converts this [Try] to [Option]. * * @return [None] if this is [Failure] or [Some] if this is [Success]. */ abstract fun toOption(): Option companion object { /** * Creates a new [Try] based on the result of the [callable]. * * @param callable A callable operation. * * @return An instance of [Success] or [Failure], depending on whether the operation. */ inline operator fun invoke(callable: () -> T): Try = try { Success(callable()) } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } } /** * Gets the value of a [Success] or [default] value if this is a [Failure]. * * @param default Default value provider. * * @return Value of a [Success] or [default] value. */ inline fun Try.getOrElse(default: () -> T): T = if (isSuccess) get() else default() /** * Returns this [Try] if this is a [Success] or [default] if this is a [Failure]. * * @param default Default [Try] provider. * * @return This [Success] or [default]. */ inline fun Try.orElse(default: () -> Try): Try = if (isSuccess) { this } else { try { default() } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } /** * Transforms a nested [Try] to a not nested [Try]. * * @return [Try] nested in a [Success] or this object if this is a [Failure]. */ fun Try>.flatten(): Try = when (this) { is Success -> value is Failure -> this } /** * Returns this [Try] if this is a [Success] or a [Try] created for the [rescue] operation if this * is a [Failure]. * * @param rescue Function creating a new value from the exception to a [Failure]. * * @return This [Try] if this is a [Success] or a [Try] created for the [rescue] operation if this * is a [Failure]. */ inline fun Try.recover(rescue: (Throwable) -> T): Try = when (this) { is Success -> { this } is Failure -> { try { Success(rescue(exception)) } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } } /** * Returns this [Try] if this is a [Success] or a [Try] created by the [rescue] function if this is * a [Failure]. * * @param rescue Function creating a new [Try] from the exception to a [Failure]. * * @return This [Try] if this is a [Success] or a [Try] created by the [rescue] function if this is * a [Failure]. */ inline fun Try.recoverWith(rescue: (Throwable) -> Try): Try = when (this) { is Success -> { this } is Failure -> { try { rescue(exception) } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } } /** * Returns the same [Success] if its value is not `null`. Otherwise, returns a [Failure]. * * @return The same [Success] if its value is not `null`. Otherwise, returns a [Failure]. */ fun Try.filterNotNull(): Try = when (this) { is Success -> { try { if (value != null) Success(value) else throw NoSuchElementException("Value is null") } catch (exception: Throwable) { if (NonFatal(exception)) Failure(exception) else throw exception } } is Failure -> { this } } data class Success( val value: T ) : Try() { override val isSuccess: Boolean get() = true override val isFailure: Boolean get() = false override val failed: Try get() = Failure(UnsupportedOperationException("Unsupported operation: Success::failed")) override fun get(): T = value override fun getOrNull(): T? = value override fun toEither(): Either = Right(value) override fun toOption(): Option = Some(value) } data class Failure( val exception: Throwable ) : Try() { override val isSuccess: Boolean get() = false override val isFailure: Boolean get() = true override val failed: Try get() = Success(exception) override fun get(): Nothing = throw exception override fun getOrNull(): Nothing? = null override fun toEither(): Either = Left(exception) override fun toOption(): Option = None } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/http/StoveHttpResponse.kt ================================================ package com.trendyol.stove.http sealed class StoveHttpResponse( open val status: Int, open val headers: Map ) { data class Bodiless( override val status: Int, override val headers: Map ) : StoveHttpResponse(status, headers) data class WithBody( override val status: Int, override val headers: Map, val body: suspend () -> T ) : StoveHttpResponse(status, headers) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/messaging/Observation.kt ================================================ package com.trendyol.stove.messaging import arrow.core.Option import com.trendyol.stove.system.annotations.StoveDsl data class MessageMetadata( val topic: String, val key: String, val headers: Map ) sealed interface ParsedMessage { val message: Option val metadata: MessageMetadata } class SuccessfulParsedMessage( override val message: Option, override val metadata: MessageMetadata ) : ParsedMessage class FailedParsedMessage( override val message: Option, override val metadata: MessageMetadata, val reason: Throwable ) : ParsedMessage @StoveDsl open class ObservedMessage( open val actual: T, open val metadata: MessageMetadata ) @StoveDsl data class FailedObservedMessage( override val actual: T, override val metadata: MessageMetadata, val reason: Throwable ) : ObservedMessage(actual, metadata) @StoveDsl data class Failure( val message: ObservedMessage, val reason: Throwable ) ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/JsonReportRenderer.kt ================================================ package com.trendyol.stove.reporting import arrow.core.Option import arrow.core.getOrElse import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import java.time.Instant import java.time.format.DateTimeFormatter /** * JSON renderer for machine-parseable test reports. * Useful for CI integration, log aggregation, and programmatic analysis. */ object JsonReportRenderer : ReportRenderer { private val mapper = ObjectMapper().apply { enable(SerializationFeature.INDENT_OUTPUT) } private val timestampFormatter = DateTimeFormatter.ISO_INSTANT override fun render(report: TestReport, snapshots: List): String { val entries = report.entries() val jsonReport = JsonTestReport( testId = report.testId, testName = report.testName, timestamp = timestampFormatter.format(Instant.now()), entries = entries.map { it.toJsonEntry() }, systemSnapshots = snapshots.associate { it.system to it.state }, summary = JsonSummary( total = entries.size, passed = entries.count { it.isPassed }, failed = entries.count { it.isFailed } ) ) return mapper.writeValueAsString(jsonReport) } private fun ReportEntry.toJsonEntry(): JsonReportEntry = JsonReportEntry( timestamp = timestampFormatter.format(timestamp), system = system, testId = testId, action = action, input = input.toJsonValue(), output = output.toJsonValue(), metadata = metadata, expected = expected.toJsonValue(), actual = actual.toJsonValue(), result = result.name, error = error.toJsonValue() ) /** Convert Option to JSON-friendly value - empty string for None */ private fun Option.toJsonValue(): Any = getOrElse { "" } } /** * JSON representation of a test report. */ data class JsonTestReport( val testId: String, val testName: String, val timestamp: String, val entries: List, val systemSnapshots: Map, val summary: JsonSummary ) /** * JSON representation of a report entry. * No nullable fields - uses empty string/map for absent values. */ data class JsonReportEntry( val timestamp: String, val system: String, val testId: String, val action: String, val input: Any, val output: Any, val metadata: Map, val expected: Any, val actual: Any, val result: String, val error: Any ) /** * JSON representation of report summary. */ data class JsonSummary( val total: Int, val passed: Int, val failed: Int ) ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/PrettyConsoleRenderer.kt ================================================ package com.trendyol.stove.reporting import arrow.core.Option import arrow.core.getOrElse import com.github.ajalt.mordant.rendering.AnsiLevel import com.github.ajalt.mordant.rendering.BorderType.Companion.ROUNDED import com.github.ajalt.mordant.rendering.BorderType.Companion.SQUARE import com.github.ajalt.mordant.rendering.TextColors.brightBlue import com.github.ajalt.mordant.rendering.TextColors.brightCyan import com.github.ajalt.mordant.rendering.TextColors.brightGreen import com.github.ajalt.mordant.rendering.TextColors.brightMagenta import com.github.ajalt.mordant.rendering.TextColors.brightRed import com.github.ajalt.mordant.rendering.TextColors.brightWhite import com.github.ajalt.mordant.rendering.TextColors.brightYellow import com.github.ajalt.mordant.rendering.TextColors.cyan import com.github.ajalt.mordant.rendering.TextColors.green import com.github.ajalt.mordant.rendering.TextColors.magenta import com.github.ajalt.mordant.rendering.TextColors.red import com.github.ajalt.mordant.rendering.TextColors.white import com.github.ajalt.mordant.rendering.TextColors.yellow import com.github.ajalt.mordant.rendering.TextStyle import com.github.ajalt.mordant.rendering.TextStyles.bold import com.github.ajalt.mordant.rendering.TextStyles.dim import com.github.ajalt.mordant.rendering.Whitespace import com.github.ajalt.mordant.rendering.Widget import com.github.ajalt.mordant.table.verticalLayout import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.widgets.Panel import com.github.ajalt.mordant.widgets.Text import com.trendyol.stove.tracing.TraceVisualization import java.time.ZoneId import java.time.format.DateTimeFormatter /** * Mordant-based renderer for rich, terminal-friendly Stove test reports. */ @Suppress("TooManyFunctions") object PrettyConsoleRenderer : ReportRenderer { private const val MIN_RENDER_WIDTH = 72 private const val MAX_RENDER_WIDTH = 160 private const val PANEL_CHROME_WIDTH = 6 private const val NESTED_PANEL_CHROME_WIDTH = 12 private const val SNAPSHOT_INDENT_STEP = 4 private const val DETAIL_INDENT_STEP = 2 private const val VALUE_PREVIEW_LIMIT = 6 private const val LABEL_WRAP_INDENT_LIMIT = 32 private const val MIN_BREAK_SEARCH_WINDOW = 12 private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") private data class SummaryStats( val passed: Int, val failed: Int, val total: Int ) { val hasFailures: Boolean = failed > 0 val statusLabel: String = if (hasFailures) "FAILED" else "IN PROGRESS" val statusColor: TextStyle = if (hasFailures) brightRed else brightBlue val borderColor: TextStyle = if (hasFailures) brightMagenta else brightCyan } private data class PreparedSnapshot( val snapshot: SystemSnapshot, val text: String ) private data class PreparedReport( val report: TestReport, val entries: List, val summary: SummaryStats, val summaryText: String, val timelineText: String, val snapshots: List ) override fun render(report: TestReport, snapshots: List): String { val prepared = prepareReport(report, snapshots) val renderWidth = calculateRenderWidth(prepared) val terminal = createTerminal(renderWidth) val panelContentWidth = renderWidth - PANEL_CHROME_WIDTH val snapshotContentWidth = (renderWidth - NESTED_PANEL_CHROME_WIDTH).coerceAtLeast(MIN_RENDER_WIDTH - PANEL_CHROME_WIDTH) val widgets = buildList { add(buildSummaryPanel(prepared, panelContentWidth)) add(buildTimelinePanel(prepared, panelContentWidth)) if (prepared.snapshots.isNotEmpty()) add(buildSnapshotsPanel(prepared.snapshots, snapshotContentWidth)) } return widgets.joinToString(separator = "\n\n") { terminal.render(it) } } private fun prepareReport(report: TestReport, snapshots: List): PreparedReport { val entries = report.entries() val summary = buildSummaryStats(entries) return PreparedReport( report = report, entries = entries, summary = summary, summaryText = buildSummaryText(report, summary), timelineText = buildTimelineText(entries), snapshots = snapshots.map { PreparedSnapshot(it, buildSnapshotText(it)) } ) } private fun buildSummaryStats(entries: List): SummaryStats = SummaryStats( passed = entries.count { it.isPassed }, failed = entries.count { it.isFailed }, total = entries.size ) private fun createTerminal(width: Int): Terminal = Terminal( ansiLevel = AnsiLevel.TRUECOLOR, width = width, nonInteractiveWidth = width, interactive = true ) private fun buildSummaryPanel(prepared: PreparedReport, contentWidth: Int): Widget = Panel( title = Text((bold + brightWhite)("STOVE TEST EXECUTION REPORT")), bottomTitle = Text((bold + prepared.summary.statusColor)(prepared.summary.statusLabel)), borderType = ROUNDED, borderStyle = prepared.summary.borderColor, expand = true, content = Text(wrapTextBlock(prepared.summaryText, contentWidth), whitespace = Whitespace.PRE) ) private fun buildSummaryText(report: TestReport, summary: SummaryStats): String = buildString { appendLine("${bold("Test")}: ${brightYellow(report.testName)}") appendLine("${bold("ID")}: ${dim(report.testId)}") appendLine("${bold("Status")}: ${(bold + summary.statusColor)(summary.statusLabel)}") appendLine() appendLine( "${bold("Summary")}: " + brightGreen("${summary.passed} passed") + " · " + (if (summary.failed > 0) brightRed("${summary.failed} failed") else brightGreen("0 failed")) + " · " + brightCyan("${summary.total} total") ) }.trimEnd() private fun buildTimelinePanel(prepared: PreparedReport, contentWidth: Int): Widget { val content = if (prepared.entries.isEmpty()) { Text(dim("No actions recorded yet."), whitespace = Whitespace.PRE) } else { Text(wrapTextBlock(prepared.timelineText, contentWidth), whitespace = Whitespace.PRE) } return Panel( title = Text((bold + brightCyan)("TIMELINE")), bottomTitle = Text(dim("${prepared.entries.size} step(s)")), borderType = ROUNDED, borderStyle = cyan, expand = true, content = content ) } private fun calculateRenderWidth(prepared: PreparedReport): Int { val candidateLines = buildList { add("STOVE TEST EXECUTION REPORT") add(prepared.report.testName) add(prepared.report.testId) add(prepared.summary.statusLabel) addAll(prepared.summaryText.lines()) add("TIMELINE") addAll(prepared.timelineText.lines()) if (prepared.snapshots.isNotEmpty()) { add("SYSTEM SNAPSHOTS") prepared.snapshots.forEach { preparedSnapshot -> add(preparedSnapshot.snapshot.system.uppercase()) addAll(preparedSnapshot.text.lines()) } } } val longestLine = candidateLines.maxOfOrNull { visibleLength(it) } ?: MIN_RENDER_WIDTH return (longestLine + PANEL_CHROME_WIDTH).coerceIn(MIN_RENDER_WIDTH, MAX_RENDER_WIDTH) } private fun groupSequentialBySystem(entries: List): List>> { val groups = mutableListOf>>() entries.withIndex().forEach { indexedEntry -> val lastGroup = groups.lastOrNull() if (lastGroup != null && lastGroup.first().value.system == indexedEntry.value.system) { lastGroup += indexedEntry } else { groups += mutableListOf(indexedEntry) } } return groups.map { it.toList() } } private fun buildTimelineText(entries: List): String = groupSequentialBySystem(entries) .flatMapIndexed { groupIndex, group -> val groupHeader = buildTimelineGroupHeader(group) val renderedEntries = group.flatMap { indexedEntry -> buildTimelineEntryLines(indexedEntry.index + 1, indexedEntry.value) } if (groupIndex == 0) listOf(groupHeader) + renderedEntries else listOf("", groupHeader) + renderedEntries }.joinToString("\n") private fun buildTimelineGroupHeader(group: List>): String { val system = group.first().value.system val style = bold + styleForSystem(system) val failedCount = group.count { it.value.isFailed } val passedCount = group.size - failedCount val summary = if (failedCount > 0) { "${brightGreen("$passedCount passed")} · ${brightRed("$failedCount failed")}" } else { brightGreen("${group.size} passed") } return "${style("${system.uppercase()} · ${group.size} step(s)")}${dim(" $summary")}" } private fun buildTimelineEntryLines(index: Int, entry: ReportEntry): List { val statusColor = if (entry.isFailed) brightRed else brightGreen val statusText = if (entry.isFailed) "✗ FAILED" else "✓ PASSED" val header = " ${(bold + statusColor)( "#$index $statusText" )} ${brightWhite(sanitize(entry.action))} ${dim("(${formatTimestamp(entry)})")}" val details = buildEntryDetails(entry).lines().map { " $it" } return listOf(header) + details } private fun buildEntryDetails(entry: ReportEntry): String = buildList { add("${brightCyan("Action")}: ${sanitize(entry.action)}") entry.input.fold({ }, { addAll(renderDetailBlock(yellow("Input"), it)) }) entry.output.fold({ }, { addAll(renderDetailBlock(brightBlue("Output"), it)) }) if (entry.metadata.isNotEmpty()) { addAll(renderDetailBlock(dim("Metadata"), entry.metadata)) } if (entry.isFailed) { entry.expected.fold({ }, { addAll(renderDetailBlock(green("Expected"), it)) }) entry.actual.fold({ }, { addAll(renderDetailBlock(red("Actual"), it)) }) entry.error.fold({ }, { add("${brightRed("Error")}: ${sanitize(it)}") }) } entry.executionTrace.fold({ }, { addAll(renderTraceDetails(it)) }) }.joinToString("\n") private fun renderTraceDetails(trace: TraceVisualization): List { val spanSummary = if (trace.failedSpans > 0) { "${trace.totalSpans} total / ${brightRed("${trace.failedSpans} failed")}" } else { "${trace.totalSpans} total / ${brightGreen("0 failed")}" } val styledTreeLines = trace.tree .lines() .map { line -> when { line.contains("✗") -> brightRed(line) line.contains("✓") -> brightGreen(line) line.startsWith("POST") || line.startsWith("GET") || line.startsWith("PUT") || line.startsWith("DELETE") -> brightCyan(line) line.trimStart().startsWith("|") -> magenta(line) else -> dim(line) } } return listOf( "", (bold + brightMagenta)("Execution Trace"), "${dim("TraceId")}: ${trace.traceId}", "${dim("Spans")}: $spanSummary" ) + styledTreeLines } private fun buildSnapshotsPanel(snapshots: List, contentWidth: Int): Widget = Panel( title = Text((bold + brightMagenta)("SYSTEM SNAPSHOTS")), bottomTitle = Text(dim("${snapshots.size} snapshot(s)")), borderType = ROUNDED, borderStyle = brightMagenta, expand = true, content = verticalLayout { spacing = 1 snapshots.forEach { preparedSnapshot -> cell(buildSnapshotPanel(preparedSnapshot, contentWidth)) } } ) private fun buildSnapshotPanel(preparedSnapshot: PreparedSnapshot, contentWidth: Int): Widget = Panel( title = Text((bold + brightWhite)(preparedSnapshot.snapshot.system.uppercase())), borderType = SQUARE, borderStyle = styleForSystem(preparedSnapshot.snapshot.system), expand = true, content = Text(wrapTextBlock(preparedSnapshot.text, contentWidth), whitespace = Whitespace.PRE) ) private fun buildSnapshotText(snapshot: SystemSnapshot): String { val summaryLines = snapshot.summary .lines() .map(::sanitize) .filter { it.isNotBlank() } val stateLines = renderSnapshotState(snapshot.state) return buildString { appendLine((bold + brightCyan)("Summary")) if (summaryLines.isEmpty()) { appendLine(" ${dim("No summary available")}") } else { summaryLines.forEach { appendLine(" ${styleSummaryLine(it)}") } } if (stateLines.isNotEmpty()) { appendLine() appendLine((bold + brightCyan)("State")) stateLines.forEach(::appendLine) } }.trimEnd() } private fun renderSnapshotState(state: Map, indent: Int = 4): List = state.flatMap { (key, value) -> renderSnapshotEntry(key, value, indent) } private fun renderSnapshotEntry(key: String, value: Any?, indent: Int): List { val prefix = " ".repeat(indent) val keyLabel = yellow(key) return when (value) { is Collection<*> -> { val count = "$prefix$keyLabel: ${styleCollectionCount(key, value.size)}" val items = value.flatMapIndexed { index, item -> renderSnapshotItem(index, item, indent + SNAPSHOT_INDENT_STEP) } listOf(count) + items } is Map<*, *> -> { val header = "$prefix$keyLabel:" val lines = value.entries.flatMap { (nestedKey, nestedValue) -> renderSnapshotEntry(nestedKey.toString(), nestedValue, indent + SNAPSHOT_INDENT_STEP) } listOf(header) + lines } else -> { listOf("$prefix$keyLabel: ${styleSnapshotValue(key, value)}") } } } private fun renderSnapshotItem(index: Int, item: Any?, indent: Int): List { val prefix = " ".repeat(indent) val indexLabel = dim("[$index]") return when (item) { is Map<*, *> -> { val nested = item.entries.flatMap { (key, value) -> renderSnapshotEntry(key.toString(), value, indent + SNAPSHOT_INDENT_STEP) } listOf("$prefix$indexLabel") + nested } is Collection<*> -> { listOf("$prefix$indexLabel ${brightCyan("${item.size} item(s)")}") } else -> { listOf("$prefix$indexLabel ${formatValuePlain(item)}") } } } private fun renderDetailBlock(label: String, value: Any?): List { val renderedValue = renderNestedValue(value) return if (renderedValue.size == 1) { listOf("$label: ${renderedValue.first().trimStart()}") } else { listOf("$label:") + renderedValue.map { " $it" } } } private fun renderNestedValue(value: Any?, indent: Int = 0): List { val prefix = " ".repeat(indent) return when (value) { null -> { listOf("${prefix}none") } is Option<*> -> { renderNestedValue(value.getOrElse { null }, indent) } is String -> { sanitize(value).lines().map { "$prefix$it" } } is Number, is Boolean -> { listOf("$prefix$value") } is Map<*, *> -> { if (value.isEmpty()) { listOf("$prefix{}") } else { value.entries.flatMap { (key, nestedValue) -> when (nestedValue) { is Map<*, *>, is Collection<*> -> listOf("$prefix$key:") + renderNestedValue(nestedValue, indent + DETAIL_INDENT_STEP) else -> listOf("$prefix$key: ${formatValuePlain(nestedValue)}") } } } } is Collection<*> -> { if (value.isEmpty()) { listOf("$prefix[]") } else { value.flatMapIndexed { index, item -> when (item) { is Map<*, *>, is Collection<*> -> listOf("$prefix[$index]") + renderNestedValue(item, indent + DETAIL_INDENT_STEP) else -> listOf("$prefix[$index] ${formatValuePlain(item)}") } } } } else -> { listOf("${prefix}${sanitize(value.toString())}") } } } private fun styleForSystem(system: String): TextStyle { val palette = listOf(brightBlue, brightMagenta, brightCyan, brightGreen, brightYellow) val index = (system.lowercase().hashCode() and Int.MAX_VALUE) % palette.size return palette[index] } private fun styleSummaryLine(line: String): String { val lower = line.lowercase() val number = extractLastNumber(lower) return when { "failed" in lower -> if ((number ?: 0) == 0) brightGreen(line) else brightRed(line) "passed" in lower || "success" in lower -> brightGreen(line) "consumed" in lower || "produced" in lower || "published" in lower || "registered" in lower || "served" in lower -> brightCyan(line) else -> white(line) } } private fun styleCollectionCount(key: String, size: Int): String { val lower = key.lowercase() return when { "fail" in lower -> if (size == 0) brightGreen("0 item(s)") else brightRed("$size item(s)") "pass" in lower || "success" in lower -> brightGreen("$size item(s)") else -> brightCyan("$size item(s)") } } private fun styleSnapshotValue(key: String, value: Any?): String { val lower = key.lowercase() return when (value) { is Number -> { val intValue = value.toInt() when { "fail" in lower -> if (intValue == 0) brightGreen(value.toString()) else brightRed(value.toString()) "pass" in lower || "success" in lower -> brightGreen(value.toString()) else -> brightYellow(value.toString()) } } is Boolean -> { if (value) brightGreen("true") else brightRed("false") } else -> { formatValuePlain(value) } } } private fun formatTimestamp(entry: ReportEntry): String = entry.timestamp .atZone(ZoneId.systemDefault()) .format(timeFormatter) private fun extractLastNumber(value: String): Int? = Regex("(\\d+)(?!.*\\d)") .find(value) ?.groupValues ?.getOrNull(1) ?.toIntOrNull() private fun formatValuePlain(value: Any?): String = when (value) { null -> "none" is Option<*> -> value.getOrElse { null }?.let(::formatValuePlain) ?: "none" is String -> sanitize(value) is Number, is Boolean -> value.toString() is Collection<*> -> renderCollection(value) is Map<*, *> -> renderMap(value) else -> sanitize(value.toString()) } private fun renderCollection(value: Collection<*>): String { if (value.isEmpty()) return "[]" val printable = value.take(VALUE_PREVIEW_LIMIT) return printable.joinToString(", ", prefix = "[", postfix = if (value.size > VALUE_PREVIEW_LIMIT) ", ...]" else "]") { formatValuePlain(it) } } private fun renderMap(value: Map<*, *>): String { if (value.isEmpty()) return "{}" val printable = value.entries.take(VALUE_PREVIEW_LIMIT) return printable.joinToString(", ", prefix = "{", postfix = if (value.size > VALUE_PREVIEW_LIMIT) ", ...}" else "}") { "${it.key}=${formatValuePlain(it.value)}" } } private fun sanitize(value: String): String = value.replace("\r", "") private fun visibleLength(value: String): Int = stripAnsi(value).length private fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "") private fun wrapTextBlock(text: String, width: Int): String = text.lines().flatMap { wrapLine(it, width) }.joinToString("\n") private fun wrapLine(line: String, width: Int): List { if (line.isEmpty() || visibleLength(line) <= width) return listOf(line) val plain = stripAnsi(line) val continuationIndent = buildContinuationIndent(plain) val wrapped = mutableListOf() var remaining = line var remainingWidth = width var firstLine = true while (visibleLength(remaining) > remainingWidth) { val plainRemaining = stripAnsi(remaining) val breakAt = findWrapPosition(plainRemaining, remainingWidth) val rawBreakAt = rawIndexForVisibleIndex(remaining, breakAt) wrapped += remaining.substring(0, rawBreakAt).trimEnd() val nextRawStart = rawIndexAfterLeadingWhitespace(remaining, rawBreakAt) remaining = " ".repeat(continuationIndent) + remaining.substring(nextRawStart) remainingWidth = (width - continuationIndent).coerceAtLeast(MIN_BREAK_SEARCH_WINDOW) firstLine = false if (!firstLine && visibleLength(remaining) <= width) { remainingWidth = width } } wrapped += remaining return wrapped } private fun buildContinuationIndent(line: String): Int { val leadingSpaces = line.takeWhile { it == ' ' }.length val content = line.drop(leadingSpaces) val labelIndex = content.indexOf(": ") return when { labelIndex in 1..LABEL_WRAP_INDENT_LIMIT -> leadingSpaces + labelIndex + 2 content.startsWith("[") -> leadingSpaces + DETAIL_INDENT_STEP else -> leadingSpaces + DETAIL_INDENT_STEP } } private fun findWrapPosition(line: String, width: Int): Int { val softBreakStart = width.coerceAtLeast(MIN_BREAK_SEARCH_WINDOW) for (index in width downTo softBreakStart) { val previous = line.getOrNull(index - 1) val current = line.getOrNull(index) if (previous != null && isWrapDelimiter(previous)) return index if (current != null && current.isWhitespace()) return index } return width.coerceAtMost(line.length) } private fun isWrapDelimiter(char: Char): Boolean = char.isWhitespace() || char in charArrayOf(',', ';', ')', ']', '}', '/', '_') private fun rawIndexForVisibleIndex(line: String, visibleIndex: Int): Int { var rawIndex = 0 var visibleCount = 0 while (rawIndex < line.length && visibleCount < visibleIndex) { if (line[rawIndex] == '\u001B') { rawIndex = advancePastAnsi(line, rawIndex) } else { rawIndex++ visibleCount++ } } return rawIndex } private fun rawIndexAfterLeadingWhitespace(line: String, startIndex: Int): Int { var rawIndex = startIndex while (rawIndex < line.length) { if (line[rawIndex] == '\u001B') { rawIndex = advancePastAnsi(line, rawIndex) } else if (line[rawIndex].isWhitespace()) { rawIndex++ } else { break } } return rawIndex } private fun advancePastAnsi(line: String, startIndex: Int): Int { var rawIndex = startIndex + 1 while (rawIndex < line.length && line[rawIndex] != 'm') rawIndex++ return (rawIndex + 1).coerceAtMost(line.length) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/ReportEntry.kt ================================================ package com.trendyol.stove.reporting import arrow.core.None import arrow.core.Option import arrow.core.Some import arrow.core.toOption import com.trendyol.stove.tracing.TraceContext import com.trendyol.stove.tracing.TraceVisualization import java.time.Instant /** * Represents an action performed during test execution with its result. * * Every test operation is an action with an outcome: * - If it completes successfully → PASSED * - If it throws or fails assertion → FAILED * * Uses Arrow's Option monad for optional fields - no nullability. */ data class ReportEntry( val timestamp: Instant, val system: String, val testId: String, val action: String, val result: AssertionResult = AssertionResult.PASSED, val input: Option = None, val output: Option = None, val metadata: Map = emptyMap(), val expected: Option = None, val actual: Option = None, val error: Option = None, val traceId: Option = None, val executionTrace: Option = None ) { val summary: String get() = "[$system] $action" val isFailed: Boolean get() = result == AssertionResult.FAILED val isPassed: Boolean get() = result == AssertionResult.PASSED val hasTrace: Boolean get() = traceId.isSome() companion object { private fun now(): Instant = Instant.now() /** * Creates a successful action entry. */ fun success( system: String, testId: String, action: String, input: Option = None, output: Option = None, metadata: Map = emptyMap(), traceId: Option = TraceContext.current()?.traceId.toOption() ): ReportEntry = ReportEntry( timestamp = now(), system = system, testId = testId, action = action, result = AssertionResult.PASSED, input = input, output = output, metadata = metadata, traceId = traceId ) /** * Creates an action entry with explicit result. */ fun action( system: String, testId: String, action: String, passed: Boolean, input: Option = None, output: Option = None, metadata: Map = emptyMap(), expected: Option = None, actual: Option = None, error: Option = None, traceId: Option = TraceContext.current()?.traceId.toOption(), executionTrace: Option = None ): ReportEntry = ReportEntry( timestamp = now(), system = system, testId = testId, action = action, result = AssertionResult.of(passed), input = input, output = output, metadata = metadata, expected = expected, actual = actual, error = error, traceId = traceId, executionTrace = executionTrace ) /** * Creates a failed action entry. */ fun failure( system: String, testId: String, action: String, error: String, input: Option = None, output: Option = None, metadata: Map = emptyMap(), expected: Option = None, actual: Option = None, traceId: Option = TraceContext.current()?.traceId.toOption() ): ReportEntry = ReportEntry( timestamp = now(), system = system, testId = testId, action = action, result = AssertionResult.FAILED, input = input, output = output, metadata = metadata, expected = expected, actual = actual, error = Some(error), traceId = traceId ) } } /** * Result of an action/assertion. */ enum class AssertionResult { PASSED, FAILED; companion object { fun of(passed: Boolean): AssertionResult = if (passed) PASSED else FAILED } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/ReportEventListener.kt ================================================ package com.trendyol.stove.reporting /** * Listener for report lifecycle events. * * Implementors receive callbacks when tests start, end, and when report entries are recorded. * All methods have default no-op implementations — override only what you need. * * Methods are non-suspending. Implementors should dispatch async work internally * if needed — the reporter will not wait. */ interface ReportEventListener { fun onTestStarted(ctx: StoveTestContext) {} fun onTestFailed(testId: String, error: String) {} fun onTestEnded(testId: String) {} fun onEntryRecorded(entry: ReportEntry) {} } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/ReportRenderer.kt ================================================ package com.trendyol.stove.reporting /** * Interface for rendering test reports in different formats. */ interface ReportRenderer { /** * Render a test report with optional system snapshots. */ fun render(report: TestReport, snapshots: List): String } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/Reports.kt ================================================ @file:Suppress("TooGenericExceptionCaught") package com.trendyol.stove.reporting import arrow.core.None import arrow.core.Option import arrow.core.Some import arrow.core.toOption import com.trendyol.stove.system.abstractions.PluggedSystem import com.trendyol.stove.tracing.TraceContext import com.trendyol.stove.tracing.TraceVisualization /** * Interface for systems that participate in test reporting. * * Provides recording capabilities for actions during test execution. * Every action has an implicit or explicit result (PASSED/FAILED). * * ## Design Principles * - **Functional**: Uses Option monad, immutable data, no nullability * - **Simple**: Single entry type for all operations * - **Composable**: `record` combines action recording with execution */ interface Reports { /** * System identifier for reports. Defaults to class name without "System" suffix. * Override only when custom naming is needed (e.g., "HTTP" instead of "Http"). */ val reportSystemName: String get() = this::class.simpleName?.removeSuffix("System") ?: "Unknown" /** * Access to the reporter. Requires implementing class to be a [PluggedSystem]. */ val reporter: StoveReporter get() = (this as? PluggedSystem)?.stove?.reporter ?: error("Reports must be implemented by a PluggedSystem") /** * Capture current system state for failure reports. * Override to provide system-specific snapshots (e.g., Kafka messages, WireMock stubs). */ fun snapshot(): SystemSnapshot = SystemSnapshot( system = reportSystemName, state = emptyMap(), summary = "No detailed state available" ) /** * Execute an action and report the result. * * This is the preferred method for actions that include assertions. * It handles success/failure reporting automatically and re-throws on failure. * * @param action Description of the action being performed * @param input Optional input data for the action * @param output Optional output data (if not provided, block result is used) * @param metadata Additional metadata for the report entry * @param expected Optional expected result description * @param actual Optional actual result description * @param block The block to execute * * Example: * ```kotlin * report( * action = "GET /users/123", * input = Some(queryParams), * expected = Some("200 OK") * ) { * response.status shouldBe 200 * } * ``` */ suspend fun report( action: String, input: Option = None, output: Option = None, metadata: Map = emptyMap(), expected: Option = None, actual: Option = None, block: suspend () -> T ): T { if (!reporter.isEnabled) return block() return try { val result = block() val finalOutput = output.fold({ result.toOption() }, { Some(it) }) reporter.record( ReportEntry.action( system = reportSystemName, testId = reporter.currentTestId(), action = action, passed = true, input = input, output = finalOutput, metadata = metadata, expected = expected, actual = actual, traceId = TraceContext.current()?.traceId.toOption() ) ) result } catch (e: Throwable) { // Try to attach trace visualization if tracing system is available val executionTrace = tryAttachTraceVisualization() reporter.record( ReportEntry.action( system = reportSystemName, testId = reporter.currentTestId(), action = action, passed = false, input = input, output = output, metadata = metadata, expected = expected, actual = actual, error = e.message.toOption(), traceId = TraceContext.current()?.traceId.toOption(), executionTrace = executionTrace ) ) throw e } } /** * Try to attach trace visualization from the tracing system if available. * No reflection needed - uses TraceProvider interface. * * For failure cases, we wait longer (2 seconds) to ensure spans are exported, * especially when exceptions are thrown immediately. */ private fun tryAttachTraceVisualization(): Option { val stove = (this as? PluggedSystem)?.stove ?: return None // Find any system that implements TraceProvider val traceProvider = stove.systemsOf() .firstOrNull() ?: return None // Wait longer for failures (2s) since exceptions might interrupt span export return traceProvider.getTraceVisualizationForCurrentTest(waitTimeMs = 2000) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/SpanEventListener.kt ================================================ package com.trendyol.stove.reporting import com.trendyol.stove.tracing.SpanInfo /** * Listener for span recording events. * * Receives callbacks when spans are recorded by [com.trendyol.stove.tracing.StoveTraceCollector]. * Default no-op implementation — override only what you need. */ interface SpanEventListener { fun onSpanRecorded(span: SpanInfo) {} } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/SpanListenerRegistry.kt ================================================ package com.trendyol.stove.reporting /** * Interface for systems that accept span event listeners. * Lives in the core module so that other modules (e.g. dashboard) can * register listeners without depending on the tracing module directly. */ interface SpanListenerRegistry { fun addSpanListener(listener: SpanEventListener) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/StoveReporter.kt ================================================ package com.trendyol.stove.reporting import com.trendyol.stove.system.Stove import java.util.concurrent.* /** * Central reporter that manages test reports and context. * Thread-safe for concurrent test execution. * * ## Design * - Each test gets its own [TestReport] container * - Test context is resolved from [StoveTestContextHolder] (ThreadLocal) or internal context * - Snapshots are collected from all systems implementing [Reports] */ class StoveReporter( val isEnabled: Boolean = true ) { private val logger = org.slf4j.LoggerFactory.getLogger(StoveReporter::class.java) private val reports = ConcurrentHashMap() private val contextThreadLocal = ThreadLocal() private val listeners = CopyOnWriteArrayList() /** Register a listener to receive report events */ fun addListener(listener: ReportEventListener) { listeners.add(listener) } /** Remove a previously registered listener */ fun removeListener(listener: ReportEventListener) { listeners.remove(listener) } /** Start tracking a new test */ fun startTest(ctx: StoveTestContext) { contextThreadLocal.set(ctx.testId) reports.computeIfAbsent(ctx.testId) { TestReport(ctx.testId, ctx.testName) } listeners.forEach { runCatching { it.onTestStarted(ctx) }.onFailure { e -> logger.warn("Listener failed on onTestStarted", e) } } } /** Mark the current test as failed */ fun reportFailure(error: String) { val testId = resolveTestId() ?: return listeners.forEach { runCatching { it.onTestFailed(testId, error) }.onFailure { e -> logger.warn("Listener failed on onTestFailed", e) } } } /** End tracking the current test */ fun endTest() { val testId = resolveTestId() try { if (testId != null) { listeners.forEach { runCatching { it.onTestEnded(testId) }.onFailure { e -> logger.warn("Listener failed on onTestEnded", e) } } } } finally { contextThreadLocal.remove() } } /** Record an entry in the current test's report */ fun record(entry: ReportEntry) { if (!isEnabled) return currentTest().record(entry) listeners.forEach { runCatching { it.onEntryRecorded(entry) }.onFailure { e -> logger.warn("Listener failed on onEntryRecorded", e) } } } /** Get report for current test, creating if needed */ fun currentTest(): TestReport = reports.computeIfAbsent(currentTestId()) { TestReport(it, it) } /** Get report for current test if it exists */ fun currentTestOrNull(): TestReport? = resolveTestId()?.let { reports[it] } /** Get current test ID */ fun currentTestId(): String = resolveTestId() ?: DEFAULT_TEST_ID /** Check if current test has failures */ fun hasFailures(): Boolean = currentTestOrNull()?.hasFailures() == true /** Clear current test report */ fun clear(): Unit = resolveTestId()?.let(::clear) ?: Unit /** Clear report for the specified test ID */ fun clear(testId: String): Unit = reports.remove(testId)?.clear() ?: Unit /** Render report using specified renderer */ fun dump(renderer: ReportRenderer): String = currentTestOrNull()?.let { renderer.render(it, collectSnapshots()) } ?: "" /** Render report only if there are failures */ fun dumpIfFailed(renderer: ReportRenderer = PrettyConsoleRenderer): String = currentTestOrNull() ?.takeIf { it.hasFailures() } ?.let { renderer.render(it, collectSnapshots()) } ?: "" /** Print report to console only if there are failures */ fun printIfFailed(renderer: ReportRenderer = PrettyConsoleRenderer): Unit = dumpIfFailed(renderer).takeIf { it.isNotEmpty() }?.let(::println) ?: Unit /** Collect snapshots from all reporting systems */ fun collectSnapshots(): List = runCatching { if (Stove.instanceInitialized()) { Stove.instance.systemsOf() .map { it.snapshot() } } else { emptyList() } }.getOrDefault(emptyList()) private fun resolveTestId(): String? = StoveTestContextHolder.get()?.testId ?: contextThreadLocal.get() companion object { private const val DEFAULT_TEST_ID = "default" } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/StoveTestContext.kt ================================================ package com.trendyol.stove.reporting import kotlinx.coroutines.currentCoroutineContext import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext /** * Coroutine context element that identifies the current test. * Used by Kotest to correlate report entries with the test that generated them. */ data class StoveTestContext( val testId: String, val testName: String, val specName: String? = null, val testPath: List = emptyList() ) : AbstractCoroutineContextElement(Key) { companion object Key : CoroutineContext.Key } /** * Extension function to get the current test context from the coroutine context. */ suspend fun currentStoveTestContext(): StoveTestContext? = currentCoroutineContext()[StoveTestContext] ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/StoveTestContextHolder.kt ================================================ package com.trendyol.stove.reporting /** * Thread-local holder for test context. * Used by JUnit 5 (which uses threads) to correlate report entries with tests. */ object StoveTestContextHolder { private val threadLocalContext = ThreadLocal() /** * Set the current test context for this thread. */ fun set(context: StoveTestContext) = threadLocalContext.set(context) /** * Get the current test context for this thread, if any. */ fun get(): StoveTestContext? = threadLocalContext.get() /** * Clear the test context for this thread. */ fun clear() = threadLocalContext.remove() } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/StoveTestExceptions.kt ================================================ package com.trendyol.stove.reporting /** * Exception that wraps test assertion failures with Stove's execution report. * The report is included in the exception message for display by test engines. * * Preserves the original exception's stack trace so test frameworks show the actual failure location. */ class StoveTestFailureException( originalMessage: String, stoveReport: String, cause: Throwable? = null ) : AssertionError(buildStoveReportMessage(originalMessage, stoveReport), cause) { init { // Copy the original stack trace to show the actual failure location cause?.let { stackTrace = it.stackTrace } } } /** * Exception that wraps test errors with Stove's execution report. * The report is included in the exception message for display by test engines. * * Preserves the original exception's stack trace so test frameworks show the actual failure location. */ class StoveTestErrorException( originalMessage: String, stoveReport: String, cause: Throwable? = null ) : Exception(buildStoveReportMessage(originalMessage, stoveReport), cause) { init { // Copy the original stack trace to show the actual failure location cause?.let { stackTrace = it.stackTrace } } } private fun buildStoveReportMessage( originalMessage: String, stoveReport: String ): String = """ |$originalMessage | |${formatStoveReport(stoveReport)} """.trimMargin() private fun formatStoveReport(stoveReport: String): String { if (stoveReport.isBlank()) return "" return if (hasReportHeader(stoveReport)) { stoveReport } else { """ |═══════════════════════════════════════════════════════════════════════════════ | STOVE EXECUTION REPORT |═══════════════════════════════════════════════════════════════════════════════ | |$stoveReport """.trimMargin() } } private fun hasReportHeader(stoveReport: String): Boolean { val plain = stoveReport.replace(Regex("\u001B\\[[0-9;]*m"), "") return plain.contains("STOVE EXECUTION REPORT") || plain.contains("STOVE TEST EXECUTION REPORT") } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/SystemSnapshot.kt ================================================ package com.trendyol.stove.reporting /** * Snapshot of a system's state at a point in time. * Used for debugging test failures by providing context about what the system was doing. * * Systems with rich internal state (like Kafka's MessageStore or WireMock's stubs) * should override [Reports.snapshot] to provide detailed state information. */ data class SystemSnapshot( val system: String, val state: Map, val summary: String ) ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/TestReport.kt ================================================ package com.trendyol.stove.reporting import java.util.concurrent.ConcurrentLinkedQueue /** * Container for report entries belonging to a single test. * Thread-safe for concurrent recording during test execution. * * Exposes only immutable views of data through public APIs. */ class TestReport( val testId: String, val testName: String ) { private val queue: ConcurrentLinkedQueue = ConcurrentLinkedQueue() /** Record a new entry. Thread-safe. */ fun record(entry: ReportEntry): Unit = queue.add(entry).let { } /** All entries as immutable list */ fun entries(): List = queue.toList() /** Entries for this test only */ fun entriesForThisTest(): List = queue.filter { it.testId == testId } /** All failed entries */ fun failures(): List = queue.filter { it.isFailed } /** Failed entries for this test */ fun failuresForThisTest(): List = entriesForThisTest().filter { it.isFailed } /** True if any failures exist */ fun hasFailures(): Boolean = queue.any { it.isFailed } /** Clear all entries */ fun clear(): Unit = queue.clear() } // ============================================================================ // Extension Functions for List // Functional-style filtering operations // ============================================================================ /** Filter entries by system name */ fun List.forSystem(system: String): List = filter { it.system == system } /** Filter entries by test ID */ fun List.forTest(testId: String): List = filter { it.testId == testId } /** Get only failed entries */ fun List.failures(): List = filter { it.isFailed } /** Get only passed entries */ fun List.passed(): List = filter { it.isPassed } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/reporting/TraceProvider.kt ================================================ package com.trendyol.stove.reporting import arrow.core.Option import com.trendyol.stove.tracing.TraceVisualization /** * Interface for systems that can provide execution trace information. * Implemented by the tracing system to avoid circular dependencies. */ interface TraceProvider { /** * Gets trace visualization for the current test context. * Returns None if no traces are available. * * @param waitTimeMs How long to wait for spans to be exported (default 300ms) */ fun getTraceVisualizationForCurrentTest(waitTimeMs: Long = 300): Option } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/serialization/gson.kt ================================================ package com.trendyol.stove.serialization import com.google.gson.Gson object StoveGson { val default: Gson = com.google.gson .GsonBuilder() .create() fun byConfiguring( configurer: com.google.gson.GsonBuilder.() -> com.google.gson.GsonBuilder ): Gson = configurer(com.google.gson.GsonBuilder()).create() fun anyJsonStringSerde(gson: Gson = default): StoveSerde = StoveGsonStringSerializer(gson) fun anyByteArraySerde(gson: Gson = default): StoveSerde = StoveGsonByteArraySerializer(gson) } class StoveGsonStringSerializer( private val gson: Gson ) : StoveSerde { override fun serialize(value: TIn): String = gson.toJson(value) override fun deserialize(value: String, clazz: Class): T = gson.fromJson(value, clazz) } class StoveGsonByteArraySerializer( private val gson: Gson ) : StoveSerde { override fun serialize(value: TIn): ByteArray = gson.toJson(value).toByteArray() override fun deserialize(value: ByteArray, clazz: Class): T = gson.fromJson(value.toString(Charsets.UTF_8), clazz) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/serialization/jackson.kt ================================================ package com.trendyol.stove.serialization import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.* import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.SerializationFeature.FAIL_ON_EMPTY_BEANS import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.* import com.trendyol.stove.functional.* import java.time.Instant import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAccessor object StoveJackson { val default: ObjectMapper = jacksonObjectMapper().disable(FAIL_ON_EMPTY_BEANS).apply { findAndRegisterModules() } fun byConfiguring( configurer: JsonMapper.Builder.() -> Unit ): ObjectMapper = JsonMapper.builder(default.factory).apply(configurer).build() fun anyByteArraySerde(objectMapper: ObjectMapper = default): StoveSerde = StoveJacksonByteArraySerializer(objectMapper) fun anyJsonStringSerde(objectMapper: ObjectMapper = default): StoveSerde = StoveJacksonStringSerializer(objectMapper) } class StoveJacksonStringSerializer( private val objectMapper: ObjectMapper ) : StoveSerde { override fun serialize(value: TIn): String = objectMapper.writeValueAsString(value) as String override fun deserialize(value: String, clazz: Class): T = objectMapper.readValue(value, clazz) } class StoveJacksonByteArraySerializer( private val objectMapper: ObjectMapper ) : StoveSerde { override fun serialize(value: TIn): ByteArray = objectMapper.writeValueAsBytes(value) override fun deserialize(value: ByteArray, clazz: Class): T = objectMapper.readValue(value, clazz) } /** * This class is used to create an object mapper with default configurations. * This object mapper is used to serialize and deserialize request and response bodies. */ object E2eObjectMapperConfig { /** * Creates an object mapper with default configurations. * This object mapper is used to serialize and deserialize request and response bodies. */ fun createObjectMapperWithDefaults(): ObjectMapper { val isoInstantModule = SimpleModule() .addSerializer(Instant::class.java, IsoInstantSerializer()) .addDeserializer(Instant::class.java, IsoInstantDeserializer()) return JsonMapper .builder() .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .defaultPropertyInclusion(JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.NON_NULL)) .build() .registerKotlinModule() .registerModule(isoInstantModule) } } /** * Instant serializer deserializer for jackson */ class IsoInstantDeserializer : JsonDeserializer() { override fun deserialize( parser: JsonParser, context: DeserializationContext ): Instant { val string: String = parser.text.trim() return Try { DateTimeFormatter.ISO_INSTANT.parse(string) { temporal: TemporalAccessor -> Instant.from(temporal) } as Instant }.recover { Instant.ofEpochSecond(string.toLong()) }.get() } } /** * Instant serializer for jackson */ class IsoInstantSerializer : JsonSerializer() { override fun serialize( value: Instant, gen: JsonGenerator, serializers: SerializerProvider? ) { gen.writeString(value.toString()) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/serialization/kotlinx.kt ================================================ package com.trendyol.stove.serialization import kotlinx.serialization.* import kotlinx.serialization.json.* import java.io.ByteArrayOutputStream object StoveKotlinx { val default: Json = Json { ignoreUnknownKeys = true encodeDefaults = true isLenient = true explicitNulls = false } fun byConfiguring(configurer: JsonBuilder.() -> Unit): Json = Json(default) { configurer() } fun anyJsonStringSerde(json: Json = default): StoveSerde = StoveKotlinxStringSerializer(json) fun anyByteArraySerde(json: Json = default): StoveSerde = StoveKotlinxByteArraySerializer(json) } @Suppress("UNCHECKED_CAST") class StoveKotlinxStringSerializer( private val json: Json ) : StoveSerde { override fun serialize(value: TIn): String { value as Any return json.encodeToString(serializer(value::class.java), value) } override fun deserialize(value: String, clazz: Class): T = json.decodeFromString(serializer(clazz), value) as T } class StoveKotlinxByteArraySerializer( private val json: Json ) : StoveSerde { @OptIn(ExperimentalSerializationApi::class) override fun serialize(value: Any): ByteArray = ByteArrayOutputStream().use { stream -> json.encodeToStream(serializer(value::class.java), value, stream) stream.toByteArray() } @OptIn(ExperimentalSerializationApi::class) @Suppress("UNCHECKED_CAST") override fun deserialize(value: ByteArray, clazz: Class): T = json.decodeFromStream(serializer(clazz), value.inputStream()) as T } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/serialization/serialization.kt ================================================ package com.trendyol.stove.serialization import arrow.core.* /** * Unified serialization/deserialization interface for Stove's test infrastructure. * * Stove uses this interface internally for JSON handling in HTTP responses, Kafka messages, * document databases, and more. You can configure which implementation to use (Jackson, Gson, * or Kotlinx Serialization) to match your application's serialization setup. * * ## Available Implementations * * - [StoveSerde.jackson] - Jackson ObjectMapper (default) * - [StoveSerde.gson] - Google Gson * - [StoveSerde.kotlinx] - Kotlinx Serialization * * ## Configuration Example * * ```kotlin * // Configure Kafka to use the same ObjectMapper as your application * kafka { * stoveKafkaObjectMapperRef = myApplicationObjectMapper * KafkaSystemOptions { cfg -> * listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") * } * } * * // Configure HTTP client with custom content converter * httpClient { * HttpClientSystemOptions( * baseUrl = "http://localhost:8080", * contentConverter = JacksonConverter(myObjectMapper) * ) * } * ``` * * ## Custom Serde Implementation * * ```kotlin * object MyCustomSerde : StoveSerde { * override fun serialize(value: Any): String = mySerialize(value) * * override fun deserialize(value: String, clazz: Class): T = * myDeserialize(value, clazz) * } * ``` * * @param TIn The base type of objects that can be serialized (typically `Any`). * @param TOut The serialized format type (`String` for JSON, `ByteArray` for binary). */ interface StoveSerde { /** * Serializes an object to the target format. * * @param value The object to serialize. * @return The serialized representation. * @throws StoveSerdeProblem.BecauseOfSerialization if serialization fails. */ fun serialize(value: TIn): TOut /** * Deserializes data into the specified type. * * @param value The serialized data. * @param clazz The target class to deserialize into. * @return The deserialized object. * @throws StoveSerdeProblem.BecauseOfDeserialization if deserialization fails. */ fun deserialize(value: TOut, clazz: Class): T /** * Deserializes data with error handling via [Either]. * * Use this when you want to handle deserialization failures gracefully * without exceptions. * * ```kotlin * val result = serde.deserializeEither(json, User::class.java) * result.fold( * ifLeft = { error -> println("Failed: ${error.message}") }, * ifRight = { user -> println("Success: ${user.name}") } * ) * ``` * * @param value The serialized data. * @param clazz The target class. * @return Either a [StoveSerdeProblem] or the deserialized object. */ fun deserializeEither(value: TOut, clazz: Class): Either = Either .catch { deserialize(value, clazz) } .mapLeft { StoveSerdeProblem.BecauseOfDeserialization(it.message ?: "Deserialization failed", it) } /** * Companion object providing default serde implementations and utility functions. */ companion object { /** * Jackson-based serialization using [com.fasterxml.jackson.databind.ObjectMapper]. * * This is the default and most commonly used implementation. * * ```kotlin * val mapper = StoveSerde.jackson.default * val json = mapper.serialize(myObject) * val obj = mapper.deserialize(json) * ``` */ val jackson = StoveJackson /** * Gson-based serialization using [com.google.gson.Gson]. * * ```kotlin * val gson = StoveSerde.gson.default * val json = gson.serialize(myObject) * ``` */ val gson = StoveGson /** * Kotlinx Serialization-based implementation. * * Requires classes to be annotated with `@Serializable`. * * ```kotlin * @Serializable * data class User(val name: String) * * val json = StoveSerde.kotlinx.default.serialize(user) * ``` */ val kotlinx = StoveKotlinx /** * Deserializes [ByteArray] data using reified type parameter. * * ```kotlin * val user: User = serde.deserialize(bytes) * ``` */ inline fun StoveSerde.deserialize( value: ByteArray ): T = deserialize(value, T::class.java) /** * Deserializes [ByteArray] data, returning [None] on failure. * * ```kotlin * val userOption: Option = serde.deserializeOption(bytes) * userOption.onSome { user -> println(user.name) } * ``` */ inline fun StoveSerde.deserializeOption( value: ByteArray ): Option = deserializeEither(value, T::class.java).getOrNone() /** * Deserializes [String] data using reified type parameter. * * ```kotlin * val user: User = serde.deserialize(jsonString) * ``` */ inline fun StoveSerde.deserialize( value: String ): T = deserialize(value, T::class.java) /** * Deserializes [String] data, returning [None] on failure. * * ```kotlin * val userOption: Option = serde.deserializeOption(jsonString) * ``` */ inline fun StoveSerde.deserializeOption(value: String): Option = deserializeEither(value, T::class.java).getOrNone() } /** * Sealed class hierarchy for serialization/deserialization errors. * * These exceptions provide structured error information when JSON operations fail. */ sealed class StoveSerdeProblem( message: String, cause: Throwable? = null ) : RuntimeException(message, cause) { /** * Error during serialization (object to JSON/bytes). */ class BecauseOfSerialization( message: String, cause: Throwable? = null ) : StoveSerdeProblem(message, cause) /** * Error during deserialization (JSON/bytes to object). */ class BecauseOfDeserialization( message: String, cause: Throwable? = null ) : StoveSerdeProblem(message, cause) /** * Deserialization failed but a specific type was expected. * * Used when asserting message types in Kafka or document types in databases. */ class BecauseOfDeserializationButExpected( message: String, cause: Throwable? = null ) : StoveSerdeProblem(message, cause) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/BridgeSystem.kt ================================================ package com.trendyol.stove.system import arrow.core.getOrElse import com.trendyol.stove.reporting.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlin.reflect.* /** * A system that provides a bridge between the test system and the application context. * * @property stove the test system to bridge. */ @StoveDsl abstract class BridgeSystem( override val stove: Stove ) : PluggedSystem, AfterRunAwareWithContext, Reports { /** * The application context used to resolve dependencies. */ protected lateinit var ctx: T /** * Closes the bridge system. */ override fun close(): Unit = Unit /** * Initializes the bridge system after the test run. * * @param context the application context. */ override suspend fun afterRun(context: T) { ctx = context } /** * Resolves a dependency by KClass. * Override this for basic type resolution without generic support. */ abstract fun get(klass: KClass): D /** * Resolves a dependency by KType, preserving generic type information. * Override this to support generic types like List, Map, etc. * Default implementation falls back to KClass-based resolution. * * @param type the full KType including generic parameters * @return the resolved dependency */ @Suppress("UNCHECKED_CAST") open fun getByType(type: KType): D { val klass = type.classifier as? KClass ?: throw IllegalArgumentException("Cannot resolve type: $type") return get(klass) } /** * Checks that the application context has been initialized. * Throws with a clear error message if it hasn't (e.g., when using providedApplication()). */ @PublishedApi internal fun ensureContextInitialized() { check(::ctx.isInitialized) { "BridgeSystem context is not initialized. " + "Ensure a JVM starter (springBoot/ktor/micronaut) is configured and Stove.run() has been called. " + "Note: providedApplication() does not support Bridge because the remote application's DI container is not accessible." } } /** * Resolves a bean of the specified type from the application context. * Uses KType to preserve generic type information (e.g., List). * * @param T the type of bean to resolve. * @return the resolved bean. */ @PublishedApi internal inline fun resolve(): D = getByType(typeOf()) /** * Executes the specified block using the resolved bean. * If you need to capture values, declare variables outside the block and assign inside. * * @param D the type of bean to resolve. * @param block the block to execute with the resolved bean as receiver. */ @Suppress("TooGenericExceptionCaught") suspend inline fun using(block: suspend D.() -> Unit) { ensureContextInitialized() val beanName = D::class.simpleName ?: "Unknown" val metadata = mapOf("type" to (D::class.qualifiedName ?: "")) try { block(resolve()) reporter.record( ReportEntry.success( system = reportSystemName, testId = reporter.currentTestId(), action = "Bean usage: $beanName", metadata = metadata ) ) } catch (e: Throwable) { reporter.record( ReportEntry.failure( system = reportSystemName, testId = reporter.currentTestId(), action = "Bean usage: $beanName", error = e.message ?: "Unknown error", metadata = metadata ) ) throw e } } } /** * Adds a bridge system to Stove and returns the modified Stove instance. * * @receiver Stove instance to modify. * @return the modified Stove instance. */ fun Stove.withBridgeSystem(bridge: BridgeSystem): Stove = getOrRegister(bridge).let { this } /** * Returns the bridge system associated with Stove. * This function is only available in the validation DSL. * * @receiver Stove instance. * @return the bridge system. * @throws SystemNotRegisteredException if the bridge system is not registered. */ @PublishedApi internal fun Stove.bridge(): BridgeSystem<*> = getOrNone>().getOrElse { throw SystemNotRegisteredException(BridgeSystem::class) } /** * Returns the bridge system associated with Stove. * * @receiver Stove instance. * @return the bridge system. * @throws SystemNotRegisteredException if the bridge system is not registered. */ fun WithDsl.bridge(of: BridgeSystem): Stove = this.stove.withBridgeSystem(of) /** * Executes the specified block using the resolved bean from the bridge system. * Resolved beans are using physical components of the application. * * Suggested usage: validating or preparing the application state without accessing the physical components directly. * If you need to capture values from inside the block, declare variables outside and assign inside: * * ```kotlin * stove { * // Simple assertion * using { * serviceName shouldBe "personService" * find(userId = 123) shouldBe Person(id = 123, name = "John Doe") * } * * // Capturing a value for later use * var userId: Long = 0 * using { * userId = save(User(name = "John")).id * } * // Use userId in subsequent operations * } * ``` * * @receiver the validation DSL. * @param T the type of bean to resolve. * @param block the block to execute with the resolved bean as receiver. */ suspend inline fun ValidationDsl.using( block: @StoveDsl suspend T.() -> Unit ): Unit = this.stove.bridge().using(block) /** * Executes the specified block using two resolved beans. * * @param T1 the type of the first bean to resolve. * @param T2 the type of the second bean to resolve. * @param validation the block to execute with the resolved beans. */ @Suppress("TooGenericExceptionCaught") suspend inline fun < reified T1 : Any, reified T2 : Any > ValidationDsl.using( crossinline validation: suspend (T1, T2) -> Unit ): Unit = stove.bridge().let { bridge -> bridge.ensureContextInitialized() val name1 = T1::class.simpleName ?: "Unknown" val name2 = T2::class.simpleName ?: "Unknown" val beanNames = "$name1, $name2" val metadata = mapOf( "types" to listOf(T1::class.qualifiedName, T2::class.qualifiedName) ) try { val t1: T1 = bridge.resolve() val t2: T2 = bridge.resolve() validation(t1, t2) bridge.reporter.record( ReportEntry.success( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", metadata = metadata ) ) } catch (e: Throwable) { bridge.reporter.record( ReportEntry.failure( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", error = e.message ?: "Unknown error", metadata = metadata ) ) throw e } } /** * Executes the specified block using three resolved beans. * * @param T1 the type of the first bean to resolve. * @param T2 the type of the second bean to resolve. * @param T3 the type of the third bean to resolve. * @param validation the block to execute with the resolved beans. */ @Suppress("TooGenericExceptionCaught") suspend inline fun < reified T1 : Any, reified T2 : Any, reified T3 : Any > ValidationDsl.using( crossinline validation: suspend (T1, T2, T3) -> Unit ): Unit = stove.bridge().let { bridge -> bridge.ensureContextInitialized() val name1 = T1::class.simpleName ?: "Unknown" val name2 = T2::class.simpleName ?: "Unknown" val name3 = T3::class.simpleName ?: "Unknown" val beanNames = "$name1, $name2, $name3" val metadata = mapOf( "types" to listOf(T1::class.qualifiedName, T2::class.qualifiedName, T3::class.qualifiedName) ) try { val t1: T1 = bridge.resolve() val t2: T2 = bridge.resolve() val t3: T3 = bridge.resolve() validation(t1, t2, t3) bridge.reporter.record( ReportEntry.success( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", metadata = metadata ) ) } catch (e: Throwable) { bridge.reporter.record( ReportEntry.failure( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", error = e.message ?: "Unknown error", metadata = metadata ) ) throw e } } /** * Executes the specified block using four resolved beans. * * @param T1 the type of the first bean to resolve. * @param T2 the type of the second bean to resolve. * @param T3 the type of the third bean to resolve. * @param T4 the type of the fourth bean to resolve. * @param validation the block to execute with the resolved beans. */ @Suppress("TooGenericExceptionCaught") suspend inline fun < reified T1 : Any, reified T2 : Any, reified T3 : Any, reified T4 : Any > ValidationDsl.using( crossinline validation: suspend (T1, T2, T3, T4) -> Unit ): Unit = stove.bridge().let { bridge -> bridge.ensureContextInitialized() val name1 = T1::class.simpleName ?: "Unknown" val name2 = T2::class.simpleName ?: "Unknown" val name3 = T3::class.simpleName ?: "Unknown" val name4 = T4::class.simpleName ?: "Unknown" val beanNames = "$name1, $name2, $name3, $name4" val metadata = mapOf( "types" to listOf(T1::class.qualifiedName, T2::class.qualifiedName, T3::class.qualifiedName, T4::class.qualifiedName) ) try { val t1: T1 = bridge.resolve() val t2: T2 = bridge.resolve() val t3: T3 = bridge.resolve() val t4: T4 = bridge.resolve() validation(t1, t2, t3, t4) bridge.reporter.record( ReportEntry.success( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", metadata = metadata ) ) } catch (e: Throwable) { bridge.reporter.record( ReportEntry.failure( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", error = e.message ?: "Unknown error", metadata = metadata ) ) throw e } } /** * Executes the specified block using five resolved beans. * * @param T1 the type of the first bean to resolve. * @param T2 the type of the second bean to resolve. * @param T3 the type of the third bean to resolve. * @param T4 the type of the fourth bean to resolve. * @param T5 the type of the fifth bean to resolve. * @param validation the block to execute with the resolved beans. */ @Suppress("TooGenericExceptionCaught") suspend inline fun < reified T1 : Any, reified T2 : Any, reified T3 : Any, reified T4 : Any, reified T5 : Any > ValidationDsl.using( crossinline validation: suspend (T1, T2, T3, T4, T5) -> Unit ): Unit = stove.bridge().let { bridge -> bridge.ensureContextInitialized() val name1 = T1::class.simpleName ?: "Unknown" val name2 = T2::class.simpleName ?: "Unknown" val name3 = T3::class.simpleName ?: "Unknown" val name4 = T4::class.simpleName ?: "Unknown" val name5 = T5::class.simpleName ?: "Unknown" val beanNames = "$name1, $name2, $name3, $name4, $name5" val metadata = mapOf( "types" to listOf( T1::class.qualifiedName, T2::class.qualifiedName, T3::class.qualifiedName, T4::class.qualifiedName, T5::class.qualifiedName ) ) try { val t1: T1 = bridge.resolve() val t2: T2 = bridge.resolve() val t3: T3 = bridge.resolve() val t4: T4 = bridge.resolve() val t5: T5 = bridge.resolve() validation(t1, t2, t3, t4, t5) bridge.reporter.record( ReportEntry.success( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", metadata = metadata ) ) } catch (e: Throwable) { bridge.reporter.record( ReportEntry.failure( system = bridge.reportSystemName, testId = bridge.reporter.currentTestId(), action = "Bean usage: $beanNames", error = e.message ?: "Unknown error", metadata = metadata ) ) throw e } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/PortFinder.kt ================================================ package com.trendyol.stove.system import java.net.ServerSocket /** * Utility for finding available ports for test infrastructure. * * This is useful when running tests in parallel or when default ports * might already be in use. * * Usage: * ```kotlin * val port = PortFinder.findAvailablePort() * // or * val port = PortFinder.findAvailablePortFrom(50000) * ``` */ object PortFinder { private const val MAX_PORT = 65535 private const val MIN_PORT = 1024 /** * Finds an available port by letting the OS assign one. * This is the most reliable way to find an available port. * * @return An available port number assigned by the OS */ @JvmStatic fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } /** * Finds an available port starting from the given port number. * Scans ports sequentially until an available one is found. * * @param startingFrom The port number to start searching from * @return An available port number * @throws IllegalStateException if no available port is found in the range */ @JvmStatic fun findAvailablePortFrom(startingFrom: Int): Int { var port = startingFrom while (port <= MAX_PORT) { if (isPortAvailable(port)) { return port } port++ } port = MIN_PORT while (port < startingFrom) { if (isPortAvailable(port)) { return port } port++ } error("No available port found in range 1024-$MAX_PORT") } /** * Finds an available port and returns it as a String. * Uses OS-assigned port for reliability. * * @return An available port number as a String */ @JvmStatic fun findAvailablePortAsString(): String = findAvailablePort().toString() /** * Finds an available port starting from the given port and returns it as a String. * * @param startingFrom The port number to start searching from * @return An available port number as a String */ @JvmStatic fun findAvailablePortFromAsString(startingFrom: Int): String = findAvailablePortFrom(startingFrom).toString() /** * Checks if a given port is available for binding. * * @param port The port to check * @return true if the port is available, false otherwise */ @JvmStatic fun isPortAvailable(port: Int): Boolean = try { ServerSocket(port).use { true } } catch (_: Exception) { false } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/PropertiesFile.kt ================================================ package com.trendyol.stove.system import org.slf4j.Logger import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths import kotlin.io.path.* class PropertiesFile { companion object { const val REUSE_ENABLED = "testcontainers.reuse.enable=true" } private val l: Logger = LoggerFactory.getLogger(javaClass) private val propertiesFilePath: Path = Paths.get(System.getProperty("user.home"), ".testcontainers.properties") fun detectAndLogStatus() { if (propertiesFilePath.exists()) { l.info("'.testcontainers.properties' file exists") when { propertiesFilePath .readText() .contains(REUSE_ENABLED) -> { l.info("'.testcontainers.properties' looks good and contains reuse feature!") } else -> { l.info( """ '.testcontainers.properties' does not contain 'testcontainers.reuse.enable=true' You need to create either by yourself or using '${StoveOptionsDsl::enableReuseForTestContainers.name}' method """.trimIndent() ) } } } else { l.info( """'.testcontainers.properties' file DOES NOT exist. |You need to create either by yourself or using '${StoveOptionsDsl::enableReuseForTestContainers.name} method """.trimMargin() ) } } fun enable() { l.info( """ You will see a file `~/.testcontainers.properties', with the setting 'testcontainers.reuse.enable=true'. | If you don't see the file please create by yourself. | Otherwise dependencies won't keep running. """.trimIndent() ) when { !propertiesFilePath.exists() -> { propertiesFilePath.writeText(REUSE_ENABLED) } else -> { when { propertiesFilePath .readText() .contains(REUSE_ENABLED) -> { l.info( "'.testcontainers.properties' looks good and contains reuse feature!" ) } else -> { propertiesFilePath.appendText(REUSE_ENABLED) } } } } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/ProvidedApplicationUnderTest.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl /** * Options for [ProvidedApplicationUnderTest]. * * @param readiness Optional readiness strategy. If provided, Stove will verify * the remote application is reachable before running tests. * Use [ReadinessStrategy.HttpGet] for HTTP health checks, * [ReadinessStrategy.TcpPort] for gRPC/TCP, or [ReadinessStrategy.Probe] * for custom checks. */ data class ProvidedApplicationOptions( val readiness: ReadinessStrategy? = null ) /** * A no-op [ApplicationUnderTest] for testing against already-deployed remote applications. * * Use this when the application under test is already running (e.g., deployed to staging/dev) * and you want to write Stove tests against it without starting it locally. * * The application can be written in **any language** (Go, Python, .NET, Rust, Node.js, etc.) * as long as it exposes HTTP/gRPC and uses infrastructure Stove can connect to. * * ## Example * * ```kotlin * Stove().with { * httpClient { * HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") * } * providedApplication { * ProvidedApplicationOptions( * readiness = ReadinessStrategy.HttpGet( * url = "https://staging.myapp.com/actuator/health" * ) * ) * } * }.run() * ``` * * @see ProvidedApplicationOptions * @see ReadinessStrategy */ @StoveDsl class ProvidedApplicationUnderTest( private val options: ProvidedApplicationOptions ) : ApplicationUnderTest { override suspend fun start(configurations: List) { options.readiness?.let { ReadinessChecker.check(it) } } override suspend fun stop(): Unit = Unit } /** * Registers a no-op application under test for testing against a remote/already-deployed application. * * HTTP and other system configurations are done separately via their own DSL functions * (`httpClient { }`, `kafka { }`, etc.). This function only signals that the application * is already running and should not be started by Stove. * * ## Example * * ```kotlin * Stove().with { * httpClient { HttpClientSystemOptions(baseUrl = "https://staging.myapp.com") } * postgresql(AppDb) { PostgresqlOptions.provided(jdbcUrl = "jdbc:...") } * providedApplication { * ProvidedApplicationOptions( * readiness = ReadinessStrategy.HttpGet( * url = "https://staging.myapp.com/health" * ) * ) * } * }.run() * ``` * * @param configure Configuration block for [ProvidedApplicationOptions]. Defaults to no health check. * @return [ReadyStove] to chain with `.run()`. */ fun WithDsl.providedApplication( configure: () -> ProvidedApplicationOptions = { ProvidedApplicationOptions() } ): ReadyStove { this.stove.applicationUnderTest(ProvidedApplicationUnderTest(configure())) return this.stove } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/ReadinessChecker.kt ================================================ @file:Suppress("TooGenericExceptionThrown", "UseCheckOrError") package com.trendyol.stove.system import kotlinx.coroutines.delay import kotlinx.coroutines.future.await import org.slf4j.LoggerFactory import java.net.InetSocketAddress import java.net.Socket import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import kotlin.time.Duration /** * Executes [ReadinessStrategy] checks to verify that an application is ready * before running tests. * * Used internally by [ProvidedApplicationUnderTest] and `ProcessApplicationUnderTest`. * * @see ReadinessStrategy */ object ReadinessChecker { private val logger = LoggerFactory.getLogger(ReadinessChecker::class.java) private const val TCP_CONNECT_TIMEOUT_MS = 1000 /** * Executes the given [strategy] and blocks until the application is ready * or the strategy's retry limit is exhausted. * * @throws IllegalStateException if readiness cannot be confirmed. */ suspend fun check(strategy: ReadinessStrategy) { when (strategy) { is ReadinessStrategy.HttpGet -> checkHttp(strategy) is ReadinessStrategy.TcpPort -> checkTcp(strategy) is ReadinessStrategy.Probe -> checkProbe(strategy) is ReadinessStrategy.FixedDelay -> { logger.info("Waiting ${strategy.delay} for process readiness (fixed delay)") delay(strategy.delay) } } } private suspend fun checkHttp(strategy: ReadinessStrategy.HttpGet) { val client = HttpClient.newBuilder() .connectTimeout(java.time.Duration.ofMillis(strategy.timeout.inWholeMilliseconds)) .build() val request = HttpRequest.newBuilder() .uri(URI.create(strategy.url)) .GET() .timeout(java.time.Duration.ofMillis(strategy.timeout.inWholeMilliseconds)) .build() retryUntilReady(strategy.retries, strategy.retryDelay, "Health check failed after ${strategy.retries} attempts for ${strategy.url}") { attempt, total -> val response = runCatching { client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() }.onFailure { logger.warn("Health check attempt ${attempt + 1}/$total failed: ${it.message}") }.getOrThrow() if (response.statusCode() !in strategy.expectedStatusCodes) { logger.warn("Health check attempt ${attempt + 1}/$total failed: status ${response.statusCode()}") throw IllegalStateException("Health check returned unexpected status ${response.statusCode()} from ${strategy.url}") } logger.info("Health check passed for ${strategy.url} (status: ${response.statusCode()})") } } private suspend fun checkTcp(strategy: ReadinessStrategy.TcpPort) { retryUntilReady(strategy.retries, strategy.retryDelay, "TCP port ${strategy.port} did not open after ${strategy.retries} attempts") { attempt, total -> runCatching { Socket().use { socket -> socket.connect(InetSocketAddress("localhost", strategy.port), TCP_CONNECT_TIMEOUT_MS) } }.onFailure { logger.debug("TCP check attempt ${attempt + 1}/$total on port ${strategy.port} failed: ${it.message}") }.getOrThrow() logger.info("TCP port ${strategy.port} is open after ${attempt + 1} attempts") } } private suspend fun checkProbe(strategy: ReadinessStrategy.Probe) { retryUntilReady(strategy.retries, strategy.retryDelay, "Readiness probe did not pass after ${strategy.retries} attempts") { attempt, total -> val ready = runCatching { strategy.check() }.onFailure { logger.debug("Readiness probe attempt ${attempt + 1}/$total threw: ${it.message}") }.getOrThrow() if (!ready) { logger.debug("Readiness probe attempt ${attempt + 1}/$total returned false") error("Probe returned false") } logger.info("Readiness probe passed after ${attempt + 1} attempts") } } /** * Retries [attempt] up to [retries] times with [retryDelay] between attempts. * The [attempt] block should return normally on success or throw on failure. */ private suspend fun retryUntilReady( retries: Int, retryDelay: Duration, errorMessage: String, attempt: suspend (index: Int, total: Int) -> Unit ) { var lastException: Throwable? = null repeat(retries) { index -> runCatching { attempt(index, retries) }.onSuccess { return }.onFailure { lastException = it } if (index < retries - 1) { delay(retryDelay) } } throw IllegalStateException(errorMessage, lastException as? Exception) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/ReadinessStrategy.kt ================================================ package com.trendyol.stove.system import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds private const val DEFAULT_RETRIES = 30 private const val DEFAULT_HEALTH_CHECK_RETRIES = 10 private const val HTTP_OK = 200 /** * Protocol-agnostic readiness checking strategy for applications under test. * * Determines how Stove verifies that an application is ready to accept * requests before running tests. Supports HTTP, TCP, custom probes, * and fixed delays. * * ## Usage * * ```kotlin * // HTTP health check (REST APIs) * ReadinessStrategy.HttpGet(url = "http://localhost:8080/health") * * // TCP port check (gRPC, raw TCP) * ReadinessStrategy.TcpPort(port = 50051) * * // Custom probe (file existence, DB query, etc.) * ReadinessStrategy.Probe { File("/tmp/ready").exists() } * * // Fixed delay (simple workers) * ReadinessStrategy.FixedDelay(3.seconds) * ``` * * @see ReadinessChecker */ sealed interface ReadinessStrategy { /** * Poll an HTTP GET endpoint until it returns an expected status code. * * Best for REST APIs and web servers that expose a health endpoint. * * @param url The health check endpoint URL (e.g., "http://localhost:8080/health"). * @param timeout Maximum time to wait for each HTTP request. * @param retries Number of retry attempts before giving up. * @param retryDelay Delay between retry attempts. * @param expectedStatusCodes HTTP status codes considered healthy. */ data class HttpGet( val url: String, val timeout: Duration = 30.seconds, val retries: Int = DEFAULT_HEALTH_CHECK_RETRIES, val retryDelay: Duration = 1.seconds, val expectedStatusCodes: Set = setOf(HTTP_OK) ) : ReadinessStrategy { init { require(url.isNotBlank()) { "Health check URL must not be blank" } require(retries > 0) { "retries must be positive, got $retries" } require(timeout.isPositive()) { "timeout must be positive, got $timeout" } require(!retryDelay.isNegative()) { "retryDelay must not be negative, got $retryDelay" } } } /** * Try to open a TCP connection to a port until it succeeds. * * Best for gRPC servers, raw TCP servers, and any process that listens * on a port but doesn't expose an HTTP health endpoint. * * @param port The TCP port to connect to. * @param retries Number of connection attempts before giving up. * @param retryDelay Delay between connection attempts. */ data class TcpPort( val port: Int, val retries: Int = DEFAULT_RETRIES, val retryDelay: Duration = 1.seconds ) : ReadinessStrategy /** * Execute a user-provided probe function until it returns `true`. * * Best for processes with non-standard readiness signals (file existence, * database state, custom protocol, etc.). * * @param retries Number of probe attempts before giving up. * @param retryDelay Delay between probe attempts. * @param check Suspend function that returns `true` when the process is ready. */ data class Probe( val retries: Int = DEFAULT_RETRIES, val retryDelay: Duration = 1.seconds, val check: suspend () -> Boolean ) : ReadinessStrategy /** * Wait a fixed duration before considering the process ready. * * Fallback for simple workers that don't expose any readiness signal. * * @param delay Duration to wait. */ data class FixedDelay( val delay: Duration = 2.seconds ) : ReadinessStrategy } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/Runner.kt ================================================ package com.trendyol.stove.system /** * Alias for runner of system under test */ typealias Runner = (Array) -> TContext ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/Stove.kt ================================================ package com.trendyol.stove.system import arrow.core.* import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.* import com.trendyol.stove.system.Stove.Companion.instance import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.* import org.slf4j.* import kotlin.reflect.KClass /** * Entrance of entire Stove test system. * Expects an url and port combination that are available also in the configuration of System Under Test. * For example; if your Spring application starts at :8081 then you need to change httpClient.baseUrl to `http://localhost:8081` * * Stove should be initialized only once for project, because it will start all the dependencies you plugged into it. * See also: [PluggedSystem] * * As a full example of Stove: * ```kotlin * Stove { * if (this.isRunningLocally()) { * enableReuseForTestContainers() * keepDependenciesRunning() * } * }.with { * httpClient { * HttpClientSystemOptions( * baseUrl = "http://localhost:8080", * ) * } * bridge() * postgresql { * PostgresqlOptions(configureExposedConfiguration = { cfg -> * listOf( * "database.jdbcUrl=${cfg.jdbcUrl}", * "database.host=${cfg.host}", * "database.port=${cfg.port}", * "database.name=${cfg.database}", * "database.username=${cfg.username}", * "database.password=${cfg.password}" * ) * }) * } * kafka { * stoveKafkaObjectMapperRef = objectMapperRef * KafkaSystemOptions { * listOf( * "kafka.bootstrapServers=${it.bootstrapServers}", * "kafka.interceptorClasses=${it.interceptorClass}" * ) * } * } * wiremock { * WireMockSystemOptions( * port = 9090, * removeStubAfterRequestMatched = true, * afterRequest = { e, _ -> * logger.info(e.request.toString()) * } * ) * } * ktor( * withParameters = listOf( * "port=8080" * ), * runner = { parameters -> * stove.ktor.example.run(parameters) { * addTestSystemDependencies() * } * } * ) * }.run() * ``` */ @StoveDsl class Stove( configure: @StoveDsl StoveOptionsDsl.() -> Unit = {} ) : ReadyStove, AutoCloseable { private val optionsDsl: StoveOptionsDsl = StoveOptionsDsl() init { configure(optionsDsl) } private var cleanup: MutableList<(suspend () -> Unit)> = mutableListOf() @PublishedApi internal val activeSystems: MutableMap, PluggedSystem> = mutableMapOf() @PublishedApi internal val keyedSystems: MutableMap, SystemKey>, PluggedSystem> = mutableMapOf() private lateinit var applicationUnderTest: ApplicationUnderTest<*> private val logger: Logger = LoggerFactory.getLogger(javaClass) @PublishedApi internal val options: StoveOptions = optionsDsl.options internal val reporter: StoveReporter = StoveReporter(isEnabled = options.reportingEnabled) /** * Returns all registered systems from both default and keyed registrations. * This is the single source of truth used by lifecycle, reporting, and cleanup. */ fun allRegisteredSystems(): Collection = activeSystems.values + keyedSystems.values /** * Returns all registered systems that implement the given type. * Includes both default and keyed systems. */ inline fun systemsOf(): List = allRegisteredSystems().filterIsInstance() /** * Returns a read-only snapshot of all registered systems. */ fun allSystems(): Collection = allRegisteredSystems() /** * Whether dependencies (containers) should be kept running after tests complete. */ val keepDependenciesRunning: Boolean get() = options.keepDependenciesRunning /** * Whether migrations should always run, even when reusing containers. */ val runMigrationsAlways: Boolean get() = options.runMigrationsAlways /** * Creates a state storage for the given system and configuration types. */ inline fun createStateStorage(): StateStorage = options.createStateStorage() /** * Creates a keyed state storage for the given system and configuration types. * The key name is included in the storage path to prevent collisions. */ inline fun createStateStorage( key: SystemKey ): StateStorage = options.stateStorageFactory.createWithKey(options, TSystem::class, TState::class, keyDisplayName(key)) /** * Creates a state storage with an optional key name for disambiguation. * When keyName is null, behaves identically to the no-arg version. */ inline fun createStateStorage( keyName: String? ): StateStorage = if (keyName != null) { options.stateStorageFactory.createWithKey(options, TSystem::class, TState::class, keyName) } else { options.createStateStorage() } /** * Registers a listener to receive report events. */ fun addReportListener(listener: ReportEventListener) = reporter.addListener(listener) /** * Removes a previously registered report event listener. */ fun removeReportListener(listener: ReportEventListener) = reporter.removeListener(listener) /** * Starts tracking a new test in the reporter. */ fun startTest(ctx: StoveTestContext) = reporter.startTest(ctx) /** * Records a report entry in the current test. */ fun recordReport(entry: ReportEntry) = reporter.record(entry) /** * Ends tracking the current test in the reporter. */ fun endTest() = reporter.endTest() companion object { /** * [instance] is created only once per project, and it is available throughout the lifetime of the all the tests. * DO NOT access it before [run] completes */ internal lateinit var instance: Stove /** * Check if Stove instance has been initialized. */ fun instanceInitialized(): Boolean = ::instance.isInitialized fun reporter(): StoveReporter { check(::instance.isInitialized) { "Stove is not initialized yet, do not forget to call Stove#run" } return instance.reporter } fun options(): StoveOptions { check(::instance.isInitialized) { "Stove is not initialized yet, do not forget to call Stove#run" } return instance.options } @Suppress("UNCHECKED_CAST") fun getSystem(kClass: KClass<*>): T { check(::instance.isInitialized) { "Stove is not initialized yet, do not forget to call Stove#run" } return instance.getSystemOrThrow(kClass) as T } /** * Returns the system of the given type as an Option. * Returns None if Stove is not initialized or the system is not registered. */ inline fun getSystemOrNone(): Option = getSystemOrNone(T::class) /** * Returns the system of the given type as an Option. * Returns None if Stove is not initialized or the system is not registered. */ @Suppress("UNCHECKED_CAST") @PublishedApi internal fun getSystemOrNone(kClass: KClass): Option { if (!::instance.isInitialized) return None return instance.activeSystems.getOrNone(kClass).map { it as T } } fun stop(): Unit = instance.close() } /** * Application under test, the tests run against the application provided. * Usually a spring or generic application that can be hosted */ fun applicationUnderTest(applicationUnderTest: ApplicationUnderTest<*>): Stove { this.applicationUnderTest = applicationUnderTest return this } internal fun getSystemOrThrow( kClass: KClass<*> ): PluggedSystem = activeSystems[kClass] ?: error("System of type ${kClass.simpleName} is not registered in Stove") private lateinit var applicationUnderTestContext: Any /** * Runs the entire dependency tree that implements [RunnableSystemWithContext] since only the [RunnableSystemWithContext] can be run. * Note that all the dependencies will run as parallel. * It will invoke the runnable methods of [RunnableSystemWithContext]s with the order: * - [RunnableSystemWithContext.beforeRun] * - [RunnableSystemWithContext.run] * - [RunnableSystemWithContext.afterRun] */ override suspend fun run() { coroutineScope { val allSystems = allRegisteredSystems() allSystems.filterIsInstance() .map { async(context = Dispatchers.IO) { it.beforeRun() } }.awaitAll() allSystems.filterIsInstance() .map { async(context = Dispatchers.IO) { it.run() } }.awaitAll() val dependencyConfigurations = allSystems .filterIsInstance() .flatMap { it.configuration() } applicationUnderTestContext = applicationUnderTest.start(dependencyConfigurations) allSystems.filterIsInstance() .map { async(context = Dispatchers.IO) { it.afterRun() } }.awaitAll() // Cleanup is handled by registerForDispose — no duplication here cleanup.add { applicationUnderTest.stop() } } instance = this } /** * Enables the DSL for constructing the entire system with the [PluggedSystem]s. * * Example: * ```kotlin * Stove().with { * httpClient{ * // configure the http client * } * kafka{ * // configure kafka * } * couchbase { * // configure couchbase * } * * // and so on... * } * ``` */ fun with(withDsl: WithDsl.() -> Unit): Stove { withDsl(WithDsl(this)) return this } /** * Gets or registers a [PluggedSystem] to Stove. Use it when you want to register a new [PluggedSystem] to Stove. * That can be a system that comply your needs, for example; SchedulerSystem, GarbageCollectorSystem etc... These are only the names, * so, you can implement these systems and register to the Test suite. When you register a new system to the test suite, it is wise to * implement [AfterRunAwareWithContext.afterRun] to get the context/container of the system, * so you can create your system methods based on that. * * Example: * ```kotlin * // plug the new system called scheduler * Stove().withScheduler() * * // use it in testing * stove.scheduler().advance() * ``` */ inline fun getOrRegister(system: T): T = activeSystems.getOrPut(T::class) { registerForDispose(system) } as T /** * Gets or registers a keyed [PluggedSystem]. Multiple instances of the same system type * can coexist when registered with different [SystemKey]s. * * @see SystemKey */ inline fun getOrRegister( key: SystemKey, system: T ): T = keyedSystems.getOrPut(T::class to key) { registerForDispose(system) } as T /** * Gets the registered system or returns [None] */ inline fun getOrNone(): Option = activeSystems.getOrNone(T::class).map { it as T } /** * Gets the keyed registered system or returns [None] */ inline fun getOrNone(key: SystemKey): Option = keyedSystems.getOrNone(T::class to key).map { it as T } fun registerForDispose(closeable: T): T { cleanup.add { closeable.close() } return closeable } @Suppress("UNCHECKED_CAST", "unused") fun applicationUnderTestContext(): TContext = applicationUnderTestContext as TContext override fun close(): Unit = runBlocking { Try { if (options.dumpReportOnStop && options.reportingEnabled) { // Only dump report if there are failures val report = reporter.dumpIfFailed(options.defaultRenderer) if (report.isNotEmpty()) { logger.info("=== Stove Test Report (Failures Detected) ===") logger.info(report) } } cleanup.forEach { it() } }.recover { logger.warn("got an error while stopping Stove: ${it.message}") } } } /** * Main entry point for test validation. Use this DSL to write assertions against your system. * * This is the primary way to interact with Stove in your tests. The DSL provides * access to all registered systems (HTTP, Kafka, databases, etc.) for assertions. * * ## Example * * ```kotlin * @Test * fun `should create user and publish event`() = runTest { * stove { * http { * post("/users", request) { response -> * response.id shouldNotBe null * } * } * kafka { * shouldBePublished { * actual.userId == expectedUserId * } * } * couchbase { * shouldGet("users", "user-123") { user -> * user.name shouldBe "John" * } * } * } * } * ``` * * @param validation The DSL block containing test assertions. * @throws IllegalStateException if Stove has not been initialized via [Stove.run]. * @see ValidationDsl */ suspend fun stove( validation: @StoveDsl suspend ValidationDsl.() -> Unit ) { check(Stove.instanceInitialized()) { "Stove is not initialized yet, do not forget to call Stove#run" } validation(ValidationDsl(instance)) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/StoveOptions.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.reporting.JsonReportRenderer import com.trendyol.stove.reporting.PrettyConsoleRenderer import com.trendyol.stove.reporting.ReportRenderer import com.trendyol.stove.system.abstractions.* data class StoveOptions( val keepDependenciesRunning: Boolean = false, val stateStorageFactory: StateStorageFactory = StateStorageFactory.Default(), val runMigrationsAlways: Boolean = false, val reportingEnabled: Boolean = true, val dumpReportOnTestFailure: Boolean = true, val dumpReportOnStop: Boolean = false, val defaultRenderer: ReportRenderer = PrettyConsoleRenderer, val failureRenderer: ReportRenderer = PrettyConsoleRenderer, val fileRenderer: ReportRenderer = JsonReportRenderer, val reportToConsole: Boolean = true, val reportToFile: Boolean = false, val reportFilePath: String = "build/stove-reports" ) { inline fun createStateStorage(): StateStorage = (this.stateStorageFactory(this, TSystem::class, TState::class)) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/StoveOptionsDsl.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.reporting.ReportRenderer import com.trendyol.stove.system.abstractions.StateStorageFactory import com.trendyol.stove.system.annotations.StoveDsl import org.slf4j.LoggerFactory /** * DSL for configuring [StoveOptions]. * * Example: * ```kotlin * Stove { * if (isRunningLocally()) { * enableReuseForTestContainers() * keepDependenciesRunning() * } * reporting { * enabled() * dumpOnFailure() * } * } * ``` */ @StoveDsl class StoveOptionsDsl { private val logger = LoggerFactory.getLogger(javaClass) private val propertiesFile = PropertiesFile() internal var options = StoveOptions() private set // ═══════════════════════════════════════════════════════════════════════════ // Container & Environment // ═══════════════════════════════════════════════════════════════════════════ /** * Keep dependencies (containers) running after tests complete. * Requires `.testcontainers.properties` file - call [enableReuseForTestContainers] first. */ fun keepDependenciesRunning(): StoveOptionsDsl = apply { logger.info( """ |You have chosen to keep dependencies running. |For that Stove needs '.testcontainers.properties' file under your user(~/). |To add that call 'enableReuseForTestContainers()' method """.trimMargin() ) propertiesFile.detectAndLogStatus() options = options.copy(keepDependenciesRunning = true) } /** * Check if tests are running locally (not on CI). */ fun isRunningLocally(): Boolean = !isRunningOnCI() /** * Enable container reuse in TestContainers by creating the required properties file. */ fun enableReuseForTestContainers(): Unit = propertiesFile.enable() /** * Configure custom state storage factory. */ fun stateStorage(factory: StateStorageFactory): StoveOptionsDsl = apply { options = options.copy(stateStorageFactory = factory) } /** * Always run migrations, even if the database state hasn't changed. */ fun runMigrationsAlways(): StoveOptionsDsl = apply { options = options.copy(runMigrationsAlways = true) } // ═══════════════════════════════════════════════════════════════════════════ // Reporting Configuration // ═══════════════════════════════════════════════════════════════════════════ /** * Configure reporting options using the [ReportingDsl]. * * Example: * ```kotlin * reporting { * enabled() * dumpOnFailure() * } * ``` */ fun reporting(configure: ReportingDsl.() -> Unit): StoveOptionsDsl = apply { ReportingDsl(this).configure() } /** Enable reporting. */ fun reportingEnabled(enabled: Boolean = true): StoveOptionsDsl = apply { options = options.copy(reportingEnabled = enabled) } /** Dump report on test failure. */ fun dumpReportOnTestFailure(enabled: Boolean = true): StoveOptionsDsl = apply { options = options.copy(dumpReportOnTestFailure = enabled) } /** Set the renderer used for test failure reports. */ fun failureRenderer(renderer: ReportRenderer): StoveOptionsDsl = apply { options = options.copy(failureRenderer = renderer) } private fun isRunningOnCI(): Boolean = CI_ENV_VARS.any { System.getenv(it) == "true" } companion object { private val CI_ENV_VARS = listOf("CI", "GITLAB_CI", "GITHUB_ACTIONS") } } /** * DSL for configuring reporting options in a grouped, fluent manner. */ @StoveDsl class ReportingDsl( private val parent: StoveOptionsDsl ) { /** Enable reporting. */ fun enabled(value: Boolean = true) = parent.reportingEnabled(value) /** Disable reporting. */ fun disabled() = enabled(false) /** Dump report on test failure. */ fun dumpOnFailure(value: Boolean = true) = parent.dumpReportOnTestFailure(value) /** Set the failure renderer. */ fun failureRenderer(renderer: ReportRenderer) = parent.failureRenderer(renderer) } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/ValidationDsl.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.system.abstractions.PluggedSystem import com.trendyol.stove.system.annotations.StoveDsl /** * The DSL wrapper for writing test validations against registered [PluggedSystem]s. * * This class provides the entry point for all test assertions and validations. * It wraps [Stove] and exposes extension functions for each registered system * (HTTP, Kafka, Couchbase, PostgreSQL, etc.). * * ## Usage * * Use [Stove.stove] to access the validation DSL: * * ```kotlin * Stove.stove { * // HTTP assertions * http { * get("/users/123") { user -> * user.name shouldBe "John" * } * } * * // Kafka assertions * kafka { * shouldBePublished { * actual.userId == "123" * } * } * * // Database assertions using Bridge * using { * findById("123").name shouldBe "John" * } * * // Couchbase assertions * couchbase { * shouldGet("users", "user-123") { user -> * user.name shouldBe "John" * } * } * } * ``` * * ## Available System DSLs * * Each registered system provides its own DSL extension: * - `http { }` - HTTP client assertions * - `kafka { }` - Kafka publish/consume assertions * - `couchbase { }` - Couchbase document assertions * - `postgresql { }` / `mssql { }` - Database assertions * - `elasticsearch { }` - Elasticsearch document assertions * - `mongodb { }` - MongoDB document assertions * - `wiremock { }` - WireMock stub setup * - `using { }` - Bridge to application's DI container * * @property stove The underlying Stove instance containing all registered systems. * @see stove * @see PluggedSystem */ @JvmInline @StoveDsl value class ValidationDsl( val stove: Stove ) ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/WithDsl.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl /** * The DSL wrapper for constructing and configuring the test system with [PluggedSystem]s. * * This class provides the entry point for registering all components that your tests need: * databases, message brokers, HTTP clients, mock servers, and the application under test. * * ## Usage * * Use [Stove.with] to access the configuration DSL: * * ```kotlin * Stove() * .with { * // Configure HTTP client * httpClient { * HttpClientSystemOptions(baseUrl = "http://localhost:8080") * } * * // Configure Kafka * kafka { * KafkaSystemOptions { * listOf("kafka.bootstrapServers=${it.bootstrapServers}") * } * } * * // Configure PostgreSQL * postgresql { * PostgresqlOptions(configureExposedConfiguration = { cfg -> * listOf( * "spring.datasource.url=${cfg.jdbcUrl}", * "spring.datasource.username=${cfg.username}", * "spring.datasource.password=${cfg.password}" * ) * }) * } * * // Configure WireMock for external service mocking * wiremock { * WireMockSystemOptions(port = 9090) * } * * // Enable Bridge for DI container access * bridge() * * // Configure the application under test * springBoot( * runner = { params -> myApp.run(params) }, * withParameters = listOf("server.port=8080") * ) * } * .run() * ``` * * ## Available System Configurations * * Each system provides its own configuration function: * - `httpClient { }` - HTTP client for API testing * - `kafka { }` - Apache Kafka for messaging * - `couchbase { }` - Couchbase database * - `postgresql { }` - PostgreSQL database * - `mssql { }` - Microsoft SQL Server database * - `mongodb { }` - MongoDB database * - `elasticsearch { }` - Elasticsearch search engine * - `redis { }` - Redis cache * - `wiremock { }` - WireMock for HTTP mocking * - `bridge()` - Bridge to application's DI container * - `springBoot { }` / `ktor { }` - Application under test * * @property stove The underlying Stove instance being configured. * @see Stove.with * @see PluggedSystem */ @JvmInline @StoveDsl value class WithDsl( val stove: Stove ) { /** * Registers the application under test with Stove. * * This is typically called by framework-specific functions like `springBoot()` or `ktor()`. * You generally don't need to call this directly. * * @param applicationUnderTest The application to be tested. */ fun applicationUnderTest(applicationUnderTest: ApplicationUnderTest<*>) { this.stove.applicationUnderTest(applicationUnderTest) } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/ApplicationUnderTest.kt ================================================ package com.trendyol.stove.system.abstractions /** * Interface representing the application being tested by Stove. * * This is the entry point for your actual application. Stove starts this application * after all test infrastructure (databases, message brokers, etc.) is running, * passing the exposed configurations so your app can connect to the test infrastructure. * * ## Framework Implementations * * Stove provides implementations for popular frameworks: * - **Spring Boot**: `SpringApplicationUnderTest` * - **Ktor**: `KtorApplicationUnderTest` * - **Micronaut**: `MicronautApplicationUnderTest` * * ## Spring Boot Example * * ```kotlin * Stove() * .with { * postgresql { /* config */ } * kafka { /* config */ } * * springBoot( * runner = { params -> * com.example.MyApplication.run(params) { * addTestDependencies() // Optional test-specific beans * } * }, * withParameters = listOf( * "server.port=8080", * "logging.level.root=WARN" * ) * ) * } * .run() * ``` * * ## Ktor Example * * ```kotlin * Stove() * .with { * mongodb { /* config */ } * * ktor( * withParameters = listOf("port=8080"), * runner = { params -> * com.example.main(params) { * addTestModules() * } * } * ) * } * .run() * ``` * * ## Configuration Flow * * 1. [TestSystem] starts all [PluggedSystem]s * 2. Systems expose their configuration via [ExposesConfiguration] * 3. Configurations are collected and passed to [start] * 4. Your application starts with access to test infrastructure * 5. [AfterRunAware.afterRun] is called on all systems * * @param TContext The application context type (e.g., `ApplicationContext` for Spring, * `Application` for Ktor). * @see TestSystem * @see ExposesConfiguration * @author Oguzhan Soykan */ interface ApplicationUnderTest { /** * Starts the application with the provided configurations. * * The [configurations] list contains properties from all [ExposesConfiguration] * implementors plus any parameters specified in the test setup. These are typically * passed as system properties or command-line arguments. * * ## Example Configuration List * * ```kotlin * // configurations parameter might contain: * listOf( * "spring.datasource.url=jdbc:postgresql://localhost:32789/test", * "spring.datasource.username=test", * "spring.kafka.bootstrap-servers=localhost:32790", * "server.port=8080" * ) * ``` * * @param configurations Combined list of infrastructure configs and test parameters. * @return The application context for use by [AfterRunAwareWithContext] systems. */ suspend fun start(configurations: List): TContext /** * Stops the application gracefully. * * Called during [TestSystem] shutdown to clean up application resources. */ suspend fun stop() } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/Exceptions.kt ================================================ package com.trendyol.stove.system.abstractions import kotlin.reflect.KClass /** * @author Oguzhan Soykan */ class SystemNotRegisteredException( system: KClass<*>, detail: String? = null ) : Throwable( "${system.simpleName} was not registered. " + (detail ?: "Make sure that you registered your service on TestSystem") ) /** * @author Oguzhan Soykan */ class SystemConfigurationException( system: KClass<*>, reason: String ) : Throwable( "${system.simpleName} configuration got an error: $reason" ) class SystemNotInitializedException( system: KClass<*> ) : Throwable( "${system.simpleName} was not initialized. Make sure that you initialized your service on TestSystem" ) ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/ExposesConfiguration.kt ================================================ package com.trendyol.stove.system.abstractions /** * Interface for systems that expose configuration to the application under test. * * When a [PluggedSystem] starts (e.g., a database container), it knows its runtime configuration * (ports, credentials, URLs). This interface allows the system to expose that configuration * so it can be passed to the application under test during startup. * * ## How It Works * * 1. [TestSystem] starts all registered systems via [RunAware.run] * 2. After systems are running, [TestSystem] collects configuration from all [ExposesConfiguration] implementors * 3. The collected configuration is passed to [ApplicationUnderTest.start] * 4. Your application receives these as system properties/environment variables * * ## Example Implementation * * ```kotlin * class KafkaSystem( * override val stove: Stove, * private val options: KafkaSystemOptions * ) : PluggedSystem, RunAware, ExposesConfiguration { * * private lateinit var container: KafkaContainer * private lateinit var exposedConfig: KafkaExposedConfiguration * * override suspend fun run() { * container = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0")) * container.start() * exposedConfig = KafkaExposedConfiguration( * bootstrapServers = container.bootstrapServers * ) * } * * override fun configuration(): List = * options.configureExposedConfiguration(exposedConfig) * // Returns: ["spring.kafka.bootstrap-servers=localhost:32789"] * } * ``` * * ## Configuration Format * * The returned list typically contains `key=value` strings that match your application's * expected configuration format: * * ```kotlin * // Spring Boot style * listOf( * "spring.datasource.url=jdbc:postgresql://localhost:5432/test", * "spring.datasource.username=test" * ) * * // Generic style * listOf( * "DATABASE_URL=jdbc:postgresql://localhost:5432/test", * "DATABASE_USER=test" * ) * ``` * * @see PluggedSystem * @see RunAware * @see ConfiguresExposedConfiguration * @see ExposedConfiguration * @author Oguzhan Soykan */ interface ExposesConfiguration { /** * Returns the configuration properties exposed by this system. * * This method is called after [RunAware.run] completes, so containers * are running and their runtime information (ports, hosts) is available. * * @return A list of configuration strings, typically in `key=value` format. */ fun configuration(): List } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/PluggedSystem.kt ================================================ package com.trendyol.stove.system.abstractions import com.trendyol.stove.system.Stove /** * Base interface for all systems that can be plugged into [Stove]. * * A PluggedSystem represents a testable component such as: * - **Databases**: PostgreSQL, MongoDB, Couchbase, Elasticsearch, MSSQL, Redis * - **Message Brokers**: Kafka * - **HTTP**: HTTP client, WireMock * - **Bridge**: Access to the application's DI container * - **Custom Systems**: Any domain-specific system you implement * * ## Implementing a Custom PluggedSystem * * To create a custom system, implement this interface along with the appropriate * lifecycle interfaces ([RunAware], [AfterRunAware], [ExposesConfiguration]): * * ```kotlin * class MyCustomSystem( * override val stove: Stove, * private val options: MyCustomSystemOptions * ) : PluggedSystem, RunAware, AfterRunAware { * * private lateinit var client: MyClient * * override suspend fun run() { * // Initialize your system (e.g., start container, connect to service) * client = MyClient(options.connectionString) * } * * override suspend fun afterRun() { * // Called after the application under test has started * // Useful for setup that requires the app to be running * } * * override fun close() { * // Cleanup resources * client.close() * } * * // Chainable method for DSL * override fun then(): Stove = stove * * // Custom DSL methods * fun doSomething(): MyCustomSystem { * client.execute() * return this * } * } * ``` * * ## Registering Your System * * Create extension functions for easy DSL usage: * * ```kotlin * // Registration function * fun WithDsl.myCustomSystem( * configure: () -> MyCustomSystemOptions * ): Stove = stove.getOrRegister( * MyCustomSystem(stove, configure()) * ).let { stove } * * // Validation DSL function * suspend fun ValidationDsl.myCustom( * block: suspend MyCustomSystem.() -> Unit * ) = block(stove.getOrNone().getOrElse { * throw SystemNotRegisteredException(MyCustomSystem::class) * }) * ``` * * @see Stove * @see RunAware * @see AfterRunAware * @see ExposesConfiguration * @see ThenSystemContinuation * @author Oguzhan Soykan */ interface PluggedSystem : AutoCloseable, ThenSystemContinuation ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/ReadyStove.kt ================================================ package com.trendyol.stove.system.abstractions /** * Marks the [com.trendyol.stove.system.Stove] as ready after it is started. * @author Oguzhan Soykan */ interface ReadyStove { suspend fun run() } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/RunnableSystemWithContext.kt ================================================ package com.trendyol.stove.system.abstractions import com.trendyol.stove.functional.* import kotlinx.coroutines.runBlocking import org.slf4j.* /** * Lifecycle interface for systems that need to perform setup before starting. * * Implement this when your system needs to prepare resources before the main * [RunAware.run] phase. This is called before any systems are started. * * ## Example Use Cases * - Pulling Docker images ahead of time * - Validating configuration * - Creating network resources * * ```kotlin * class MySystem(...) : PluggedSystem, BeforeRunAware, RunAware { * override suspend fun beforeRun() { * // Download required files, validate config, etc. * validateConfiguration() * } * * override suspend fun run() { * // Start the actual system * } * } * ``` * * @see RunAware * @see AfterRunAware * @author Oguzhan Soykan */ interface BeforeRunAware { /** * Called before any systems are started. * * Use this for early initialization that doesn't depend on other systems. */ suspend fun beforeRun() } /** * Core lifecycle interface for systems that can be started and stopped. * * This is the main lifecycle interface for [PluggedSystem]s. Most systems * implement this to start containers, establish connections, or initialize resources. * * ## Lifecycle Order * * 1. [BeforeRunAware.beforeRun] - All systems (parallel) * 2. [RunAware.run] - All systems (parallel) * 3. Application under test starts * 4. [AfterRunAware.afterRun] - All systems (parallel) * * ## Example * * ```kotlin * class PostgresqlSystem(...) : PluggedSystem, RunAware { * private lateinit var container: PostgreSQLContainer<*> * * override suspend fun run() { * container = PostgreSQLContainer("postgres:15") * .withDatabaseName("test") * container.start() * } * * override suspend fun stop() { * container.stop() * } * } * ``` * * @see BeforeRunAware * @see AfterRunAware * @author Oguzhan Soykan */ interface RunAware { /** * Starts the system. * * This is called in parallel for all registered systems. * Start containers, establish connections, or initialize resources here. */ suspend fun run() /** * Stops the system. * * Called during [TestSystem] shutdown. Clean up resources here. */ suspend fun stop() } /** * Lifecycle interface for systems that need the application context after startup. * * This is used by systems like [BridgeSystem] that need access to the * application's DI container after the application has started. * * ## Example * * ```kotlin * class SpringBridgeSystem(stove: Stove) : * BridgeSystem(stove) { * * override suspend fun afterRun(context: ApplicationContext) { * // Now we have access to Spring's ApplicationContext * this.ctx = context * } * * override fun get(klass: KClass): D = * ctx.getBean(klass.java) * } * ``` * * @param TContext The type of application context (e.g., `ApplicationContext` for Spring) * @see AfterRunAware * @see BridgeSystem * @author Oguzhan Soykan */ interface AfterRunAwareWithContext { /** * Called after the application under test has started. * * @param context The application context from the started application. */ suspend fun afterRun(context: TContext) } /** * Lifecycle interface for systems that need to perform actions after the application starts. * * Implement this when your system needs to do something after the application * under test is running, but doesn't need direct access to the application context. * * ## Example Use Cases * - Running database migrations * - Seeding test data * - Verifying connectivity * * ```kotlin * class PostgresqlSystem(...) : PluggedSystem, RunAware, AfterRunAware { * override suspend fun afterRun() { * // Run migrations after app has started * migrations.forEach { it.execute(connection) } * } * } * ``` * * @see AfterRunAwareWithContext * @see RunAware * @author Oguzhan Soykan */ interface AfterRunAware { /** * Called after the application under test has started. */ suspend fun afterRun() } /** * Combined lifecycle interface for systems with full lifecycle support and context access. * * This interface combines [BeforeRunAware], [RunAware], and [AfterRunAwareWithContext] * for systems that need complete lifecycle control and access to the application context. * * Most systems don't need this full interface; use individual interfaces instead. * * @param TContext The type of application context. * @see BeforeRunAware * @see RunAware * @see AfterRunAwareWithContext * @author Oguzhan Soykan */ interface RunnableSystemWithContext : AutoCloseable, BeforeRunAware, RunAware, AfterRunAwareWithContext { private val logger: Logger get() = LoggerFactory.getLogger(javaClass) override fun close(): Unit = runBlocking { Try { stop() }.recover { logger.warn("got an error while stopping") } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/StateStorage.kt ================================================ @file:Suppress("FunctionName") package com.trendyol.stove.system.abstractions import com.fasterxml.jackson.module.kotlin.readValue import com.trendyol.stove.serialization.* import com.trendyol.stove.system.* import org.slf4j.* import java.nio.file.* import java.util.* import kotlin.io.path.* import kotlin.reflect.KClass interface StateStorage { suspend fun capture(start: suspend () -> TState): TState fun isSubsequentRun(): Boolean } interface StateStorageFactory { operator fun invoke(options: StoveOptions, system: KClass<*>, state: KClass): StateStorage /** * Creates a state storage with an optional key name to prevent collisions * when multiple instances of the same system type are registered. * Default implementation delegates to [invoke], ignoring the key. */ fun createWithKey( options: StoveOptions, system: KClass<*>, state: KClass, keyName: String? ): StateStorage = invoke(options, system, state) companion object { fun Default(): StateStorageFactory = DefaultStateStorageFactory() private fun DefaultStateStorageFactory(): StateStorageFactory = object : StateStorageFactory { override fun invoke(options: StoveOptions, system: KClass<*>, state: KClass): StateStorage = DefaultStateStorage(options, system, state) override fun createWithKey( options: StoveOptions, system: KClass<*>, state: KClass, keyName: String? ): StateStorage = FileSystemStorage(options, system, state, keyName) } } fun DefaultStateStorage( options: StoveOptions, system: KClass<*>, state: KClass ): StateStorage = FileSystemStorage(options, system, state) } /** * Represents the state of [Stove] which is being captured. * @param TState the type of the state * @param state the state of [Stove] * @param processId the process id of [Stove] */ data class StateWithProcess( val state: TState, val processId: Long ) internal class FileSystemStorage( val options: StoveOptions, val system: KClass<*>, private val state: KClass, private val keyName: String? = null ) : StateStorage { private val folderForSystem = Paths.get( System.getProperty("java.io.tmpdir"), "com.trendyol.stove" ) private val pathForSystem: Path = folderForSystem.resolve( "stove-e2e-${system.simpleName!!.lowercase(Locale.ROOT)}" + (keyName?.let { "-${it.replace(UNSAFE_FILENAME_CHARS, "-").lowercase(Locale.ROOT)}" } ?: "") + ".lock" ) private val j = StoveSerde.jackson.default private val l: Logger = LoggerFactory.getLogger(javaClass) init { if (!folderForSystem.exists()) { folderForSystem.createDirectories() } } /** * Captures Stove state into the file system. Basically creates a Json file which contains the state of the [PluggedSystem] * that is run by [Stove]. */ override suspend fun capture(start: suspend () -> TState): TState = when { !options.keepDependenciesRunning -> { l.info("State for ${name()} is being deleted at the path: ${pathForSystem.absolutePathString()}") pathForSystem.deleteIfExists() start() } pathForSystem.exists() && options.keepDependenciesRunning -> { recover(otherwise = start) } !pathForSystem.exists() && options.keepDependenciesRunning -> { saveStateForNextRun(start()) } else -> { pathForSystem.deleteIfExists() start() } } /** * Returns true if [Stove] is being run for the first time. */ override fun isSubsequentRun(): Boolean = pathForSystem.exists() && options.keepDependenciesRunning && isDifferentProcess() /** * Recovers the state of [Stove] from the file system. */ private suspend fun recover(otherwise: suspend () -> TState): TState = when { pathForSystem.exists() -> { l.info("State exists for ${name()}. System is being recovered from: ${pathForSystem.absolutePathString()}") val swp = j.readValue>(pathForSystem.readBytes()) j.convertValue(swp.state, state.java) } else -> { saveStateForNextRun(otherwise()) } } private fun saveStateForNextRun(state: TState): TState = state.also { l.info("State does not exist for ${name()}. System is being saved to: ${pathForSystem.absolutePathString()}") pathForSystem.writeBytes(j.writeValueAsBytes(StateWithProcess(state, getPid()))) } private fun isDifferentProcess(): Boolean { val swp: StateWithProcess = j.readValue(pathForSystem.readBytes()) return swp.processId != getPid() } private fun name(): String = system.simpleName!! private fun getPid(): Long = ProcessHandle.current().pid() } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/SystemKey.kt ================================================ package com.trendyol.stove.system.abstractions /** * Marker interface for typed keys used to register and look up multiple instances of the same system type. * * Define keys as singleton objects: * ```kotlin * object PaymentService : SystemKey * object OrderService : SystemKey * object AnalyticsDb : SystemKey * ``` * * Use keys in registration and validation DSLs: * ```kotlin * // Registration * httpClient(PaymentService) { HttpClientSystemOptions(baseUrl = "https://pay.internal") } * * // Validation * http(PaymentService) { get("/payments") { ... } } * ``` * * A single key can be shared across protocols: * ```kotlin * httpClient(PaymentService) { ... } * grpc(PaymentService) { ... } * ``` * * @see com.trendyol.stove.system.Stove.getOrRegister */ interface SystemKey internal val UNSAFE_FILENAME_CHARS = Regex("[^a-zA-Z0-9._-]") /** * Returns a display-safe, filesystem-safe name for a [SystemKey], * with fallbacks for anonymous classes. */ fun keyDisplayName(key: SystemKey): String = (key::class.simpleName ?: key::class.qualifiedName ?: "anonymous-key-${System.identityHashCode(key)}") .replace(UNSAFE_FILENAME_CHARS, "-") ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/SystemOptions.kt ================================================ package com.trendyol.stove.system.abstractions /** * Marker interface for system configuration options. * * Each [PluggedSystem] has its own options class implementing this interface. * Options define how the system should be configured, including container settings, * connection parameters, and configuration exposure. * * ## Example Implementation * * ```kotlin * data class PostgresqlOptions( * val databaseName: String = "test_db", * val username: String = "test", * val password: String = "test", * override val configureExposedConfiguration: (PostgresqlExposedConfiguration) -> List * ) : SystemOptions, ConfiguresExposedConfiguration * ``` * * ## Usage in TestSystem * * ```kotlin * Stove() * .with { * postgresql { * PostgresqlOptions( * databaseName = "my_app_test", * configureExposedConfiguration = { cfg -> * listOf( * "spring.datasource.url=${cfg.jdbcUrl}", * "spring.datasource.username=${cfg.username}", * "spring.datasource.password=${cfg.password}" * ) * } * ) * } * } * ``` * * @see PluggedSystem * @see ExposedConfiguration * @see ConfiguresExposedConfiguration */ interface SystemOptions /** * Marker interface for configuration values exposed by a [PluggedSystem] to the application under test. * * When a system starts (e.g., a PostgreSQL container), it exposes configuration values * like connection URLs, ports, and credentials. These values are passed to the application * under test so it can connect to the test infrastructure. * * ## Example Implementation * * ```kotlin * data class PostgresqlExposedConfiguration( * val host: String, * val port: Int, * val database: String, * val username: String, * val password: String * ) : ExposedConfiguration { * val jdbcUrl: String * get() = "jdbc:postgresql://$host:$port/$database" * } * ``` * * ## How Configuration Flows * * 1. System starts (container or provided instance) * 2. System creates [ExposedConfiguration] with runtime values * 3. [ConfiguresExposedConfiguration.configureExposedConfiguration] transforms it to property strings * 4. Properties are passed to the application under test on startup * * @see SystemOptions * @see ConfiguresExposedConfiguration * @see ExposesConfiguration */ interface ExposedConfiguration /** * Interface for system options that can transform [ExposedConfiguration] into application properties. * * This interface bridges the gap between Stove's test infrastructure and your application's * configuration format (Spring properties, environment variables, etc.). * * ## Example * * ```kotlin * data class KafkaSystemOptions( * override val configureExposedConfiguration: (KafkaExposedConfiguration) -> List * ) : SystemOptions, ConfiguresExposedConfiguration * * // Usage * kafka { * KafkaSystemOptions { cfg -> * listOf( * "spring.kafka.bootstrap-servers=${cfg.bootstrapServers}", * "spring.kafka.consumer.group-id=test-group" * ) * } * } * ``` * * @param T The type of exposed configuration this options class works with. * @see ExposedConfiguration * @see SystemOptions */ interface ConfiguresExposedConfiguration { /** * Function that transforms the exposed configuration into a list of property strings. * * The returned strings are typically in the format `key=value` and are passed * to the application under test as configuration properties. */ val configureExposedConfiguration: (T) -> List } /** * Interface for system options that connect to externally provided instances * instead of starting testcontainers. * * Use this when you want to: * - Connect to shared test infrastructure * - Use existing databases/services in CI/CD * - Debug against local installations * - Avoid container startup overhead * * ## Example * * ```kotlin * // Instead of starting a PostgreSQL container, connect to an existing instance * postgresql { * ProvidedPostgresqlOptions( * providedConfig = PostgresqlExposedConfiguration( * host = "localhost", * port = 5432, * database = "test_db", * username = "postgres", * password = "secret" * ), * configureExposedConfiguration = { cfg -> * listOf("spring.datasource.url=${cfg.jdbcUrl}") * } * ) * } * ``` * * @param TConfig The type of exposed configuration for this system. * @see SystemOptions * @see ExposedConfiguration */ interface ProvidedSystemOptions { /** * The configuration for the provided (external) instance. * * This contains connection details for the external service. * Unlike container-based options, this is always non-null. */ val providedConfig: TConfig /** * Whether to run database migrations when using a provided instance. * * Set to `true` if you want Stove to apply migrations to the external database. * Set to `false` if migrations are managed externally or not needed. */ val runMigrationsForProvided: Boolean } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/SystemRuntime.kt ================================================ package com.trendyol.stove.system.abstractions /** * Represents the runtime environment for a system. * * Implementations: * - [com.trendyol.stove.containers.StoveContainer] - container-based runtime * - [ProvidedRuntime] - externally provided instance * * Use pattern matching (`when`) to handle different runtime types: * ```kotlin * when (val runtime = context.runtime) { * is StoveContainer -> runtime.start() * is ProvidedRuntime -> // use provided config from options * } * ``` */ interface SystemRuntime /** * Provided (external) instance runtime that connects to an existing service. * Configuration comes from the options class. */ data object ProvidedRuntime : SystemRuntime ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/ThenSystemContinuation.kt ================================================ package com.trendyol.stove.system.abstractions import com.trendyol.stove.system.Stove /** * Enables method chaining between different system assertions in the DSL. * * All [PluggedSystem]s implement this interface, allowing fluent switching * between different systems during test assertions. * * ## Chaining Example * * ```kotlin * stove { * http { * postAndExpectBodilessResponse("/orders", body = order.some()) { * it.status shouldBe 201 * } * } * .then() // Switch back to Stove context * .also { * kafka { * shouldBePublished { * actual.orderId == order.id * } * } * } * } * ``` * * ## Fluent DSL Style * * The `then()` method returns [Stove], enabling continued assertions: * * ```kotlin * // All methods return their system, allowing chaining * couchbase { * save(documentId, document) * }.then() * * http { * get("/documents/$documentId") { doc -> * doc shouldBe document * } * } * ``` * * @property stove The parent Stove instance for continuation. * @see PluggedSystem * @author Oguzhan Soykan */ interface ThenSystemContinuation { val stove: Stove /** * Returns [Stove] to continue with other system assertions. * * @return The parent Stove instance. */ fun then(): Stove = stove /** * Executes an action only if dependencies are not set to keep running. * * This is useful for cleanup actions that should be skipped when * containers are reused across test runs (development mode). * * @param action The suspend action to conditionally execute. */ suspend fun executeWithReuseCheck(action: suspend () -> Unit) { if (stove.keepDependenciesRunning) { return } action() } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/abstractions/ValidatedSystem.kt ================================================ package com.trendyol.stove.system.abstractions /** * An abstraction for a system that can be validated after each test or any given moment. * @author Oguzhan Soykan */ interface ValidatedSystem { /** * System that validates itself at any given moment * Each system needs to implement its validation logic */ suspend fun validate() } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/annotations/StoveDsl.kt ================================================ package com.trendyol.stove.system.annotations /** * DSL marker annotation for Stove's type-safe builder pattern. * * This annotation is used to scope DSL functions and prevent accidental access * to outer receivers in nested lambdas, ensuring type safety in the test DSL. * * ## Purpose * * When writing nested DSL blocks, Kotlin allows implicit access to outer receivers. * This can lead to confusing code where it's unclear which receiver a method belongs to. * [StoveDsl] prevents this by marking all Stove DSL components. * * ## Example * * ```kotlin * // Without @DslMarker, this would compile but be confusing: * stove { * http { * kafka { // Accidentally nested - should be at validation level * // ... * } * } * } * * // With @StoveDsl, the above code won't compile, forcing correct structure: * stove { * http { * get("/users/1") { /* ... */ } * } * kafka { // Correctly at validation level * shouldBePublished { /* ... */ } * } * } * ``` * * ## When to Use * * Apply this annotation when creating: * - Custom [com.trendyol.stove.system.abstractions.PluggedSystem] implementations * - DSL extension functions for systems * - Options classes used in configuration DSL * - Any class that participates in Stove's builder pattern * * ```kotlin * @StoveDsl * class MyCustomSystem(override val stove: Stove) : PluggedSystem { * @StoveDsl * fun myDslMethod(): MyCustomSystem { * // ... * return this * } * } * ``` * * @see DslMarker */ @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class StoveDsl ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/application/ApplicationConfigurations.kt ================================================ package com.trendyol.stove.system.application /** * Parses Stove `key=value` configuration entries into a map for application launch. */ fun List.toConfigurationMap(): Map = associate { line -> val (key, value) = line.split("=", limit = 2) key to value } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/application/ArgsProvider.kt ================================================ package com.trendyol.stove.system.application import com.trendyol.stove.system.annotations.StoveDsl /** * Provides CLI arguments for an application under test. */ fun interface ArgsProvider { fun provide(configurations: Map): List companion object { fun empty(): ArgsProvider = ArgsProvider { emptyList() } } } fun argsMapper( prefix: String = "--", separator: String = "=", block: ArgsMapperBuilder.() -> Unit ): ArgsProvider = ArgsMapperBuilder(prefix = prefix, separator = separator).apply(block).build() @StoveDsl class ArgsMapperBuilder( private val prefix: String, private val separator: String ) { private val mappings = mutableMapOf() private val staticArgs = mutableListOf<() -> List>() fun map(configurationKey: String, flagName: String) { mappings[configurationKey] = flagName } infix fun String.to(flagName: String) { map(this, flagName) } fun arg(flag: String, value: String? = null) { staticArgs.add { if (value != null) { formatArg(flag, value) } else { listOf("$prefix$flag") } } } fun arg(flag: String, value: () -> String) { staticArgs.add { formatArg(flag, value()) } } private fun formatArg(flag: String, value: String): List = if (separator == " ") { listOf("$prefix$flag", value) } else { listOf("$prefix$flag$separator$value") } fun build(): ArgsProvider = ArgsProvider { configurations -> buildList { for ((configKey, flagName) in mappings) { configurations[configKey]?.let { value -> addAll(formatArg(flagName, value)) } } for (provider in staticArgs) { addAll(provider()) } } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/system/application/EnvProvider.kt ================================================ package com.trendyol.stove.system.application import com.trendyol.stove.system.annotations.StoveDsl /** * Provides environment variables for an application under test. */ fun interface EnvProvider { fun provide(configurations: Map): Map companion object { fun empty(): EnvProvider = EnvProvider { emptyMap() } } } fun envMapper(block: EnvMapperBuilder.() -> Unit): EnvProvider = EnvMapperBuilder().apply(block).build() @StoveDsl class EnvMapperBuilder { private val mappings = mutableMapOf() private val staticVars = mutableMapOf String>() fun map(configurationKey: String, envVarName: String) { mappings[configurationKey] = envVarName } infix fun String.to(envVarName: String) { map(this, envVarName) } fun env(name: String, value: String) { staticVars[name] = { value } } fun env(name: String, value: () -> String) { staticVars[name] = value } fun build(): EnvProvider = EnvProvider { configurations -> buildMap { for ((configKey, envVarName) in mappings) { configurations[configKey]?.let { put(envVarName, it) } } for ((name, valueProvider) in staticVars) { put(name, valueProvider()) } } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/tracing/SpanInfo.kt ================================================ package com.trendyol.stove.tracing /** * Information about a single span in a trace. */ data class SpanInfo( val traceId: String, val spanId: String, val parentSpanId: String?, val operationName: String, val serviceName: String, val startTimeNanos: Long, val endTimeNanos: Long, val status: SpanStatus, val attributes: Map = emptyMap(), val exception: ExceptionInfo? = null ) { val durationMs: Long get() = (endTimeNanos - startTimeNanos) / NANOS_TO_MILLIS val durationNanos: Long get() = endTimeNanos - startTimeNanos val isFailed: Boolean get() = status == SpanStatus.ERROR val isSuccess: Boolean get() = status == SpanStatus.OK companion object { internal const val NANOS_TO_MILLIS = 1_000_000L } } /** * Exception information captured in a span. */ data class ExceptionInfo( val type: String, val message: String, val stackTrace: List = emptyList() ) /** * Status of a span. */ enum class SpanStatus { OK, ERROR, UNSET } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/tracing/SpanTree.kt ================================================ package com.trendyol.stove.tracing /** * Represents a node in the span tree. * This is an immutable data structure - children cannot be modified after construction. */ data class SpanNode( val span: SpanInfo, val children: List = emptyList() ) { val hasFailedDescendants: Boolean get() = span.isFailed || children.any { it.hasFailedDescendants } val totalDurationMs: Long get() = span.durationMs val depth: Int get() = 1 + (children.maxOfOrNull { it.depth } ?: 0) val spanCount: Int get() = 1 + children.sumOf { it.spanCount } fun findFailurePoint(): SpanNode? { if (span.isFailed && children.none { it.hasFailedDescendants }) { return this } return children.firstNotNullOfOrNull { it.findFailurePoint() } } fun flatten(): List = listOf(span) + children.flatMap { it.flatten() } } /** * Utility object for building and querying span trees. */ object SpanTree { fun build(spans: List): SpanNode? { if (spans.isEmpty()) return null val spanMap = spans.associateBy { it.spanId } val childrenMap = mutableMapOf>() // Group spans by their parent ID for (span in spans) { val effectiveParentId = if (span.parentSpanId != null && spanMap.containsKey(span.parentSpanId)) { span.parentSpanId } else { null } childrenMap.getOrPut(effectiveParentId) { mutableListOf() }.add(span) } // Recursively build nodes bottom-up (immutably) fun buildNode(spanInfo: SpanInfo): SpanNode { val childSpans = childrenMap[spanInfo.spanId] ?: emptyList() val sortedChildren = childSpans .sortedBy { it.startTimeNanos } .map { buildNode(it) } return SpanNode(spanInfo, sortedChildren) } val roots = childrenMap[null] ?: return null if (roots.isEmpty()) return null val sortedRoots = roots.sortedBy { it.startTimeNanos } return if (sortedRoots.size == 1) { buildNode(sortedRoots.first()) } else { // Create a virtual root containing multiple roots val rootNodes = sortedRoots.map { buildNode(it) } SpanNode( span = sortedRoots.first().copy( operationName = "trace-root", parentSpanId = null ), children = rootNodes ) } } fun findSpan(root: SpanNode, predicate: (SpanInfo) -> Boolean): SpanNode? { if (predicate(root.span)) return root return root.children.firstNotNullOfOrNull { findSpan(it, predicate) } } fun filterSpans(root: SpanNode, predicate: (SpanInfo) -> Boolean): List { val result = mutableListOf() if (predicate(root.span)) result.add(root) root.children.forEach { result.addAll(filterSpans(it, predicate)) } return result } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/tracing/TraceContext.kt ================================================ package com.trendyol.stove.tracing import io.opentelemetry.api.baggage.Baggage import io.opentelemetry.api.trace.* import io.opentelemetry.context.Context import io.opentelemetry.context.Scope import kotlinx.coroutines.ThreadContextElement import kotlinx.coroutines.withContext import java.text.Normalizer import java.util.UUID import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext data class TraceContext( val traceId: String, val testId: String, val rootSpanId: String ) { fun toTraceparent(): String = "00-$traceId-$rootSpanId-01" companion object { /** W3C trace context header name */ const val TRACEPARENT_HEADER = "traceparent" /** Stove test ID header name */ const val STOVE_TEST_ID_HEADER = "X-Stove-Test-Id" /** Baggage key for propagating the Stove test ID through OTel's baggage propagator. */ const val BAGGAGE_TEST_ID_KEY = "stove.test.id" /** Span ID length in W3C trace context */ private const val SPAN_ID_LENGTH = 16 /** Minimum parts required in a W3C traceparent header */ private const val TRACEPARENT_MIN_PARTS = 3 /** * Uses InheritableThreadLocal to propagate trace context to child threads. * IMPORTANT: Always call [clear] when done with the trace to avoid memory leaks. * Prefer using [use] for automatic cleanup. */ private val current = InheritableThreadLocal() /** * Stores the OTel [Scope] so it can be closed in [clear]. * Not inheritable -- only the creating thread should close it. */ private val otelScope = ThreadLocal() fun start(testId: String): TraceContext { val ctx = TraceContext( traceId = generateTraceId(), testId = testId, rootSpanId = generateSpanId() ) current.set(ctx) activateOtelContext(ctx) return ctx } fun current(): TraceContext? = current.get() /** * Runs [block] while propagating the current [TraceContext] across coroutine thread switches. * * This keeps both Stove's thread-local context and OTel scope active when coroutines hop * between worker threads. */ suspend fun withCurrentPropagation(block: suspend () -> T): T { val ctx = current() ?: return block() return withPropagation(ctx, block) } /** * Runs [block] with the given [ctx] propagated across coroutine thread switches. */ suspend fun withPropagation(ctx: TraceContext, block: suspend () -> T): T = withContext(TraceContextPropagationElement(ctx)) { block() } /** * Clears the current trace context from the thread-local storage. * IMPORTANT: Always call this method when done with a trace to prevent memory leaks. */ fun clear() { deactivateOtelContext() current.remove() } /** * Executes the given block with a new trace context, ensuring cleanup afterward. * This is the preferred way to use trace contexts to avoid memory leaks. * * @param testId The test identifier for the trace * @param block The code block to execute within the trace context * @return The result of the block execution */ inline fun use(testId: String, block: (TraceContext) -> T): T { val ctx = start(testId) return try { block(ctx) } finally { clear() } } fun generateTraceId(): String = UUID.randomUUID().toString().replace("-", "") fun generateSpanId(): String = UUID .randomUUID() .toString() .replace("-", "") .take(SPAN_ID_LENGTH) fun parseTraceparent(traceparent: String): Pair? { val parts = traceparent.split("-") return if (parts.size >= TRACEPARENT_MIN_PARTS) { parts[1] to parts[2] } else { null } } /** * Sanitizes a string for use in HTTP headers and as a consistent identifier. * Replaces non-ASCII characters with their closest ASCII equivalents or underscores. * * Uses Java's Normalizer to decompose characters (e.g., "ü" → "u" + combining diaeresis) * then strips combining marks, leaving only base ASCII characters. * * For scripts that don't decompose to ASCII (e.g., Japanese, Chinese, Korean), * a hash suffix is appended to ensure uniqueness. * * This should be used when creating testId to ensure consistency between * what's stored internally and what's sent in HTTP/gRPC/Kafka headers. */ fun sanitizeToAscii(value: String): String { val sanitized = Normalizer .normalize(value, Normalizer.Form.NFD) .replace(COMBINING_MARKS_REGEX, "") .replace(NON_ASCII_REGEX, "_") // If we replaced any characters with underscores (lost information), // append a hash to ensure uniqueness for non-decomposable scripts like Japanese val hasReplacements = sanitized.contains("_") && !value.all { it.code in ASCII_PRINTABLE_START..ASCII_PRINTABLE_END } return if (hasReplacements) { val hash = Integer.toHexString(value.hashCode() and POSITIVE_INT_MASK).takeLast(HASH_SUFFIX_LENGTH) "${sanitized}_$hash" } else { sanitized } } /** Length of hash suffix for uniqueness */ private const val HASH_SUFFIX_LENGTH = 6 /** Start of printable ASCII range (space character) */ private const val ASCII_PRINTABLE_START = 0x20 /** End of printable ASCII range (tilde character) */ private const val ASCII_PRINTABLE_END = 0x7E /** Mask to convert hash to positive integer */ private const val POSITIVE_INT_MASK = 0x7FFFFFFF /** Regex to match Unicode combining marks (diacritics) */ private val COMBINING_MARKS_REGEX = Regex("\\p{M}") /** Regex to match any remaining non-ASCII characters */ private val NON_ASCII_REGEX = Regex("[^\\x20-\\x7E]") /** * Activates an OTel context with Stove's trace ID and test ID baggage. * * When the OTel Java Agent is present, this makes the agent treat subsequent * outgoing calls (HTTP, Kafka, gRPC) as children of Stove's trace: * - **SpanContext**: The agent sees an active trace and creates child spans * with Stove's trace ID instead of generating a new root trace. * - **Baggage**: The test ID is propagated automatically via the W3C `baggage` * header across all transports, without manual header injection. * * When no agent is present, the OTel API is a no-op. */ @Suppress("TooGenericExceptionCaught") private fun activateOtelContext(ctx: TraceContext) { try { val spanContext = SpanContext.create( ctx.traceId, ctx.rootSpanId, TraceFlags.getSampled(), TraceState.getDefault() ) val span = Span.wrap(spanContext) val baggage = Baggage .fromContext(Context.current()) .toBuilder() .put(BAGGAGE_TEST_ID_KEY, ctx.testId) .build() val otelCtx = Context .current() .with(span) .with(baggage) otelScope.set(otelCtx.makeCurrent()) } catch (_: Exception) { // OTel API initialization failed -- trace headers still work as fallback } } @Suppress("TooGenericExceptionCaught") private fun deactivateOtelContext() { try { otelScope.get()?.close() } catch (_: Exception) { // Scope.close() may fail if called from a different thread than start() } otelScope.remove() } private data class PreviousThreadState( val traceContext: TraceContext? ) /** * Propagates TraceContext and OTel scope across coroutine dispatcher thread switches. */ private class TraceContextPropagationElement( private val ctx: TraceContext ) : AbstractCoroutineContextElement(Key), ThreadContextElement { companion object Key : CoroutineContext.Key override fun updateThreadContext(context: CoroutineContext): PreviousThreadState { val previous = PreviousThreadState(current.get()) deactivateOtelContext() current.set(ctx) activateOtelContext(ctx) return previous } override fun restoreThreadContext(context: CoroutineContext, oldState: PreviousThreadState) { deactivateOtelContext() oldState.traceContext?.let { previous -> current.set(previous) activateOtelContext(previous) } ?: current.remove() } } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/tracing/TraceTreeRenderer.kt ================================================ package com.trendyol.stove.tracing /** * Renders span trees as human-readable text with optional ANSI colors. */ @Suppress("TooManyFunctions") object TraceTreeRenderer { private const val INDENT = "│ " private const val BRANCH = "├─ " private const val LAST_BRANCH = "└─ " private const val SPACE = " " private const val MAX_STACK_TRACE_LINES = 3 // ANSI color codes private object Colors { const val RESET = "\u001B[0m" const val BOLD = "\u001B[1m" const val DIM = "\u001B[2m" const val RED = "\u001B[31m" const val GREEN = "\u001B[32m" const val YELLOW = "\u001B[33m" const val CYAN = "\u001B[36m" const val WHITE = "\u001B[37m" const val BRIGHT_RED = "\u001B[91m" const val BRIGHT_GREEN = "\u001B[92m" const val BRIGHT_YELLOW = "\u001B[93m" } /** * Renders the span tree with ANSI colors for terminal display. */ fun renderColored( root: SpanNode, includeAttributes: Boolean = true, attributePrefixes: List = listOf("db.", "http.", "rpc.", "messaging.") ): String { val sb = StringBuilder() renderNodeColored(sb, root, "", true, includeAttributes, attributePrefixes) return sb.toString() } /** * Renders the span tree as plain text (no colors). */ fun render( root: SpanNode, includeAttributes: Boolean = true, attributePrefixes: List = listOf("db.", "http.", "rpc.", "messaging.") ): String { val sb = StringBuilder() renderNode(sb, root, "", true, includeAttributes, attributePrefixes) return sb.toString() } @Suppress("CyclomaticComplexMethod") private fun renderNodeColored( sb: StringBuilder, node: SpanNode, prefix: String, isLast: Boolean, includeAttributes: Boolean, attributePrefixes: List ) { val connector = getConnector(prefix, isLast) val childPrefix = getChildPrefix(prefix, isLast) appendColoredSpanLine(sb, node, prefix, connector) appendExceptionIfFailed(sb, node, childPrefix, colored = true) appendAttributesIfEnabled(sb, includeAttributes, childPrefix, node.span.attributes, attributePrefixes, colored = true) node.children.forEachIndexed { index, child -> renderNodeColored(sb, child, childPrefix, index == node.children.lastIndex, includeAttributes, attributePrefixes) } } private fun appendColoredSpanLine(sb: StringBuilder, node: SpanNode, prefix: String, connector: String) { val isFailed = node.span.isFailed val statusIcon = if (isFailed) "${Colors.BRIGHT_RED}✗${Colors.RESET}" else "${Colors.BRIGHT_GREEN}✓${Colors.RESET}" val durationColor = if (isFailed) Colors.BRIGHT_RED else Colors.DIM val nameColor = if (isFailed) Colors.BRIGHT_RED else Colors.WHITE val failureMarker = getFailureMarker(node, colored = true) sb.appendLine( "$prefix$connector$nameColor${node.span.operationName}${Colors.RESET} " + "$durationColor[${node.span.durationMs}ms]${Colors.RESET} $statusIcon$failureMarker" ) } private fun getFailureMarker(node: SpanNode, colored: Boolean): String { val isFailurePoint = node.span.isFailed && node.children.none { it.hasFailedDescendants } return when { !isFailurePoint -> "" colored -> " ${Colors.BOLD}${Colors.BRIGHT_YELLOW}◄── FAILURE POINT${Colors.RESET}" else -> " ◄── FAILURE POINT" } } private fun getConnector(prefix: String, isLast: Boolean): String = when { prefix.isEmpty() -> "" isLast -> LAST_BRANCH else -> BRANCH } private fun getChildPrefix(prefix: String, isLast: Boolean): String = prefix + when { prefix.isEmpty() -> "" isLast -> SPACE else -> INDENT } private fun appendExceptionIfFailed(sb: StringBuilder, node: SpanNode, childPrefix: String, colored: Boolean) { if (node.span.exception != null && node.span.isFailed) { if (colored) { renderExceptionColored(sb, childPrefix, node.span.exception) } else { renderException(sb, childPrefix, node.span.exception) } } } private fun appendAttributesIfEnabled( sb: StringBuilder, includeAttributes: Boolean, childPrefix: String, attributes: Map, attributePrefixes: List, colored: Boolean ) { if (includeAttributes) { if (colored) { renderRelevantAttributesColored(sb, childPrefix, attributes, attributePrefixes) } else { renderRelevantAttributes(sb, childPrefix, attributes, attributePrefixes) } } } private fun renderExceptionColored(sb: StringBuilder, prefix: String, exception: ExceptionInfo) { sb.appendLine( "$prefix${Colors.DIM}│${Colors.RESET} ${Colors.BRIGHT_RED}Error:${Colors.RESET} " + "${Colors.YELLOW}${exception.type}${Colors.RESET}: ${exception.message}" ) exception.stackTrace .take(MAX_STACK_TRACE_LINES) .forEach { line -> sb.appendLine("$prefix${Colors.DIM}│${Colors.RESET} ${Colors.DIM}$line${Colors.RESET}") } } private fun renderRelevantAttributesColored( sb: StringBuilder, prefix: String, attributes: Map, attributePrefixes: List ) { val relevantAttrs = attributes.filter { (key, _) -> attributePrefixes.any { key.startsWith(it) } } relevantAttrs.forEach { (key, value) -> sb.appendLine("$prefix${Colors.DIM}│${Colors.RESET} ${Colors.CYAN}$key${Colors.RESET}: $value") } } private fun renderNode( sb: StringBuilder, node: SpanNode, prefix: String, isLast: Boolean, includeAttributes: Boolean, attributePrefixes: List ) { val connector = getConnector(prefix, isLast) val childPrefix = getChildPrefix(prefix, isLast) val status = if (node.span.isFailed) "✗" else "✓" val failureMarker = getFailureMarker(node, colored = false) sb.appendLine("$prefix$connector${node.span.operationName} [${node.span.durationMs}ms] $status$failureMarker") appendExceptionIfFailed(sb, node, childPrefix, colored = false) appendAttributesIfEnabled(sb, includeAttributes, childPrefix, node.span.attributes, attributePrefixes, colored = false) node.children.forEachIndexed { index, child -> renderNode(sb, child, childPrefix, index == node.children.lastIndex, includeAttributes, attributePrefixes) } } private fun renderException(sb: StringBuilder, prefix: String, exception: ExceptionInfo) { sb.appendLine("$prefix${INDENT}Error: ${exception.type}: ${exception.message}") exception.stackTrace .take(MAX_STACK_TRACE_LINES) .forEach { line -> sb.appendLine("$prefix$INDENT $line") } } private fun renderRelevantAttributes( sb: StringBuilder, prefix: String, attributes: Map, attributePrefixes: List ) { val relevantAttrs = attributes.filter { (key, _) -> attributePrefixes.any { key.startsWith(it) } } relevantAttrs.forEach { (key, value) -> sb.appendLine("$prefix${INDENT}$key: $value") } } fun renderCompact(root: SpanNode): String { val sb = StringBuilder() renderCompactNode(sb, root, 0) return sb.toString() } private fun renderCompactNode( sb: StringBuilder, node: SpanNode, depth: Int ) { val indent = " ".repeat(depth) val status = if (node.span.isFailed) "✗" else "✓" sb.appendLine("$indent$status ${node.span.operationName} (${node.span.durationMs}ms)") node.children.forEach { child -> renderCompactNode(sb, child, depth + 1) } } fun renderSummary(root: SpanNode): String { val totalSpans = root.spanCount val failedSpans = root.flatten().count { it.isFailed } val totalDuration = root.span.durationMs val maxDepth = root.depth return buildString { appendLine("Trace Summary:") appendLine(" Total spans: $totalSpans") appendLine(" Failed spans: $failedSpans") appendLine(" Total duration: ${totalDuration}ms") appendLine(" Max depth: $maxDepth") if (failedSpans > 0) { val failurePoint = root.findFailurePoint() if (failurePoint != null) { appendLine(" Failure point: ${failurePoint.span.operationName}") failurePoint.span.exception?.let { ex -> appendLine(" Error: ${ex.type}: ${ex.message}") } } } } } } ================================================ FILE: lib/stove/src/main/kotlin/com/trendyol/stove/tracing/TraceVisualization.kt ================================================ package com.trendyol.stove.tracing /** * Data structure for trace visualization in reports. * Designed to be serializable and easy to render in different formats. */ data class TraceVisualization( val traceId: String, val testId: String, val totalSpans: Int, val failedSpans: Int, val spans: List, val tree: String, val coloredTree: String ) { companion object { fun from(traceId: String, testId: String, spans: List): TraceVisualization { val visualSpans = spans.map { VisualSpan.from(it) } val (tree, coloredTree) = buildTraceTrees(spans) return TraceVisualization( traceId = traceId, testId = testId, totalSpans = spans.size, failedSpans = spans.count { it.status == SpanStatus.ERROR }, spans = visualSpans, tree = tree, coloredTree = coloredTree ) } /** * Build tree visualizations of spans using SpanTree and TraceTreeRenderer. * Returns both plain and colored versions for different display contexts. */ private fun buildTraceTrees(spans: List): Pair { if (spans.isEmpty()) return "No spans in trace" to "No spans in trace" val root = SpanTree.build(spans) ?: return "No spans in trace" to "No spans in trace" return TraceTreeRenderer.render(root) to TraceTreeRenderer.renderColored(root) } } } /** * Simplified span representation for visualization */ data class VisualSpan( val spanId: String, val parentSpanId: String?, val operationName: String, val serviceName: String, val durationMs: Double, val status: String, val attributes: Map ) { companion object { private const val NANOS_TO_MILLIS = 1_000_000L fun from(span: SpanInfo): VisualSpan = VisualSpan( spanId = span.spanId, parentSpanId = span.parentSpanId, operationName = span.operationName, serviceName = span.serviceName, durationMs = calculateDurationMs(span), status = span.status.name, attributes = span.attributes ) private fun calculateDurationMs(span: SpanInfo): Double = if (span.endTimeNanos > 0) { (span.endTimeNanos - span.startTimeNanos).toDouble() / NANOS_TO_MILLIS } else { 0.0 } } } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/containers/ContainerOptionsTest.kt ================================================ package com.trendyol.stove.containers import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.testcontainers.utility.DockerImageName class ContainerOptionsTest : FunSpec({ test("imageWithTag should combine image and tag") { val options = object : ContainerOptions { override val registry: String = "docker.io" override val image: String = "alpine" override val tag: String = "3.19" override val compatibleSubstitute: String? = null override val useContainerFn: UseContainerFn = { _ -> error("unused") } override val containerFn: ContainerFn = { } } options.imageWithTag shouldBe "alpine:3.19" } test("useContainerFn should receive docker image name") { var received: DockerImageName? = null val options = object : ContainerOptions { override val registry: String = "docker.io" override val image: String = "alpine" override val tag: String = "3.19" override val compatibleSubstitute: String? = null override val useContainerFn: UseContainerFn = { imageName -> received = imageName error("stop") } override val containerFn: ContainerFn = { } } try { options.useContainerFn(DockerImageName.parse(options.imageWithTag)) } catch (_: Throwable) { } received?.asCanonicalNameString() shouldBe "alpine:3.19" } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/containers/ProvidedRegistryTest.kt ================================================ package com.trendyol.stove.containers import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import org.testcontainers.utility.DockerImageName class ProvidedRegistryTest : FunSpec({ beforeEach { // Reset to default before each test DEFAULT_REGISTRY = "docker.io" } context("DEFAULT_REGISTRY") { test("should have docker.io as default") { DEFAULT_REGISTRY shouldBe "docker.io" } test("should be settable globally") { DEFAULT_REGISTRY = "my-registry.example.com" DEFAULT_REGISTRY shouldBe "my-registry.example.com" } } context("withProvidedRegistry") { test("should prepend registry to image name") { var capturedImageName: DockerImageName? = null withProvidedRegistry("postgres:15") { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "docker.io/postgres:15" } test("should use custom registry when provided") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "myapp:latest", registry = "gcr.io/my-project" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "gcr.io/my-project/myapp:latest" } test("should trim leading slashes from registry") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "nginx:latest", registry = "/my-registry.com/" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "my-registry.com/nginx:latest" } test("should trim leading slashes from image name") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "/library/redis:7", registry = "docker.io" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "docker.io/library/redis:7" } test("should use image name as compatible substitute when not provided") { var capturedImageName: DockerImageName? = null withProvidedRegistry("couchbase/server:7.0") { imageName -> capturedImageName = imageName "container" } // The image should be a substitute for the original image name capturedImageName shouldBe capturedImageName } test("should use custom compatible substitute when provided") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "my-custom-postgres:15", registry = "my-registry.com", compatibleSubstitute = "postgres" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "my-registry.com/my-custom-postgres:15" } test("should return result from containerBuilder") { data class TestContainer( val name: String ) val result = withProvidedRegistry("test:latest") { imageName -> TestContainer(imageName.toString()) } result.name shouldContain "docker.io/test:latest" } test("should use DEFAULT_REGISTRY when registry not specified") { DEFAULT_REGISTRY = "custom-default.io" var capturedImageName: DockerImageName? = null withProvidedRegistry("myimage:v1") { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "custom-default.io/myimage:v1" } test("should handle image name with organization") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "confluentinc/cp-kafka:7.0.0", registry = "docker.io" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldContain "docker.io/confluentinc/cp-kafka:7.0.0" } test("should not prepend registry when image already contains a registry with a dot") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04", registry = "docker.io" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldBe "mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04" } test("should not prepend registry when image contains ghcr.io") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "ghcr.io/my-org/my-image:latest", registry = "docker.io" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldBe "ghcr.io/my-org/my-image:latest" } test("should not prepend registry when image contains localhost with port") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "localhost:5000/my-image:latest", registry = "docker.io" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldBe "localhost:5000/my-image:latest" } test("should not prepend registry when registry is blank") { var capturedImageName: DockerImageName? = null withProvidedRegistry( imageName = "postgres:15", registry = "" ) { imageName -> capturedImageName = imageName "container" } capturedImageName?.toString() shouldBe "postgres:15" } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/containers/StoveContainerTest.kt ================================================ package com.trendyol.stove.containers import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class StoveContainerTest : FunSpec({ context("ExecResult") { test("should store exit code, stdout, and stderr") { val result = ExecResult( exitCode = 0, stdout = "command output", stderr = "" ) result.exitCode shouldBe 0 result.stdout shouldBe "command output" result.stderr shouldBe "" } test("should handle non-zero exit code") { val result = ExecResult( exitCode = 1, stdout = "", stderr = "Error: command not found" ) result.exitCode shouldBe 1 result.stderr shouldBe "Error: command not found" } test("should handle timeout with negative exit code") { val result = ExecResult( exitCode = -1, stdout = "partial output", stderr = "Command timed out after 60 seconds" ) result.exitCode shouldBe -1 } test("should handle multiline output") { val result = ExecResult( exitCode = 0, stdout = """ |line 1 |line 2 |line 3 """.trimMargin(), stderr = "" ) result.stdout.lines().size shouldBe 3 } } context("StoveContainerInspectInformation") { test("should store all container information") { val info = StoveContainerInspectInformation( id = "abc123def456", labels = mapOf("app" to "test", "version" to "1.0"), name = "/test-container", state = "running", running = true, paused = false, restarting = false, startedAt = "2024-01-15T10:30:00Z", finishedAt = "0001-01-01T00:00:00Z", exitCode = 0, error = "" ) info.id shouldBe "abc123def456" info.labels shouldBe mapOf("app" to "test", "version" to "1.0") info.name shouldBe "/test-container" info.state shouldBe "running" info.running shouldBe true info.paused shouldBe false info.restarting shouldBe false info.exitCode shouldBe 0 info.error shouldBe "" } test("should represent paused container") { val info = StoveContainerInspectInformation( id = "container-id", labels = emptyMap(), name = "/paused-container", state = "paused", running = true, paused = true, restarting = false, startedAt = "2024-01-15T10:30:00Z", finishedAt = "0001-01-01T00:00:00Z", exitCode = 0, error = "" ) info.running shouldBe true info.paused shouldBe true } test("should represent stopped container with error") { val info = StoveContainerInspectInformation( id = "failed-container", labels = emptyMap(), name = "/failed-container", state = "exited", running = false, paused = false, restarting = false, startedAt = "2024-01-15T10:30:00Z", finishedAt = "2024-01-15T10:35:00Z", exitCode = 137, error = "OOM killed" ) info.running shouldBe false info.exitCode shouldBe 137 info.error shouldBe "OOM killed" } test("should represent restarting container") { val info = StoveContainerInspectInformation( id = "restarting-container", labels = emptyMap(), name = "/restarting", state = "restarting", running = false, paused = false, restarting = true, startedAt = "2024-01-15T10:30:00Z", finishedAt = "2024-01-15T10:35:00Z", exitCode = 1, error = "" ) info.restarting shouldBe true info.running shouldBe false } test("should handle empty labels") { val info = StoveContainerInspectInformation( id = "id", labels = emptyMap(), name = "/container", state = "running", running = true, paused = false, restarting = false, startedAt = "", finishedAt = "", exitCode = 0, error = "" ) info.labels shouldBe emptyMap() } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/database/migrations/MigrationCollectionTest.kt ================================================ package com.trendyol.stove.database.migrations import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe class MigrationCollectionTest : FunSpec({ data class TestConnection( val name: String ) class SimpleMigration : DatabaseMigration { override val order: Int = 1 var executed = false override suspend fun execute(connection: TestConnection) { executed = true } } class AnotherMigration : DatabaseMigration { override val order: Int = 2 var executed = false override suspend fun execute(connection: TestConnection) { executed = true } } class HighPriorityMigration : DatabaseMigration { override val order: Int = MigrationPriority.HIGHEST.value var executed = false override suspend fun execute(connection: TestConnection) { executed = true } } class LowPriorityMigration : DatabaseMigration { override val order: Int = MigrationPriority.LOWEST.value var executed = false override suspend fun execute(connection: TestConnection) { executed = true } } class ConfigurableMigration( val config: String ) : DatabaseMigration { override val order: Int = 5 var executed = false var executedConfig: String? = null override suspend fun execute(connection: TestConnection) { executed = true executedConfig = config } } test("should register migration by class") { val collection = MigrationCollection() collection.register() collection.run(TestConnection("test")) // If no exception, registration worked } test("should register migration with instance") { val collection = MigrationCollection() val migration = SimpleMigration() collection.register(SimpleMigration::class, migration) collection.run(TestConnection("test")) migration.executed shouldBe true } test("should register migration with factory function") { val collection = MigrationCollection() collection.register { ConfigurableMigration("custom-config") } collection.run(TestConnection("test")) } test("should not replace existing migration with register by class") { val collection = MigrationCollection() // First register creates the instance collection.register() // Second register should not replace (uses putIfAbsent) collection.register() collection.run(TestConnection("test")) // Test passes if no exception - only one migration executed } test("should replace migration with replace method") { val collection = MigrationCollection() val first = SimpleMigration() val replacement = SimpleMigration() collection.register(SimpleMigration::class, first) collection.replace(SimpleMigration::class, replacement) collection.run(TestConnection("test")) first.executed shouldBe false replacement.executed shouldBe true } test("should replace migration using factory function") { val collection = MigrationCollection() val original = ConfigurableMigration("original") collection.register(ConfigurableMigration::class, original) collection.replace { ConfigurableMigration("replaced") } collection.run(TestConnection("test")) original.executed shouldBe false } test("should replace one migration type with another") { val collection = MigrationCollection() collection.register() collection.replace() collection.run(TestConnection("test")) } test("should execute migrations in order") { val collection = MigrationCollection() val executionOrder = mutableListOf() val migration1 = object : DatabaseMigration { override val order: Int = 10 override suspend fun execute(connection: TestConnection) { executionOrder.add(10) } } val migration2 = object : DatabaseMigration { override val order: Int = 5 override suspend fun execute(connection: TestConnection) { executionOrder.add(5) } } val migration3 = object : DatabaseMigration { override val order: Int = 15 override suspend fun execute(connection: TestConnection) { executionOrder.add(15) } } collection.register(SimpleMigration::class, migration1) collection.register(AnotherMigration::class, migration2) collection.register(HighPriorityMigration::class, migration3) collection.run(TestConnection("test")) executionOrder shouldContainExactly listOf(5, 10, 15) } test("should execute high priority migrations first") { val collection = MigrationCollection() val executionOrder = mutableListOf() val highPriority = object : DatabaseMigration { override val order: Int = MigrationPriority.HIGHEST.value override suspend fun execute(connection: TestConnection) { executionOrder.add("high") } } val normalPriority = object : DatabaseMigration { override val order: Int = 0 override suspend fun execute(connection: TestConnection) { executionOrder.add("normal") } } val lowPriority = object : DatabaseMigration { override val order: Int = MigrationPriority.LOWEST.value override suspend fun execute(connection: TestConnection) { executionOrder.add("low") } } collection.register(LowPriorityMigration::class, lowPriority) collection.register(SimpleMigration::class, normalPriority) collection.register(HighPriorityMigration::class, highPriority) collection.run(TestConnection("test")) executionOrder shouldContainExactly listOf("high", "normal", "low") } test("should pass connection to migrations") { val collection = MigrationCollection() var receivedConnection: TestConnection? = null val capturingMigration = object : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: TestConnection) { receivedConnection = connection } } collection.register(SimpleMigration::class, capturingMigration) val testConnection = TestConnection("my-connection") collection.run(testConnection) receivedConnection shouldBe testConnection } test("should handle empty collection") { val collection = MigrationCollection() collection.run(TestConnection("test")) // No exception means success } test("should support fluent chaining") { val collection = MigrationCollection() val result = collection .register() .register() result shouldBe collection } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/database/migrations/SupportsMigrationsTest.kt ================================================ package com.trendyol.stove.database.migrations import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeSameInstanceAs /** * Unit tests for the SupportsMigrations interface. */ class SupportsMigrationsTest : FunSpec({ /** * Simple migration context for testing. */ data class TestMigrationContext( val connectionString: String ) /** * Test options class implementing SupportsMigrations. */ class TestSystemOptions( val name: String ) : SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() } /** * Test migration that records execution. */ class TestMigration : DatabaseMigration { override val order: Int = 1 var executed = false var executedContext: TestMigrationContext? = null override suspend fun execute(connection: TestMigrationContext) { executed = true executedContext = connection } } /** * Another test migration with higher order. */ class TestMigration2 : DatabaseMigration { override val order: Int = 2 var executed = false override suspend fun execute(connection: TestMigrationContext) { executed = true } } test("migrations() should return the same instance for fluent chaining") { val options = TestSystemOptions("test") val result = options.migrations { } result shouldBeSameInstanceAs options } test("migrations() should allow registering migrations") { val options = TestSystemOptions("test") val migration = TestMigration() options.migrations { register(TestMigration::class, migration) } // Run migrations and verify val context = TestMigrationContext("test-connection") options.migrationCollection.run(context) migration.executed shouldBe true migration.executedContext shouldBe context } test("migrations() should allow multiple migrations to be registered") { val options = TestSystemOptions("test") val migration1 = TestMigration() val migration2 = TestMigration2() options.migrations { register(TestMigration::class, migration1) register(TestMigration2::class, migration2) } // Run migrations val context = TestMigrationContext("test-connection") options.migrationCollection.run(context) migration1.executed shouldBe true migration2.executed shouldBe true } test("migrationCollection should be unique per instance") { val options1 = TestSystemOptions("test1") val options2 = TestSystemOptions("test2") options1.migrationCollection shouldBe options1.migrationCollection options1.migrationCollection.hashCode() != options2.migrationCollection.hashCode() } test("migrations can be chained with other builder methods") { class ChainableOptions( val name: String, var configured: Boolean = false ) : SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() fun configure(): ChainableOptions { configured = true return this } } val options = ChainableOptions("test") .configure() .migrations { register() } options.configured shouldBe true options.name shouldBe "test" } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/http/StoveHttpResponseTest.kt ================================================ package com.trendyol.stove.http import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf class StoveHttpResponseTest : FunSpec({ context("Bodiless") { test("should store status and headers") { val response = StoveHttpResponse.Bodiless( status = 200, headers = mapOf("Content-Type" to "application/json") ) response.status shouldBe 200 response.headers shouldBe mapOf("Content-Type" to "application/json") } test("should handle empty headers") { val response = StoveHttpResponse.Bodiless( status = 404, headers = emptyMap() ) response.status shouldBe 404 response.headers shouldBe emptyMap() } test("should be instance of StoveHttpResponse") { val response = StoveHttpResponse.Bodiless(status = 200, headers = emptyMap()) response.shouldBeInstanceOf() } test("data class equality should work") { val response1 = StoveHttpResponse.Bodiless(status = 200, headers = mapOf("key" to "value")) val response2 = StoveHttpResponse.Bodiless(status = 200, headers = mapOf("key" to "value")) response1 shouldBe response2 } test("copy should work") { val original = StoveHttpResponse.Bodiless(status = 200, headers = emptyMap()) val copied = original.copy(status = 201) copied.status shouldBe 201 copied.headers shouldBe emptyMap() } } context("WithBody") { test("should store status, headers, and body") { val response = StoveHttpResponse.WithBody( status = 200, headers = mapOf("Content-Type" to "application/json"), body = { "test body" } ) response.status shouldBe 200 response.headers shouldBe mapOf("Content-Type" to "application/json") } test("body should execute suspend function") { var executed = false val response = StoveHttpResponse.WithBody( status = 200, headers = emptyMap(), body = { executed = true "result" } ) val result = response.body() executed shouldBe true result shouldBe "result" } test("should handle different body types") { data class User( val id: Int, val name: String ) val response = StoveHttpResponse.WithBody( status = 200, headers = emptyMap(), body = { User(1, "John") } ) val user = response.body() user.id shouldBe 1 user.name shouldBe "John" } test("should be instance of StoveHttpResponse") { val response = StoveHttpResponse.WithBody( status = 200, headers = emptyMap(), body = { "body" } ) response.shouldBeInstanceOf() } test("should handle error status codes") { val response = StoveHttpResponse.WithBody( status = 500, headers = mapOf("X-Error" to "Internal Server Error"), body = { mapOf("error" to "Something went wrong") } ) response.status shouldBe 500 response.body() shouldBe mapOf("error" to "Something went wrong") } } context("sealed class behavior") { test("should pattern match on response type") { val bodiless: StoveHttpResponse = StoveHttpResponse.Bodiless(204, emptyMap()) val withBody: StoveHttpResponse = StoveHttpResponse.WithBody(200, emptyMap()) { "body" } val bodilessResult = when (bodiless) { is StoveHttpResponse.Bodiless -> "no body" is StoveHttpResponse.WithBody<*> -> "has body" } val withBodyResult = when (withBody) { is StoveHttpResponse.Bodiless -> "no body" is StoveHttpResponse.WithBody<*> -> "has body" } bodilessResult shouldBe "no body" withBodyResult shouldBe "has body" } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/messaging/ObservationTest.kt ================================================ package com.trendyol.stove.messaging import arrow.core.None import arrow.core.Some import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf class ObservationTest : FunSpec({ val testMetadata = MessageMetadata( topic = "test-topic", key = "test-key", headers = mapOf("header1" to "value1") ) context("MessageMetadata") { test("should store topic, key, and headers") { val metadata = MessageMetadata( topic = "orders", key = "order-123", headers = mapOf("traceId" to "abc123", "version" to 1) ) metadata.topic shouldBe "orders" metadata.key shouldBe "order-123" metadata.headers["traceId"] shouldBe "abc123" metadata.headers["version"] shouldBe 1 } test("should support empty headers") { val metadata = MessageMetadata( topic = "events", key = "event-1", headers = emptyMap() ) metadata.headers shouldBe emptyMap() } } context("SuccessfulParsedMessage") { test("should implement ParsedMessage") { val message = SuccessfulParsedMessage( message = Some("test-content"), metadata = testMetadata ) message.shouldBeInstanceOf>() } test("should store message and metadata") { val message = SuccessfulParsedMessage( message = Some(mapOf("id" to 123)), metadata = testMetadata ) message.message shouldBe Some(mapOf("id" to 123)) message.metadata shouldBe testMetadata } test("should handle None message") { val message = SuccessfulParsedMessage( message = None, metadata = testMetadata ) message.message shouldBe None } } context("FailedParsedMessage") { test("should implement ParsedMessage") { val exception = RuntimeException("Parse error") val message = FailedParsedMessage( message = None, metadata = testMetadata, reason = exception ) message.shouldBeInstanceOf>() } test("should store reason for failure") { val exception = IllegalArgumentException("Invalid JSON") val message = FailedParsedMessage( message = None, metadata = testMetadata, reason = exception ) message.reason shouldBe exception message.reason.message shouldBe "Invalid JSON" } test("should preserve partial message on failure") { val exception = RuntimeException("Validation failed") val message = FailedParsedMessage( message = Some("partial-data"), metadata = testMetadata, reason = exception ) message.message shouldBe Some("partial-data") } } context("ObservedMessage") { test("should store actual message and metadata") { data class OrderEvent( val orderId: String, val amount: Double ) val event = OrderEvent("order-123", 99.99) val observed = ObservedMessage( actual = event, metadata = testMetadata ) observed.actual shouldBe event observed.metadata shouldBe testMetadata } test("should work with primitive types") { val observed = ObservedMessage( actual = "simple-string", metadata = testMetadata ) observed.actual shouldBe "simple-string" } } context("FailedObservedMessage") { test("should extend ObservedMessage") { val exception = RuntimeException("Processing failed") val failed = FailedObservedMessage( actual = "message-content", metadata = testMetadata, reason = exception ) failed.shouldBeInstanceOf>() } test("should store failure reason") { val exception = IllegalStateException("Connection lost") val failed = FailedObservedMessage( actual = 42, metadata = testMetadata, reason = exception ) failed.actual shouldBe 42 failed.metadata shouldBe testMetadata failed.reason shouldBe exception } } context("Failure") { test("should wrap observed message with failure reason") { val observed = ObservedMessage( actual = "test-data", metadata = testMetadata ) val exception = RuntimeException("Assertion failed") val failure = Failure( message = observed, reason = exception ) failure.message shouldBe observed failure.reason shouldBe exception } test("should work with FailedObservedMessage") { val innerException = IllegalArgumentException("Parse error") val outerException = RuntimeException("Retry exhausted") val failedObserved = FailedObservedMessage( actual = "data", metadata = testMetadata, reason = innerException ) val failure = Failure( message = failedObserved, reason = outerException ) (failure.message as FailedObservedMessage).reason shouldBe innerException failure.reason shouldBe outerException } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/JsonReportRendererTest.kt ================================================ package com.trendyol.stove.reporting import com.fasterxml.jackson.databind.ObjectMapper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class JsonReportRendererTest : FunSpec({ test("generates valid JSON with entries and summary") { val report = TestReport("test-1", "should process order") report.record(ReportEntry.success("HTTP", "test-1", "POST /api")) report.record(ReportEntry.action("HTTP", "test-1", "status check", passed = true)) val json = JsonReportRenderer.render(report, emptyList()) val parsed = ObjectMapper().readTree(json) parsed["testId"].asText() shouldBe "test-1" parsed["testName"].asText() shouldBe "should process order" parsed["entries"].size() shouldBe 2 parsed["summary"]["total"].asInt() shouldBe 2 parsed["summary"]["passed"].asInt() shouldBe 2 parsed["summary"]["failed"].asInt() shouldBe 0 } test("includes system snapshots") { val report = TestReport("test-1", "test") val snapshot = SystemSnapshot( system = "Kafka", state = mapOf("consumed" to listOf(mapOf("topic" to "orders"))), summary = "1 message" ) val json = JsonReportRenderer.render(report, listOf(snapshot)) val parsed = ObjectMapper().readTree(json) parsed["systemSnapshots"]["Kafka"]["consumed"].size() shouldBe 1 parsed["systemSnapshots"]["Kafka"]["consumed"][0]["topic"].asText() shouldBe "orders" } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/PrettyConsoleRendererTest.kt ================================================ package com.trendyol.stove.reporting import arrow.core.Some import com.trendyol.stove.tracing.TraceVisualization import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain class PrettyConsoleRendererTest : FunSpec({ fun String.stripAnsi(): String = replace(Regex("\u001B\\[[0-9;]*m"), "") test("renders summary and timeline for successful entries") { val report = TestReport("test-1", "should process order") report.record( ReportEntry.success( system = "HTTP", testId = "test-1", action = "POST /api/orders", input = Some("{" + "\"id\":123}"), output = Some("201 Created") ) ) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "STOVE TEST EXECUTION REPORT" rendered shouldContain "should process order" rendered shouldContain "IN PROGRESS" rendered shouldContain "TIMELINE" rendered shouldContain "✓ PASSED" rendered shouldContain "POST /api/orders" rendered shouldContain "Input: {\"id\":123}" rendered shouldContain "Output: 201 Created" } test("renders failed assertions with expected actual and error details") { val report = TestReport("test-2", "should fail") report.record( ReportEntry.failure( system = "Kafka", testId = "test-2", action = "shouldBePublished", error = "expected:<2> but was:<1>", expected = Some(2), actual = Some(1) ) ) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "FAILED" rendered shouldContain "Expected: 2" rendered shouldContain "Actual: 1" rendered shouldContain "Error: expected:<2> but was:<1>" } test("groups sequential timeline entries by system") { val report = TestReport("test-2b", "grouped timeline") report.record(ReportEntry.success(system = "WireMock", testId = "test-2b", action = "Register stub A")) report.record(ReportEntry.success(system = "WireMock", testId = "test-2b", action = "Register stub B")) report.record(ReportEntry.success(system = "HTTP", testId = "test-2b", action = "GET /api/a")) report.record(ReportEntry.success(system = "HTTP", testId = "test-2b", action = "POST /api/b")) report.record(ReportEntry.success(system = "Kafka", testId = "test-2b", action = "Produce event")) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "WIREMOCK · 2 step(s)" rendered shouldContain "HTTP · 2 step(s)" rendered shouldContain "KAFKA · 1 step(s)" rendered shouldContain "#1 ✓ PASSED Register stub A" rendered shouldContain "#5 ✓ PASSED Produce event" } test("renders snapshots section with summary and state details") { val report = TestReport("test-3", "snapshot test") val snapshots = listOf( SystemSnapshot( system = "Kafka", summary = "Consumed: 1\nPublished: 0", state = mapOf( "consumed" to listOf(mapOf("topic" to "orders", "offset" to 42)), "failed" to emptyList() ) ) ) val rendered = PrettyConsoleRenderer.render(report, snapshots).stripAnsi() rendered shouldContain "SYSTEM SNAPSHOTS" rendered shouldContain "KAFKA" rendered shouldContain "Summary" rendered shouldContain "Consumed: 1" rendered shouldContain "State" rendered shouldContain "consumed: 1 item(s)" rendered shouldContain "topic: orders" rendered shouldContain "offset: 42" } test("renders execution trace details when trace data exists") { val report = TestReport("test-4", "trace test") val trace = TraceVisualization( traceId = "trace-123", testId = "test-4", totalSpans = 2, failedSpans = 1, spans = emptyList(), tree = "root span\n└─ child span ✗", coloredTree = "" ) report.record( ReportEntry.action( system = "HTTP", testId = "test-4", action = "POST /api/orders", passed = false, error = Some("500 Internal Server Error"), executionTrace = Some(trace) ) ) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "Execution Trace" rendered shouldContain "TraceId: trace-123" rendered shouldContain "Spans: 2 total / 1 failed" rendered shouldContain "root span" rendered shouldContain "child span" } test("renders empty timeline message for reports without entries") { val report = TestReport("test-5", "empty report") val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "No actions recorded yet." rendered shouldNotContain "SYSTEM SNAPSHOTS" } test("does not truncate very long values") { val report = TestReport("test-6", "long value") val longWord = "x".repeat(220) report.record( ReportEntry.success( system = "HTTP", testId = "test-6", action = "POST /api/long", input = Some(longWord) ) ) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "Input:" rendered shouldContain longWord.take(40) rendered shouldContain longWord.takeLast(40) rendered shouldNotContain "..." } test("wraps long detail lines with hanging indentation") { val report = TestReport("test-6a", "wrapped value") val longInput = buildString { append("CreateProductRequest(") append("storefrontId=1, brandId=1092122801123744494, businessUnitId=2496482862758973002, ") append("categoryId=3583527936634204334, code=TEST_03eacf0a-6c1d-4f34-a, ") append("requestedBarcode=BARCODE_12345678901234567890, supplierId=99)") } val longOutput = """{"productId":3,"code":"TEST_03eacf0a-6c1d-4f34-a","contents":[{"id":5,"variants":[{"id":5,"barcode":"TYBC4ZRD0TK70YBI05","requestedBarcode":"BARCODE_12345678901234567890"}]}]}""" report.record( ReportEntry.success( system = "HTTP", testId = "test-6a", action = "POST /products", input = Some(longInput), output = Some(longOutput) ) ) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() rendered shouldContain "Input: CreateProductRequest(" rendered shouldContain "Output: {\"productId\":3" (rendered.lines().maxOf { it.length } <= 160) shouldBe true rendered shouldContain "\n│ ST_03eacf0a-6c1d-4f34-a, requestedBarcode=BARCODE_12345678901234567890, supplierId=99)" rendered shouldContain "\n│ \"BARCODE_12345678901234567890\"}]}]}" } test("uses a compact width for small reports") { val report = TestReport("test-6b", "small report") report.record(ReportEntry.success(system = "HTTP", testId = "test-6b", action = "GET /health")) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() val widths = rendered .lines() .filter { it.isNotBlank() } .map { it.length } .toSet() val width = widths.first() widths.size shouldBe 1 (width < 120) shouldBe true } test("caps width for large reports") { val report = TestReport("test-6c", "large report") report.record( ReportEntry.failure( system = "HTTP", testId = "test-6c", action = "GET /very/long/endpoint/that/should/not/make/the/report/unreasonably/wide", error = "x".repeat(300) ) ) val rendered = PrettyConsoleRenderer.render(report, emptyList()).stripAnsi() val width = rendered.lines().first { it.isNotBlank() }.length (width <= 160) shouldBe true } test("renders real-world like report fixture for visual iteration") { val testId = "ExampleTest::should create new product when send product create request from api" val report = TestReport(testId, "should create new product when send product create request from api") report.record( ReportEntry.success( system = "WireMock", testId = testId, action = "Register stub: GET /api/suppliers/99", metadata = mapOf("priority" to 1, "responseStatus" to 200) ) ) report.record( ReportEntry.success( system = "WireMock", testId = testId, action = "Register stub: GET /inventory/products/1", metadata = mapOf("priority" to 1, "responseStatus" to 200) ) ) report.record( ReportEntry.success( system = "WireMock", testId = testId, action = "Register stub: POST /payments/charge", metadata = mapOf("priority" to 2, "responseStatus" to 200) ) ) report.record( ReportEntry.success( system = "WireMock", testId = testId, action = "Register stub: POST /inventory/sync", metadata = mapOf("priority" to 1, "responseStatus" to 200) ) ) report.record( ReportEntry.success( system = "HTTP", testId = testId, action = "GET /api/suppliers/99", output = Some("""{"status":200,"response":{"id":99,"name":"supplier name"}}""") ) ) report.record( ReportEntry.success( system = "Kafka", testId = testId, action = "KafkaProducer.send product command", output = Some("topic=trendyol.stove.service.productCommand.1 offset=41") ) ) report.record( ReportEntry.success( system = "PostgreSQL", testId = testId, action = "INSERT INTO outbox(product_id, status)", metadata = mapOf("rowsAffected" to 1, "table" to "outbox") ) ) report.record( ReportEntry.success( system = "Kafka", testId = testId, action = "KafkaConsumer.consume product command", output = Some("topic=trendyol.stove.service.productCommand.1 offset=41") ) ) report.record( ReportEntry.success( system = "HTTP", testId = testId, action = "POST /api/product/create", input = Some("ProductCreateRequest(id=1, name=product name, supplierId=99)"), output = Some("""{"status":201,"response":{"id":1,"status":"DRAFT"}}""") ) ) report.record( ReportEntry.success( system = "Kafka", testId = testId, action = "KafkaProducer.send product created event", output = Some("topic=trendyol.stove.service.productCreated.1 offset=0") ) ) report.record( ReportEntry.success( system = "PostgreSQL", testId = testId, action = "UPDATE outbox SET sent=true WHERE id=91", metadata = mapOf("rowsAffected" to 1, "table" to "outbox") ) ) report.record( ReportEntry.success( system = "WireMock", testId = testId, action = "Verify downstream call: POST /inventory/sync", metadata = mapOf("called" to true, "times" to 1) ) ) report.record( ReportEntry.success( system = "PostgreSQL", testId = testId, action = "SELECT status FROM products WHERE id=1", metadata = mapOf("rowsReturned" to 1, "table" to "products") ) ) val trace = TraceVisualization( traceId = "00-49242638d15b4e29ba49750d2089633f-87ab5cab1dd5b41d-01", testId = testId, totalSpans = 15, failedSpans = 1, spans = emptyList(), tree = """ GET /api/products/1 [412ms] ✗ | http.response.status_code: 200 | http.route: /api/products/{id} | http.request.method: GET ProductQueryController.get [109ms] ✓ ProductQueryService.findById [78ms] ✓ PostgreSQL.queryProductById [44ms] ✓ WireMock.inventory.getById [31ms] ✓ KafkaProducer.send inventory-check [27ms] ✓ HTTP.inventory.sync [29ms] ✓ PostgreSQL.updateInventoryProjection [41ms] ✓ InventorySyncHandler.handle [34ms] ✗ | messaging.kafka.topic: trendyol.stove.service.inventorySync.1 | error.type: INVENTORY_STATE_MISMATCH """.trimIndent(), coloredTree = "" ) report.record( ReportEntry.action( system = "HTTP", testId = testId, action = "GET /api/products/1", passed = false, input = Some("ProductQueryRequest(id=1)"), output = Some("""{"status":200,"response":{"id":1,"status":"DRAFT"}}"""), metadata = mapOf("status" to 200, "headers" to emptyMap()), expected = Some("Product status ACTIVE"), actual = Some("Product status DRAFT"), error = Some("expected: but was:"), executionTrace = Some(trace) ) ) report.record( ReportEntry.success( system = "Kafka", testId = testId, action = "KafkaProducer.send compensation event", output = Some("topic=trendyol.stove.service.productCompensation.1 offset=2") ) ) report.record( ReportEntry.success( system = "HTTP", testId = testId, action = "POST /api/product/compensate", input = Some("""{"id":1,"reason":"STATUS_MISMATCH"}"""), output = Some("""{"status":202,"response":{"queued":true}}""") ) ) report.record( ReportEntry.success( system = "Kafka", testId = testId, action = "KafkaConsumer.consume compensation event", output = Some("topic=trendyol.stove.service.productCompensation.1 offset=2") ) ) report.record( ReportEntry.success( system = "WireMock", testId = testId, action = "Verify downstream call: GET /inventory/products/1", metadata = mapOf("called" to true, "times" to 2) ) ) val snapshots = listOf( SystemSnapshot( system = "HTTP", state = mapOf( "requests" to listOf( mapOf("method" to "GET", "path" to "/api/suppliers/99", "status" to 200), mapOf("method" to "POST", "path" to "/api/product/create", "status" to 201), mapOf("method" to "GET", "path" to "/api/products/1", "status" to 200), mapOf("method" to "POST", "path" to "/api/product/compensate", "status" to 202) ), "lastRequest" to mapOf("method" to "GET", "path" to "/api/products/1"), "lastResponse" to mapOf("status" to 200, "body" to mapOf("id" to 1, "status" to "DRAFT")) ), summary = "Requests (this test): 4\nLast response status: 200" ), SystemSnapshot( system = "Kafka", summary = """ Consumed (this test): 3 Produced (this test): 4 Failed (this test): 1 """.trimIndent(), state = mapOf( "consumed" to listOf( mapOf( "messageId" to "consumed-1", "topic" to "trendyol.stove.service.productCreated.1", "key" to 1, "offset" to 0, "headers" to mapOf( "traceparent" to "00-49242638d15b4e29ba49750d2089633f-87ab5cab1dd5b41d-01", "baggage" to "stove.test.id=$testId", "__TypeId__" to "stove.spring.example4x.application.handlers.ProductCreatedEvent" ), "value" to mapOf("id" to 1, "name" to "product name", "status" to "DRAFT") ), mapOf( "messageId" to "consumed-2", "topic" to "trendyol.stove.service.productCommand.1", "key" to 1, "offset" to 41, "value" to mapOf("id" to 1, "command" to "CREATE", "tags" to listOf("new", "campaign")) ), mapOf( "messageId" to "consumed-3", "topic" to "trendyol.stove.service.productCompensation.1", "key" to 1, "offset" to 2, "value" to mapOf("id" to 1, "reason" to "STATUS_MISMATCH") ) ), "produced" to listOf( mapOf( "topic" to "trendyol.stove.service.productCommand.1", "key" to 1, "value" to mapOf("id" to 1, "command" to "CREATE") ), mapOf( "topic" to "trendyol.stove.service.productCreated.1", "key" to 1, "value" to mapOf("id" to 1, "name" to "product name", "status" to "DRAFT") ), mapOf( "topic" to "trendyol.stove.service.productCompensation.1", "key" to 1, "value" to mapOf("id" to 1, "reason" to "STATUS_MISMATCH") ), mapOf( "topic" to "trendyol.stove.service.inventorySync.1", "key" to 1, "value" to mapOf("id" to 1, "expectedStatus" to "ACTIVE", "actualStatus" to "DRAFT") ) ), "failed" to listOf( mapOf( "topic" to "trendyol.stove.service.inventorySync.1", "key" to 1, "reason" to "INVENTORY_STATE_MISMATCH", "payload" to mapOf("id" to 1, "expectedStatus" to "ACTIVE", "actualStatus" to "DRAFT") ) ) ) ), SystemSnapshot( system = "PostgreSQL", summary = """ Select queries: 4 Insert queries: 2 Update queries: 2 Errors: 0 """.trimIndent(), state = mapOf( "tables" to mapOf( "products" to listOf( mapOf("id" to 1, "name" to "product name", "status" to "DRAFT") ), "outbox" to listOf( mapOf("id" to 91, "type" to "ProductCreatedEvent", "sent" to true), mapOf("id" to 92, "type" to "ProductCompensationEvent", "sent" to false) ) ) ) ), SystemSnapshot( system = "WireMock", summary = """ Registered stubs (this test): 5 Served requests (this test): 4 (matched: 4) Unmatched requests: 0 """.trimIndent(), state = mapOf( "registeredStubs" to listOf( mapOf("method" to "GET", "url" to "/api/suppliers/99", "status" to 200), mapOf("method" to "POST", "url" to "/api/product/create", "status" to 201), mapOf("method" to "GET", "url" to "/inventory/products/1", "status" to 200), mapOf("method" to "POST", "url" to "/payments/charge", "status" to 200), mapOf("method" to "POST", "url" to "/inventory/sync", "status" to 200) ), "servedRequests" to listOf( mapOf("method" to "GET", "url" to "/api/suppliers/99", "matched" to true), mapOf("method" to "POST", "url" to "/api/product/create", "matched" to true), mapOf("method" to "GET", "url" to "/inventory/products/1", "matched" to true), mapOf("method" to "POST", "url" to "/inventory/sync", "matched" to true) ), "unmatchedRequests" to emptyList() ) ) ) val rendered = PrettyConsoleRenderer.render(report, snapshots) val plainRendered = rendered.stripAnsi() println("\n" + "=".repeat(140)) println("VISUAL ITERATION FIXTURE - REAL WORLD REPORT") println("=".repeat(140)) println(rendered) println("=".repeat(140)) plainRendered shouldContain "STOVE TEST EXECUTION REPORT" plainRendered shouldContain "TIMELINE" plainRendered shouldContain "SYSTEM SNAPSHOTS" plainRendered shouldContain "Execution Trace" plainRendered shouldContain "InventorySyncHandler.handle [34ms] ✗" plainRendered shouldContain "Failed (this test): 1" plainRendered shouldContain "expected: but was:" plainRendered shouldContain "PostgreSQL" plainRendered shouldContain "KafkaProducer.send compensation event" } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/ReportEntryTest.kt ================================================ package com.trendyol.stove.reporting import arrow.core.Some import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class ReportEntryTest : FunSpec({ test("ReportEntry generates correct summary") { val entry = ReportEntry.success("HTTP", "test-1", "POST /api/users") entry.summary shouldBe "[HTTP] POST /api/users" } test("ReportEntry with failed result is detected as failure") { val entry = ReportEntry.failure( system = "PostgreSQL", testId = "test-1", action = "Query", error = "Row count mismatch" ) entry.isFailed shouldBe true entry.isPassed shouldBe false } test("ReportEntry captures failure details with Option") { val entry = ReportEntry.action( system = "HTTP", testId = "test-1", action = "Response status check", passed = false, expected = Some(200), actual = Some(500), error = Some("Expected 200 but got 500") ) entry.isFailed shouldBe true entry.error shouldBe Some("Expected 200 but got 500") entry.summary shouldBe "[HTTP] Response status check" } test("AssertionResult.of converts boolean correctly") { AssertionResult.of(true) shouldBe AssertionResult.PASSED AssertionResult.of(false) shouldBe AssertionResult.FAILED } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/ReportEventListenerTest.kt ================================================ package com.trendyol.stove.reporting import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe class ReportEventListenerTest : FunSpec({ test("listener receives all lifecycle events") { val reporter = StoveReporter() val events = mutableListOf() val listener = object : ReportEventListener { override fun onTestStarted(ctx: StoveTestContext) { events.add("started:${ctx.testId}") } override fun onTestEnded(testId: String) { events.add("ended:$testId") } override fun onEntryRecorded(entry: ReportEntry) { events.add("entry:${entry.action}") } } reporter.addListener(listener) reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "GET /api")) reporter.endTest() events shouldBe listOf("started:test-1", "entry:GET /api", "ended:test-1") } test("throwing listener does not break reporter or other listeners") { val reporter = StoveReporter() val received = mutableListOf() val brokenListener = object : ReportEventListener { override fun onTestStarted(ctx: StoveTestContext) { error("boom") } override fun onEntryRecorded(entry: ReportEntry) { error("boom") } override fun onTestEnded(testId: String) { error("boom") } } val goodListener = object : ReportEventListener { override fun onTestStarted(ctx: StoveTestContext) { received.add("started") } override fun onEntryRecorded(entry: ReportEntry) { received.add("entry") } override fun onTestEnded(testId: String) { received.add("ended") } } reporter.addListener(brokenListener) reporter.addListener(goodListener) reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "GET /api")) reporter.endTest() received shouldBe listOf("started", "entry", "ended") } test("removed listener stops receiving events") { val reporter = StoveReporter() val events = mutableListOf() val listener = object : ReportEventListener { override fun onEntryRecorded(entry: ReportEntry) { events.add(entry.action) } } reporter.addListener(listener) reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "first")) reporter.removeListener(listener) reporter.record(ReportEntry.success("HTTP", "test-1", "second")) events shouldHaveSize 1 events[0] shouldBe "first" } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/ReportsTest.kt ================================================ package com.trendyol.stove.reporting import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.PluggedSystem import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain class ReportsTest : FunSpec({ context("reportSystemName") { test("should return class name without System suffix") { val stove = Stove() val reports = TestReportsSystem(stove) reports.reportSystemName shouldBe "TestReports" } test("should handle class name ending with System") { val stove = Stove() val reports = AnotherTestSystem(stove) reports.reportSystemName shouldBe "AnotherTest" } } context("reporter") { test("should return reporter from PluggedSystem") { val stove = Stove() val reports = TestReportsSystem(stove) reports.reporter shouldBe stove.reporter } test("should throw when not a PluggedSystem") { val reports = object : Reports {} shouldThrow { reports.reporter }.message shouldContain "Reports must be implemented by a PluggedSystem" } } context("snapshot") { test("should return default snapshot") { val stove = Stove() val reports = TestReportsSystem(stove) val snapshot = reports.snapshot() snapshot.system shouldBe "TestReports" snapshot.state shouldBe emptyMap() snapshot.summary shouldBe "No detailed state available" } test("can be overridden to provide custom snapshot") { val stove = Stove() val reports = CustomSnapshotSystem(stove) val snapshot = reports.snapshot() snapshot.system shouldBe "CustomSnapshot" snapshot.state shouldBe mapOf("key" to "value") snapshot.summary shouldBe "Custom snapshot" } } context("report") { test("should record success entry and return result") { val stove = Stove() val reports = TestReportsSystem(stove) val reporter = stove.reporter reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) val result = reports.report(action = "action") { "ok" } result shouldBe "ok" reporter.currentTest().entries().size shouldBe 1 reporter .currentTest() .entries() .first() .isPassed shouldBe true } test("should record failure entry and rethrow") { val stove = Stove() val reports = TestReportsSystem(stove) val reporter = stove.reporter reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) val error = shouldThrow { reports.report(action = "action") { error("boom") } } error.message shouldBe "boom" reporter .currentTest() .entries() .first() .isFailed shouldBe true } } }) /** * Test implementation of Reports interface that also implements PluggedSystem. */ private class TestReportsSystem( override val stove: Stove ) : PluggedSystem, Reports { override fun then(): Stove = stove override fun close() = Unit } /** * Another test system to verify suffix removal. */ private class AnotherTestSystem( override val stove: Stove ) : PluggedSystem, Reports { override fun then(): Stove = stove override fun close() = Unit } /** * Test system with custom snapshot. */ private class CustomSnapshotSystem( override val stove: Stove ) : PluggedSystem, Reports { override fun then(): Stove = stove override fun close() = Unit override fun snapshot(): SystemSnapshot = SystemSnapshot( system = reportSystemName, state = mapOf("key" to "value"), summary = "Custom snapshot" ) } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/StoveReporterTest.kt ================================================ package com.trendyol.stove.reporting import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotBeEmpty class StoveReporterTest : FunSpec({ test("starts test and creates report") { val reporter = StoveReporter() val ctx = StoveTestContext("TestSpec::test1", "test1", "TestSpec") reporter.startTest(ctx) val report = reporter.currentTest() report.testId shouldBe "TestSpec::test1" report.testName shouldBe "test1" } test("records entries when enabled") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "GET /api")) reporter.currentTest().entries() shouldHaveSize 1 } test("ignores entries when disabled") { val reporter = StoveReporter(isEnabled = false) reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "GET /api")) reporter.currentTest().entries() shouldHaveSize 0 } test("uses default test ID when no context") { val reporter = StoveReporter() reporter.currentTestId() shouldBe "default" reporter.currentTest().testId shouldBe "default" } test("clears context after endTest") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.endTest() reporter.currentTestId() shouldBe "default" } test("endTest emits lifecycle event when only StoveTestContextHolder is set") { val reporter = StoveReporter() val ended = mutableListOf() val ctx = StoveTestContext("test-holder", "holder test") val listener = object : ReportEventListener { override fun onTestEnded(testId: String) { ended.add(testId) } } reporter.addListener(listener) StoveTestContextHolder.set(ctx) try { reporter.endTest() } finally { StoveTestContextHolder.clear() } ended shouldBe listOf("test-holder") } test("endTest keeps current test context visible while listeners run") { val reporter = StoveReporter() val observedTestIds = mutableListOf() val ctx = StoveTestContext("test-listener", "listener test") val listener = object : ReportEventListener { override fun onTestEnded(testId: String) { observedTestIds.add(reporter.currentTestId()) } } reporter.addListener(listener) reporter.startTest(ctx) reporter.endTest() observedTestIds shouldBe listOf("test-listener") reporter.currentTestId() shouldBe "default" } test("detects failures correctly") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.hasFailures() shouldBe false reporter.record(ReportEntry.failure("HTTP", "test-1", "check", "assertion failed")) reporter.hasFailures() shouldBe true } test("currentTestOrNull returns null when no test started") { val reporter = StoveReporter() reporter.currentTestOrNull() shouldBe null } test("currentTestOrNull returns test after startTest") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.currentTestOrNull().shouldNotBeNull() reporter.currentTestOrNull()?.testId shouldBe "test-1" } test("clear removes entries from current test") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "GET /api")) reporter.currentTest().entries() shouldHaveSize 1 reporter.clear() reporter.currentTest().entries() shouldHaveSize 0 } test("dump returns empty string when no test exists") { val reporter = StoveReporter() val result = reporter.dump(PrettyConsoleRenderer) result shouldBe "" } test("dumpIfFailed returns empty string when no failures") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.success("HTTP", "test-1", "GET /api")) val result = reporter.dumpIfFailed() result shouldBe "" } test("dumpIfFailed returns report when there are failures") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "test1")) reporter.record(ReportEntry.failure("HTTP", "test-1", "GET /api", "Not found")) val result = reporter.dumpIfFailed() result.shouldNotBeEmpty() result.replace(Regex("\u001B\\[[0-9;]*m"), "") shouldContain "FAILED" } test("collectSnapshots returns empty list when Stove not initialized") { val reporter = StoveReporter() val snapshots = reporter.collectSnapshots() snapshots shouldBe emptyList() } test("hasFailures returns false when no test context") { val reporter = StoveReporter() reporter.hasFailures() shouldBe false } test("multiple tests can be tracked independently") { val reporter = StoveReporter() reporter.startTest(StoveTestContext("test-1", "first test")) reporter.record(ReportEntry.success("HTTP", "test-1", "action1")) reporter.endTest() reporter.startTest(StoveTestContext("test-2", "second test")) reporter.record(ReportEntry.failure("Kafka", "test-2", "action2", "error")) reporter.endTest() // Start test-1 again to check its state reporter.startTest(StoveTestContext("test-1", "first test")) reporter.currentTest().entries() shouldHaveSize 1 reporter.hasFailures() shouldBe false } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/StoveTestContextTest.kt ================================================ package com.trendyol.stove.reporting import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.withContext class StoveTestContextTest : FunSpec({ test("StoveTestContext is a CoroutineContext element") { val ctx = StoveTestContext("TestSpec::test1", "test1", "TestSpec") ctx.testId shouldBe "TestSpec::test1" ctx.testName shouldBe "test1" ctx.specName shouldBe "TestSpec" ctx.key shouldBe StoveTestContext.Key } test("currentStoveTestContext retrieves context from coroutine") { val ctx = StoveTestContext("test-1", "test1") val contextWithStove = currentCoroutineContext() + ctx withContext(contextWithStove) { currentStoveTestContext() shouldBe ctx } } test("StoveTestContextHolder stores context in ThreadLocal") { val ctx = StoveTestContext("test-1", "test1") StoveTestContextHolder.set(ctx) StoveTestContextHolder.get() shouldBe ctx StoveTestContextHolder.clear() StoveTestContextHolder.get() shouldBe null } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/StoveTestExceptionsTest.kt ================================================ package com.trendyol.stove.reporting import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf class StoveTestExceptionsTest : FunSpec({ context("StoveTestFailureException") { test("should extend AssertionError") { val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "Report content" ) exception.shouldBeInstanceOf() } test("should format message with original message and report") { val exception = StoveTestFailureException( originalMessage = "expected:<200> but was:<500>", stoveReport = "HTTP GET /api/test - FAILED" ) exception.message shouldContain "expected:<200> but was:<500>" exception.message shouldContain "STOVE EXECUTION REPORT" exception.message shouldContain "HTTP GET /api/test - FAILED" } test("should preserve cause") { val cause = RuntimeException("Original error") val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "Report", cause = cause ) exception.cause shouldBe cause } test("should copy stack trace from cause") { val cause = RuntimeException("Original error") val originalStackTrace = cause.stackTrace val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "Report", cause = cause ) exception.stackTrace shouldBe originalStackTrace } test("should handle null cause") { val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "Report", cause = null ) exception.cause shouldBe null } test("should include separator line in message") { val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "Report" ) exception.message shouldContain "═══════════════════════════════════════════════════════════════════════════════" } } context("StoveTestErrorException") { test("should extend Exception") { val exception = StoveTestErrorException( originalMessage = "Error occurred", stoveReport = "Report content" ) exception.shouldBeInstanceOf() } test("should format message with original message and report") { val exception = StoveTestErrorException( originalMessage = "Connection refused", stoveReport = "Kafka publish - ERROR" ) exception.message shouldContain "Connection refused" exception.message shouldContain "STOVE EXECUTION REPORT" exception.message shouldContain "Kafka publish - ERROR" } test("should preserve cause") { val cause = IllegalStateException("Invalid state") val exception = StoveTestErrorException( originalMessage = "Error occurred", stoveReport = "Report", cause = cause ) exception.cause shouldBe cause } test("should copy stack trace from cause") { val cause = IllegalStateException("Invalid state") val originalStackTrace = cause.stackTrace val exception = StoveTestErrorException( originalMessage = "Error occurred", stoveReport = "Report", cause = cause ) exception.stackTrace shouldBe originalStackTrace } test("should handle null cause") { val exception = StoveTestErrorException( originalMessage = "Error occurred", stoveReport = "Report", cause = null ) exception.cause shouldBe null } } context("message formatting") { test("should handle multiline original message") { val exception = StoveTestFailureException( originalMessage = "Line 1\nLine 2\nLine 3", stoveReport = "Report" ) exception.message shouldContain "Line 1" exception.message shouldContain "Line 2" exception.message shouldContain "Line 3" } test("should handle multiline report") { val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "Step 1: OK\nStep 2: FAILED\nStep 3: SKIPPED" ) exception.message shouldContain "Step 1: OK" exception.message shouldContain "Step 2: FAILED" exception.message shouldContain "Step 3: SKIPPED" } test("should handle empty report") { val exception = StoveTestFailureException( originalMessage = "Test failed", stoveReport = "" ) exception.message shouldContain "Test failed" exception.message?.trim() shouldBe "Test failed" } test("should handle special characters in message") { val exception = StoveTestFailureException( originalMessage = "Expected: {\"id\": 1} but was: {\"id\": 2}", stoveReport = "JSON comparison failed" ) exception.message shouldContain "{\"id\": 1}" exception.message shouldContain "{\"id\": 2}" } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/SystemSnapshotTest.kt ================================================ package com.trendyol.stove.reporting import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class SystemSnapshotTest : FunSpec({ test("should store system name, state, and summary") { val snapshot = SystemSnapshot( system = "Kafka", state = mapOf( "consumed" to listOf("msg1", "msg2"), "published" to emptyList() ), summary = "Consumed: 2, Published: 0" ) snapshot.system shouldBe "Kafka" snapshot.state["consumed"] shouldBe listOf("msg1", "msg2") snapshot.summary shouldBe "Consumed: 2, Published: 0" } test("should handle empty state") { val snapshot = SystemSnapshot( system = "HTTP", state = emptyMap(), summary = "No requests recorded" ) snapshot.state shouldBe emptyMap() } test("should handle complex nested state") { val snapshot = SystemSnapshot( system = "WireMock", state = mapOf( "stubs" to listOf( mapOf("url" to "/api/users", "method" to "GET"), mapOf("url" to "/api/orders", "method" to "POST") ), "unmatched" to listOf( mapOf("url" to "/api/unknown", "count" to 3) ) ), summary = "Stubs: 2, Unmatched: 1" ) val stubs = snapshot.state["stubs"] as List<*> stubs.size shouldBe 2 } test("should handle multiline summary") { val snapshot = SystemSnapshot( system = "PostgreSQL", state = mapOf("tables" to listOf("users", "orders")), summary = """ |Tables: 2 |Rows inserted: 150 |Last query: SELECT * FROM users """.trimMargin() ) snapshot.summary.lines().size shouldBe 3 } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/TestReportTest.kt ================================================ package com.trendyol.stove.reporting import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe class TestReportTest : FunSpec({ test("records entries in chronological order") { val report = TestReport("test-1", "test") val entry1 = ReportEntry.success("HTTP", "test-1", "GET /api") val entry2 = ReportEntry.success("Kafka", "test-1", "Publish") val entry3 = ReportEntry.action("Kafka", "test-1", "consumed", passed = true) report.record(entry1) report.record(entry2) report.record(entry3) report.entries() shouldHaveSize 3 } test("filters failures correctly") { val report = TestReport("test-1", "test") report.record(ReportEntry.action("HTTP", "test-1", "check", passed = true)) report.record(ReportEntry.action("Kafka", "test-1", "check", passed = false)) report.record(ReportEntry.failure("PostgreSQL", "test-1", "Query", "timeout")) report.failures() shouldHaveSize 2 report.hasFailures() shouldBe true } test("filters entries by testId") { val report = TestReport("test-1", "test") report.record(ReportEntry.success("HTTP", "test-1", "action")) report.record(ReportEntry.success("HTTP", "test-2", "other test")) report.entries() shouldHaveSize 2 report.entriesForThisTest() shouldHaveSize 1 report.entriesForThisTest().all { it.testId == "test-1" } shouldBe true } test("clear removes all entries") { val report = TestReport("test-1", "test") report.record(ReportEntry.success("HTTP", "test-1", "action")) report.clear() report.entries() shouldBe emptyList() } test("extension functions filter correctly") { val entries = listOf( ReportEntry.success("HTTP", "test-1", "action"), ReportEntry.action("Kafka", "test-1", "check", passed = true), ReportEntry.action("HTTP", "test-1", "check", passed = false) ) entries.forSystem("HTTP") shouldHaveSize 2 entries.failures() shouldHaveSize 1 entries.passed() shouldHaveSize 2 } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/reporting/TraceProviderTest.kt ================================================ package com.trendyol.stove.reporting import arrow.core.None import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class TraceProviderTest : FunSpec({ test("default wait time should be 300ms") { val provider = CapturingTraceProvider() provider.getTraceVisualizationForCurrentTest() provider.lastWaitTime shouldBe 300L } test("custom wait time should be respected") { val provider = CapturingTraceProvider() provider.getTraceVisualizationForCurrentTest(1234) provider.lastWaitTime shouldBe 1234L } }) private class CapturingTraceProvider : TraceProvider { var lastWaitTime: Long? = null override fun getTraceVisualizationForCurrentTest(waitTimeMs: Long) = None.also { lastWaitTime = waitTimeMs } } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/serialization/SerializationTests.kt ================================================ package com.trendyol.stove.serialization import arrow.core.None import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.databind.MapperFeature import com.google.gson.JsonSyntaxException import com.trendyol.stove.serialization.StoveSerde.Companion.deserialize import com.trendyol.stove.serialization.StoveSerde.Companion.deserializeOption import com.trendyol.stove.serialization.StoveSerde.StoveSerdeProblem import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.* import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.serialization.* class SerializerTest : FunSpec({ @Serializable data class TestData( val id: Int, val name: String, val tags: List = listOf(), @SerialName("created_at") val createdAt: String? = null ) val testData = TestData( id = 1, name = "Test Item", tags = listOf("tag1", "tag2"), createdAt = "2024-01-01" ) context("StoveJacksonStringSerializer") { val serializer = StoveSerde.jackson.anyJsonStringSerde() test("should serialize and deserialize object correctly") { val serialized = serializer.serialize(testData) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe testData serialized shouldContain "\"id\":1" serialized shouldContain "\"name\":\"Test Item\"" } test("should handle null values") { val dataWithNull = testData.copy(createdAt = null) val serialized = serializer.serialize(dataWithNull) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe dataWithNull serialized shouldNotContain "created_at" } test("should throw exception for invalid JSON") { shouldThrow { serializer.deserialize("invalid json", TestData::class.java) } } test("should return None when invalid JSON is deserialized") { val a = serializer.deserializeOption("invalid json") a shouldBe None } test("should return Left when invalid JSON is deserialized") { val op = serializer.deserializeEither("invalid json", TestData::class.java) op.shouldBeLeft() op.value.message shouldContain "Unrecognized token 'invalid': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')" op.value.shouldBeInstanceOf() } } context("StoveGsonStringSerializer") { val serializer = StoveSerde.gson.anyJsonStringSerde() test("should serialize and deserialize object correctly") { val serialized = serializer.serialize(testData) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe testData serialized shouldContain "\"id\":1" serialized shouldContain "\"name\":\"Test Item\"" } test("should handle null values") { val dataWithNull = testData.copy(createdAt = null) val serialized = serializer.serialize(dataWithNull) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe dataWithNull serialized shouldNotContain "created_at" } test("should throw exception for invalid JSON") { shouldThrow { serializer.deserialize("invalid json", TestData::class.java) } } } context("StoveKotlinxStringSerializer") { val serializer = StoveSerde.kotlinx.anyJsonStringSerde() test("should serialize and deserialize object correctly") { val serialized = serializer.serialize(testData) val deserialized = serializer.deserialize(serialized, TestData::class.java) val deserializedTyped = serializer.deserialize(serialized) deserialized shouldBe testData deserializedTyped shouldBe testData serialized shouldContain "\"id\":1" serialized shouldContain "\"name\":\"Test Item\"" } test("should handle null values") { val dataWithNull = testData.copy(createdAt = null) val serialized = serializer.serialize(dataWithNull) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe dataWithNull serialized shouldNotContain "created_at" } test("should throw exception for invalid JSON") { shouldThrow { serializer.deserialize("invalid json", TestData::class.java) } } } context("Edge cases for all serializers") { val jacksonSerializer = StoveSerde.jackson.anyJsonStringSerde() val gsonSerializer = StoveSerde.gson.anyJsonStringSerde() val kotlinxSerializer = StoveSerde.kotlinx.anyJsonStringSerde() test("should handle empty lists") { val dataWithEmptyList = testData.copy(tags = emptyList()) listOf(jacksonSerializer, gsonSerializer, kotlinxSerializer).forEach { serializer -> val serialized = serializer.serialize(dataWithEmptyList) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe dataWithEmptyList serialized shouldContain "\"tags\":[]" } } test("should handle special characters") { val dataWithSpecialChars = testData.copy(name = "Test \"Item\" with \\special/ chars") listOf(jacksonSerializer, gsonSerializer, kotlinxSerializer).forEach { serializer -> val serialized = serializer.serialize(dataWithSpecialChars) val deserialized = serializer.deserialize(serialized, TestData::class.java) deserialized shouldBe dataWithSpecialChars } } } context("configuring tests") { test("should configure StoveGson") { val gson = StoveGson.byConfiguring { setPrettyPrinting() } val serializer = StoveGsonStringSerializer(gson) val serialized = serializer.serialize(testData) serialized shouldContain "\n" } test("should configure StoveKotlinx") { val json = StoveKotlinx.byConfiguring { ignoreUnknownKeys = false prettyPrint = true } val serializer = StoveKotlinxStringSerializer(json) val serialized = serializer.serialize(testData) serialized shouldContain "\n" } test("should configure StoveJackson") { val objectMapper = StoveSerde.jackson.byConfiguring { enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT) } val serializer = StoveJacksonStringSerializer(objectMapper) val serialized = serializer.serialize(testData) serialized shouldContain "\n" } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/BridgeSystemGuardTest.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.reporting.StoveTestContext import com.trendyol.stove.system.abstractions.PluggedSystem import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import kotlin.reflect.KClass class BridgeSystemGuardTest : FunSpec({ test("using throws when context is not initialized") { val stove = Stove() val bridge = UninitializedBridgeSystem(stove) stove.getOrRegister>(bridge) stove.reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) val error = shouldThrow { runBlocking { ValidationDsl(stove).using { } } } error.message shouldContain "BridgeSystem context is not initialized" error.message shouldContain "providedApplication()" } test("using works after context is initialized") { val stove = Stove() val bean = TestBean("hello") val bridge = UninitializedBridgeSystem(stove, mapOf(TestBean::class to bean)) stove.getOrRegister>(bridge) // Initialize context runBlocking { bridge.afterRun(TestCtx()) } stove.reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) runBlocking { ValidationDsl(stove).using { value shouldBe "hello" } } } }) private data class TestBean(val value: String) private class TestCtx private class UninitializedBridgeSystem( override val stove: Stove, private val beans: Map, Any> = emptyMap() ) : BridgeSystem(stove), PluggedSystem { override fun then(): Stove = stove @Suppress("UNCHECKED_CAST") override fun get(klass: KClass): D = beans[klass] as? D ?: error("Missing bean for ${klass.simpleName}") } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/BridgeSystemTest.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.reporting.StoveTestContext import com.trendyol.stove.system.abstractions.PluggedSystem import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import kotlin.reflect.KClass class BridgeSystemTest : FunSpec({ test("using records success for single bean") { val stove = Stove() val serviceA = ServiceA("initial") val bridge = TestBridgeSystem(stove, mapOf(ServiceA::class to serviceA)) stove.getOrRegister>(bridge) runBlocking { bridge.afterRun(TestContext()) } stove.reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) runBlocking { ValidationDsl(stove).using { value = "updated" } } val entry = stove.reporter .currentTest() .entries() .single() entry.isPassed shouldBe true entry.action shouldContain "Bean usage: ServiceA" serviceA.value shouldBe "updated" } test("using records failure and rethrows") { val stove = Stove() val serviceA = ServiceA("initial") val bridge = TestBridgeSystem(stove, mapOf(ServiceA::class to serviceA)) stove.getOrRegister>(bridge) runBlocking { bridge.afterRun(TestContext()) } stove.reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) val error = shouldThrow { runBlocking { ValidationDsl(stove).using { error("boom") } } } error.message shouldBe "boom" val entry = stove.reporter .currentTest() .entries() .single() entry.isFailed shouldBe true entry.action shouldContain "Bean usage: ServiceA" } test("using with two beans records success") { val stove = Stove() val serviceA = ServiceA("a") val serviceB = ServiceB(42) val bridge = TestBridgeSystem( stove, mapOf( ServiceA::class to serviceA, ServiceB::class to serviceB ) ) stove.getOrRegister>(bridge) runBlocking { bridge.afterRun(TestContext()) } stove.reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) runBlocking { ValidationDsl(stove).using { a, b -> a.value shouldBe "a" b.number shouldBe 42 } } val entry = stove.reporter .currentTest() .entries() .single() entry.isPassed shouldBe true entry.action shouldContain "Bean usage: ServiceA, ServiceB" } }) private data class ServiceA( var value: String ) private data class ServiceB( val number: Int ) private class TestContext private class TestBridgeSystem( override val stove: Stove, private val beans: Map, Any> ) : BridgeSystem(stove), PluggedSystem { override fun then(): Stove = stove @Suppress("UNCHECKED_CAST") override fun get(klass: KClass): D = beans[klass] as? D ?: error("Missing bean for ${klass.simpleName}") } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/KeyedSystemTest.kt ================================================ package com.trendyol.stove.system import arrow.core.None import arrow.core.Some import com.trendyol.stove.system.abstractions.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeSameInstanceAs import kotlinx.coroutines.runBlocking private object KeyA : SystemKey private object KeyB : SystemKey class KeyedSystemTest : FunSpec({ test("keyed getOrRegister stores and returns system") { val stove = Stove() val system = KeyedTestSystem(stove) val registered = stove.getOrRegister(KeyA, system) registered shouldBeSameInstanceAs system } test("keyed getOrRegister returns existing instance for same key") { val stove = Stove() val system1 = KeyedTestSystem(stove) val system2 = KeyedTestSystem(stove) val first = stove.getOrRegister(KeyA, system1) val second = stove.getOrRegister(KeyA, system2) first shouldBeSameInstanceAs second first shouldBeSameInstanceAs system1 } test("different keys for same type store different instances") { val stove = Stove() val systemA = KeyedTestSystem(stove) val systemB = KeyedTestSystem(stove) val registeredA = stove.getOrRegister(KeyA, systemA) val registeredB = stove.getOrRegister(KeyB, systemB) registeredA shouldBeSameInstanceAs systemA registeredB shouldBeSameInstanceAs systemB registeredA shouldBe systemA registeredB shouldBe systemB } test("keyed getOrNone returns None when not registered") { val stove = Stove() stove.getOrNone(KeyA) shouldBe None } test("keyed getOrNone returns Some when registered") { val stove = Stove() val system = KeyedTestSystem(stove) stove.getOrRegister(KeyA, system) val result = stove.getOrNone(KeyA) result shouldBe Some(system) } test("keyed and default systems coexist independently") { val stove = Stove() val defaultSystem = KeyedTestSystem(stove) val keyedSystem = KeyedTestSystem(stove) stove.getOrRegister(defaultSystem) stove.getOrRegister(KeyA, keyedSystem) stove.getOrNone() shouldBe Some(defaultSystem) stove.getOrNone(KeyA) shouldBe Some(keyedSystem) } test("allRegisteredSystems includes both default and keyed systems") { val stove = Stove() val defaultSystem = KeyedTestSystem(stove) val keyedSystemA = KeyedTestSystem(stove) val keyedSystemB = KeyedTestSystem(stove) stove.getOrRegister(defaultSystem) stove.getOrRegister(KeyA, keyedSystemA) stove.getOrRegister(KeyB, keyedSystemB) val all = stove.allRegisteredSystems() all shouldHaveSize 3 all.shouldContainAll(defaultSystem, keyedSystemA, keyedSystemB) } test("systemsOf returns matching systems from both maps") { val stove = Stove() val defaultSystem = KeyedLifecycleSystem(stove) val keyedSystem = KeyedLifecycleSystem(stove) stove.getOrRegister(defaultSystem) stove.getOrRegister(KeyA, keyedSystem) val runAwareSystems = stove.systemsOf() runAwareSystems shouldHaveSize 2 } test("allSystems returns all registered systems") { val stove = Stove() val system1 = KeyedTestSystem(stove) val system2 = KeyedTestSystem(stove) stove.getOrRegister(system1) stove.getOrRegister(KeyA, system2) stove.allSystems() shouldHaveSize 2 } test("keyed systems participate in run lifecycle") { val stove = Stove() val defaultSystem = KeyedLifecycleSystem(stove) val keyedSystem = KeyedLifecycleSystem(stove) val app = KeyedTestApp() stove.getOrRegister(defaultSystem) stove.getOrRegister(KeyA, keyedSystem) stove.applicationUnderTest(app) runBlocking { stove.run() } defaultSystem.beforeRunCalled shouldBe true defaultSystem.runCalled shouldBe true defaultSystem.afterRunCalled shouldBe true keyedSystem.beforeRunCalled shouldBe true keyedSystem.runCalled shouldBe true keyedSystem.afterRunCalled shouldBe true app.started shouldBe true app.receivedConfigs shouldHaveSize 2 } test("keyed systems contribute configurations") { val stove = Stove() val defaultSystem = KeyedLifecycleSystem(stove, configValue = "default.config=true") val keyedSystem = KeyedLifecycleSystem(stove, configValue = "keyed.config=true") val app = KeyedTestApp() stove.getOrRegister(defaultSystem) stove.getOrRegister(KeyA, keyedSystem) stove.applicationUnderTest(app) runBlocking { stove.run() } app.receivedConfigs.shouldContainAll("default.config=true", "keyed.config=true") } }) private class KeyedTestSystem( override val stove: Stove ) : PluggedSystem { override fun then(): Stove = stove override fun close() = Unit } private class KeyedTestApp : ApplicationUnderTest { var started: Boolean = false var receivedConfigs: List = emptyList() override suspend fun start(configurations: List): String { started = true receivedConfigs = configurations return "context" } override suspend fun stop() = Unit } private class KeyedLifecycleSystem( override val stove: Stove, private val configValue: String = "system.config=true" ) : PluggedSystem, BeforeRunAware, RunAware, AfterRunAware, ExposesConfiguration { var beforeRunCalled: Boolean = false var runCalled: Boolean = false var afterRunCalled: Boolean = false override suspend fun beforeRun() { beforeRunCalled = true } override suspend fun run() { runCalled = true } override suspend fun stop() = Unit override suspend fun afterRun() { afterRunCalled = true } override fun configuration(): List = listOf(configValue) override fun then(): Stove = stove override fun close() = Unit } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/PortFinderTest.kt ================================================ package com.trendyol.stove.system import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.shouldBe import java.net.ServerSocket class PortFinderTest : FunSpec({ test("findAvailablePort should return a usable port") { val port = PortFinder.findAvailablePort() port shouldBeGreaterThan 0 PortFinder.isPortAvailable(port) shouldBe true } test("findAvailablePortFrom should skip occupied ports") { ServerSocket(0).use { socket -> val occupied = socket.localPort val found = PortFinder.findAvailablePortFrom(occupied) found shouldBeGreaterThan 0 found shouldBeGreaterThan occupied } } test("findAvailablePortAsString should return numeric string") { val portStr = PortFinder.findAvailablePortAsString() portStr.toInt() shouldBeGreaterThan 0 } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/ProvidedApplicationUnderTestTest.kt ================================================ package com.trendyol.stove.system import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import kotlin.time.Duration.Companion.milliseconds class ProvidedApplicationUnderTestTest : FunSpec({ test("start with no health check is a no-op") { val aut = ProvidedApplicationUnderTest(ProvidedApplicationOptions()) runBlocking { aut.start(listOf("some.config=true")) } // No exception — success } test("stop is a no-op") { val aut = ProvidedApplicationUnderTest(ProvidedApplicationOptions()) runBlocking { aut.stop() } // No exception — success } test("configurations are ignored") { val aut = ProvidedApplicationUnderTest(ProvidedApplicationOptions()) runBlocking { aut.start( listOf( "database.host=localhost", "kafka.bootstrap=localhost:9092" ) ) } // No exception — configs silently ignored } test("readiness check fails with unreachable URL") { val aut = ProvidedApplicationUnderTest( ProvidedApplicationOptions( readiness = ReadinessStrategy.HttpGet( url = "http://localhost:1/nonexistent-health", retries = 2, retryDelay = 50.milliseconds, timeout = 500.milliseconds ) ) ) val error = shouldThrow { runBlocking { aut.start(emptyList()) } } error.message shouldContain "Health check failed after 2 attempts" error.message shouldContain "nonexistent-health" } test("provided application integrates with Stove lifecycle") { val stove = Stove() stove.applicationUnderTest( ProvidedApplicationUnderTest(ProvidedApplicationOptions()) ) runBlocking { stove.run() } Stove.instanceInitialized() shouldBe true } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/ReadinessCheckerTest.kt ================================================ package com.trendyol.stove.system import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import java.net.ServerSocket import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class ReadinessCheckerTest : FunSpec({ context("HttpGet strategy") { test("passes when endpoint returns expected status") { val port = ServerSocket(0).use { it.localPort } val server = com.sun.net.httpserver.HttpServer.create(java.net.InetSocketAddress(port), 0) server.createContext("/health") { exchange -> exchange.sendResponseHeaders(200, 0) exchange.responseBody.close() } server.start() try { ReadinessChecker.check( ReadinessStrategy.HttpGet( url = "http://localhost:$port/health", retries = 3, retryDelay = 100.milliseconds, timeout = 2.seconds ) ) } finally { server.stop(0) } } test("fails after retries when endpoint is unreachable") { val error = shouldThrow { ReadinessChecker.check( ReadinessStrategy.HttpGet( url = "http://localhost:1/nonexistent", retries = 2, retryDelay = 50.milliseconds, timeout = 500.milliseconds ) ) } error.message shouldContain "Health check failed after 2 attempts" } test("fails when endpoint returns unexpected status code") { val port = ServerSocket(0).use { it.localPort } val server = com.sun.net.httpserver.HttpServer.create(java.net.InetSocketAddress(port), 0) server.createContext("/health") { exchange -> exchange.sendResponseHeaders(503, 0) exchange.responseBody.close() } server.start() try { val error = shouldThrow { ReadinessChecker.check( ReadinessStrategy.HttpGet( url = "http://localhost:$port/health", retries = 2, retryDelay = 50.milliseconds, timeout = 2.seconds ) ) } error.message shouldContain "Health check failed after 2 attempts" } finally { server.stop(0) } } test("accepts custom expected status codes") { val port = ServerSocket(0).use { it.localPort } val server = com.sun.net.httpserver.HttpServer.create(java.net.InetSocketAddress(port), 0) server.createContext("/health") { exchange -> exchange.sendResponseHeaders(204, -1) exchange.responseBody.close() } server.start() try { ReadinessChecker.check( ReadinessStrategy.HttpGet( url = "http://localhost:$port/health", retries = 2, retryDelay = 50.milliseconds, timeout = 2.seconds, expectedStatusCodes = setOf(204) ) ) } finally { server.stop(0) } } } context("TcpPort strategy") { test("passes when port is open") { val server = ServerSocket(0) val port = server.localPort try { ReadinessChecker.check( ReadinessStrategy.TcpPort( port = port, retries = 3, retryDelay = 50.milliseconds ) ) } finally { server.close() } } test("fails after retries when port is closed") { // Use port 1 which is almost certainly not open val error = shouldThrow { ReadinessChecker.check( ReadinessStrategy.TcpPort( port = 1, retries = 2, retryDelay = 50.milliseconds ) ) } error.message shouldContain "TCP port 1 did not open after 2 attempts" } } context("Probe strategy") { test("passes when probe returns true") { ReadinessChecker.check( ReadinessStrategy.Probe(retries = 3, retryDelay = 50.milliseconds) { true } ) } test("fails after retries when probe returns false") { val error = shouldThrow { ReadinessChecker.check( ReadinessStrategy.Probe(retries = 2, retryDelay = 50.milliseconds) { false } ) } error.message shouldContain "Readiness probe did not pass after 2 attempts" } test("fails after retries when probe throws") { val error = shouldThrow { ReadinessChecker.check( ReadinessStrategy.Probe(retries = 2, retryDelay = 50.milliseconds) { error("Connection refused") } ) } error.message shouldContain "Readiness probe did not pass after 2 attempts" } test("passes after initial failures") { var attempt = 0 ReadinessChecker.check( ReadinessStrategy.Probe(retries = 5, retryDelay = 50.milliseconds) { attempt++ attempt >= 3 } ) attempt shouldBe 3 } } context("FixedDelay strategy") { test("completes after specified delay") { val start = System.currentTimeMillis() ReadinessChecker.check(ReadinessStrategy.FixedDelay(200.milliseconds)) val elapsed = System.currentTimeMillis() - start (elapsed >= 180) shouldBe true } } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/StoveOptionsDslTest.kt ================================================ package com.trendyol.stove.system import com.trendyol.stove.system.abstractions.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlin.reflect.KClass class StoveOptionsDslTest : FunSpec({ test("should keep dependencies running") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.keepDependenciesRunning() stoveOptionsDsl.options.keepDependenciesRunning shouldBe true } test("should check if running locally") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.isRunningLocally() shouldBe ( System.getenv("CI") != "true" && System.getenv("GITLAB_CI") != "true" && System.getenv("GITHUB_ACTIONS") != "true" ) } test("should enable reuse for test containers") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.enableReuseForTestContainers() } test("should set state storage factory") { val stoveOptionsDsl = StoveOptionsDsl() class AnotherStateStorageFactory : StateStorageFactory { override fun invoke( options: StoveOptions, system: KClass<*>, state: KClass ): StateStorage = object : StateStorage { override suspend fun capture(start: suspend () -> T): T = start() override fun isSubsequentRun(): Boolean = false } } data class Example1ExposedState( val id: Int = 1 ) : ExposedConfiguration class Example1System( override val stove: Stove ) : PluggedSystem { override fun close() = Unit } val anotherStateStorageFactory = AnotherStateStorageFactory() stoveOptionsDsl.stateStorage(anotherStateStorageFactory) stoveOptionsDsl.options.stateStorageFactory shouldBe anotherStateStorageFactory val storage = stoveOptionsDsl.options.createStateStorage() storage.isSubsequentRun() shouldBe false storage.capture { Example1ExposedState() } shouldBe Example1ExposedState() } test("should run migrations always") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.runMigrationsAlways() stoveOptionsDsl.options.runMigrationsAlways shouldBe true } test("should enable reporting") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.reportingEnabled(true) stoveOptionsDsl.options.reportingEnabled shouldBe true } test("should disable reporting") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.reportingEnabled(false) stoveOptionsDsl.options.reportingEnabled shouldBe false } test("should enable dump report on test failure") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.dumpReportOnTestFailure(true) stoveOptionsDsl.options.dumpReportOnTestFailure shouldBe true } test("should disable dump report on test failure") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.dumpReportOnTestFailure(false) stoveOptionsDsl.options.dumpReportOnTestFailure shouldBe false } test("should configure reporting via DSL block") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.reporting { enabled() dumpOnFailure() } stoveOptionsDsl.options.reportingEnabled shouldBe true stoveOptionsDsl.options.dumpReportOnTestFailure shouldBe true } test("should disable reporting via DSL block") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl.reportingEnabled(true) stoveOptionsDsl.reporting { disabled() } stoveOptionsDsl.options.reportingEnabled shouldBe false } test("should chain multiple options fluently") { val stoveOptionsDsl = StoveOptionsDsl() stoveOptionsDsl .reportingEnabled(true) .dumpReportOnTestFailure(true) .runMigrationsAlways() stoveOptionsDsl.options.reportingEnabled shouldBe true stoveOptionsDsl.options.dumpReportOnTestFailure shouldBe true stoveOptionsDsl.options.runMigrationsAlways shouldBe true } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/StoveTest.kt ================================================ package com.trendyol.stove.system import arrow.core.None import com.trendyol.stove.system.abstractions.* import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.runBlocking class StoveTest : FunSpec({ test("getOrRegister returns existing instance") { val stove = Stove() val system = TestLifecycleSystem(stove) val first = stove.getOrRegister(system) val second = stove.getOrRegister(system) first shouldBe second } test("getOrNone returns None when system missing") { val stove = Stove() stove.getOrNone() shouldBe None } test("run invokes lifecycle and passes configurations") { val stove = Stove() val system = TestLifecycleSystem(stove) val app = TestApplicationUnderTest() stove.getOrRegister(system) stove.applicationUnderTest(app) runBlocking { stove.run() } system.beforeRunCalled shouldBe true system.runCalled shouldBe true system.afterRunCalled shouldBe true app.started shouldBe true app.receivedConfigs shouldBe listOf("system.config=true") stove.applicationUnderTestContext() shouldBe "context" Stove.instanceInitialized() shouldBe true } test("stove validation DSL throws when not initialized") { if (!Stove.instanceInitialized()) { shouldThrow { runBlocking { stove { } } } } else { runBlocking { stove { } } } } }) private class TestApplicationUnderTest : ApplicationUnderTest { var started: Boolean = false var receivedConfigs: List = emptyList() override suspend fun start(configurations: List): String { started = true receivedConfigs = configurations return "context" } override suspend fun stop() = Unit } private class TestLifecycleSystem( override val stove: Stove ) : PluggedSystem, BeforeRunAware, RunAware, AfterRunAware, ExposesConfiguration { var beforeRunCalled: Boolean = false var runCalled: Boolean = false var afterRunCalled: Boolean = false override suspend fun beforeRun() { beforeRunCalled = true } override suspend fun run() { runCalled = true } override suspend fun stop() = Unit override suspend fun afterRun() { afterRunCalled = true } override fun configuration(): List = listOf("system.config=true") override fun then(): Stove = stove override fun close() = Unit } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/ValidationDslTest.kt ================================================ package com.trendyol.stove.system import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class ValidationDslTest : FunSpec({ test("should expose stove instance") { val stove = Stove() val dsl = ValidationDsl(stove) dsl.stove shouldBe stove } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/abstractions/ProvidedSystemOptionsTest.kt ================================================ package com.trendyol.stove.system.abstractions import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe /** * Unit tests for the ProvidedSystemOptions interface. */ class ProvidedSystemOptionsTest : FunSpec({ /** * Test exposed configuration. */ data class TestExposedConfiguration( val host: String, val port: Int ) : ExposedConfiguration /** * Base system options (container mode). */ open class TestSystemOptions( val name: String, override val configureExposedConfiguration: (TestExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration /** * Provided system options (external instance mode). */ class ProvidedTestSystemOptions( override val providedConfig: TestExposedConfiguration, override val runMigrationsForProvided: Boolean = true, name: String = "provided", configureExposedConfiguration: (TestExposedConfiguration) -> List ) : TestSystemOptions(name, configureExposedConfiguration), ProvidedSystemOptions test("ProvidedSystemOptions instance check should work with base type reference") { val providedOptions: TestSystemOptions = ProvidedTestSystemOptions( providedConfig = TestExposedConfiguration("localhost", 8080), runMigrationsForProvided = true, configureExposedConfiguration = { listOf() } ) // When referenced through base type, instance check is meaningful (providedOptions is ProvidedSystemOptions<*>) shouldBe true } test("providedConfig should hold the configuration") { val config = TestExposedConfiguration("external-host", 9090) val providedOptions = ProvidedTestSystemOptions( providedConfig = config, configureExposedConfiguration = { listOf() } ) providedOptions.providedConfig shouldBe config providedOptions.providedConfig.host shouldBe "external-host" providedOptions.providedConfig.port shouldBe 9090 } test("runMigrationsForProvided should default to true") { val providedOptions = ProvidedTestSystemOptions( providedConfig = TestExposedConfiguration("localhost", 8080), configureExposedConfiguration = { listOf() } ) providedOptions.runMigrationsForProvided shouldBe true } test("runMigrationsForProvided can be set to false") { val providedOptions = ProvidedTestSystemOptions( providedConfig = TestExposedConfiguration("localhost", 8080), runMigrationsForProvided = false, configureExposedConfiguration = { listOf() } ) providedOptions.runMigrationsForProvided shouldBe false } test("base options should not be ProvidedSystemOptions") { val baseOptions = TestSystemOptions( name = "base", configureExposedConfiguration = { listOf() } ) // Base options is not a ProvidedSystemOptions (baseOptions is ProvidedSystemOptions<*>) shouldBe false } test("provided options should inherit from base options") { val providedOptions = ProvidedTestSystemOptions( providedConfig = TestExposedConfiguration("localhost", 8080), name = "inherited-name", configureExposedConfiguration = { cfg -> listOf("host=${cfg.host}", "port=${cfg.port}") } ) // Should have base class properties providedOptions.name shouldBe "inherited-name" // Should produce correct configuration val config = providedOptions.configureExposedConfiguration(providedOptions.providedConfig) config shouldBe listOf("host=localhost", "port=8080") } test("type checking can distinguish between base and provided options") { val baseOptions: TestSystemOptions = TestSystemOptions( name = "base", configureExposedConfiguration = { listOf() } ) val providedOptions: TestSystemOptions = ProvidedTestSystemOptions( providedConfig = TestExposedConfiguration("localhost", 8080), configureExposedConfiguration = { listOf() } ) // Using when expression to distinguish fun getMode(options: TestSystemOptions): String = when (options) { is ProvidedTestSystemOptions -> "provided" else -> "container" } getMode(baseOptions) shouldBe "container" getMode(providedOptions) shouldBe "provided" } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/abstractions/StateStorageKeyTest.kt ================================================ package com.trendyol.stove.system.abstractions import com.trendyol.stove.system.StoveOptions import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import java.nio.file.Paths class StateStorageKeyTest : FunSpec({ test("FileSystemStorage without key uses original path format") { val options = StoveOptions() val storage = FileSystemStorage(options, TestSystem::class, TestConfig::class) val expectedPath = Paths.get( System.getProperty("java.io.tmpdir"), "com.trendyol.stove", "stove-e2e-testsystem.lock" ) // Access via reflection to verify path — the class is internal val pathField = storage.javaClass.getDeclaredField("pathForSystem") pathField.isAccessible = true val path = pathField.get(storage) as java.nio.file.Path path shouldBe expectedPath } test("FileSystemStorage with key includes key in path") { val options = StoveOptions() val storage = FileSystemStorage(options, TestSystem::class, TestConfig::class, keyName = "AppDb") val expectedPath = Paths.get( System.getProperty("java.io.tmpdir"), "com.trendyol.stove", "stove-e2e-testsystem-appdb.lock" ) val pathField = storage.javaClass.getDeclaredField("pathForSystem") pathField.isAccessible = true val path = pathField.get(storage) as java.nio.file.Path path shouldBe expectedPath } test("different keys produce different lock file paths") { val options = StoveOptions() val storageA = FileSystemStorage(options, TestSystem::class, TestConfig::class, keyName = "AppDb") val storageB = FileSystemStorage(options, TestSystem::class, TestConfig::class, keyName = "AnalyticsDb") val pathField = FileSystemStorage::class.java.getDeclaredField("pathForSystem") pathField.isAccessible = true val pathA = pathField.get(storageA) as java.nio.file.Path val pathB = pathField.get(storageB) as java.nio.file.Path pathA shouldNotBe pathB pathA.toString() shouldContain "appdb" pathB.toString() shouldContain "analyticsdb" } test("StateStorageFactory createWithKey default delegates to invoke") { var invokedWithSystem: Class<*>? = null val factory = object : StateStorageFactory { override fun invoke( options: StoveOptions, system: kotlin.reflect.KClass<*>, state: kotlin.reflect.KClass ): StateStorage { invokedWithSystem = system.java return FileSystemStorage(options, system, state) } } factory.createWithKey(StoveOptions(), TestSystem::class, TestConfig::class, "SomeKey") invokedWithSystem shouldBe TestSystem::class.java } test("DefaultStateStorageFactory createWithKey passes keyName to FileSystemStorage") { val factory = StateStorageFactory.Default() val storage = factory.createWithKey(StoveOptions(), TestSystem::class, TestConfig::class, "MyKey") val pathField = storage.javaClass.getDeclaredField("pathForSystem") pathField.isAccessible = true val path = pathField.get(storage) as java.nio.file.Path path.toString() shouldContain "mykey" } test("DefaultStateStorageFactory createWithKey with null key behaves like no key") { val factory = StateStorageFactory.Default() val storage = factory.createWithKey(StoveOptions(), TestSystem::class, TestConfig::class, null) val pathField = storage.javaClass.getDeclaredField("pathForSystem") pathField.isAccessible = true val path = pathField.get(storage) as java.nio.file.Path path.fileName.toString() shouldBe "stove-e2e-testsystem.lock" path.fileName.toString() shouldNotContain "-null" } }) private class TestSystem private data class TestConfig(val value: String = "test") : ExposedConfiguration ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/system/abstractions/SystemKeyTest.kt ================================================ package com.trendyol.stove.system.abstractions import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldMatch import io.kotest.matchers.string.shouldNotContain private object PaymentService : SystemKey private object OrderService : SystemKey class SystemKeyTest : FunSpec({ test("keyDisplayName returns simpleName for named objects") { keyDisplayName(PaymentService) shouldBe "PaymentService" keyDisplayName(OrderService) shouldBe "OrderService" } test("keyDisplayName sanitizes invalid filename characters") { val anonymousKey = object : SystemKey {} val name = keyDisplayName(anonymousKey) name shouldNotContain "<" name shouldNotContain ">" name shouldNotContain "/" name shouldNotContain "\\" name shouldMatch Regex("[a-zA-Z0-9._-]+") } test("different SystemKey objects have different classes") { PaymentService::class shouldNotBe OrderService::class } test("same SystemKey object always returns same class") { PaymentService::class shouldBe PaymentService::class } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/tracing/SpanInfoTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class SpanInfoTest : FunSpec({ test("durationMs should calculate milliseconds from nanoseconds") { val span = createSpan( startTimeNanos = 0L, endTimeNanos = 5_000_000L // 5ms in nanoseconds ) span.durationMs shouldBe 5L } test("durationMs should handle zero duration") { val span = createSpan( startTimeNanos = 1000L, endTimeNanos = 1000L ) span.durationMs shouldBe 0L } test("durationNanos should return raw nanosecond difference") { val span = createSpan( startTimeNanos = 100L, endTimeNanos = 500L ) span.durationNanos shouldBe 400L } test("isFailed should return true when status is ERROR") { val span = createSpan(status = SpanStatus.ERROR) span.isFailed shouldBe true span.isSuccess shouldBe false } test("isSuccess should return true when status is OK") { val span = createSpan(status = SpanStatus.OK) span.isSuccess shouldBe true span.isFailed shouldBe false } test("UNSET status should be neither failed nor success") { val span = createSpan(status = SpanStatus.UNSET) span.isFailed shouldBe false span.isSuccess shouldBe false } test("span with exception should preserve exception info") { val exception = ExceptionInfo( type = "java.lang.RuntimeException", message = "Something went wrong", stackTrace = listOf("at com.example.Test.method(Test.kt:10)") ) val span = createSpan(exception = exception) span.exception shouldBe exception span.exception?.type shouldBe "java.lang.RuntimeException" span.exception?.message shouldBe "Something went wrong" span.exception?.stackTrace?.size shouldBe 1 } test("span without exception should have null exception") { val span = createSpan() span.exception shouldBe null } test("span should preserve attributes") { val attrs = mapOf( "http.method" to "GET", "http.url" to "/api/test" ) val span = createSpan(attributes = attrs) span.attributes shouldBe attrs span.attributes["http.method"] shouldBe "GET" } test("span with empty attributes should have empty map") { val span = createSpan(attributes = emptyMap()) span.attributes shouldBe emptyMap() } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span456", parentSpanId: String? = null, operationName: String = "test-operation", serviceName: String = "test-service", startTimeNanos: Long = 0L, endTimeNanos: Long = 1_000_000L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ): SpanInfo = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes, exception = exception ) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/tracing/SpanTreeTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe class SpanTreeTest : FunSpec({ context("SpanNode") { test("hasFailedDescendants should return true when span itself is failed") { val node = SpanNode(createSpan(status = SpanStatus.ERROR)) node.hasFailedDescendants shouldBe true } test("hasFailedDescendants should return true when child is failed") { val child = SpanNode(createSpan(spanId = "child", status = SpanStatus.ERROR)) val parent = SpanNode(createSpan(spanId = "parent", status = SpanStatus.OK), listOf(child)) parent.hasFailedDescendants shouldBe true } test("hasFailedDescendants should return false when all spans are OK") { val child = SpanNode(createSpan(spanId = "child", status = SpanStatus.OK)) val parent = SpanNode(createSpan(spanId = "parent", status = SpanStatus.OK), listOf(child)) parent.hasFailedDescendants shouldBe false } test("depth should be 1 for leaf node") { val node = SpanNode(createSpan()) node.depth shouldBe 1 } test("depth should count nested levels") { val grandchild = SpanNode(createSpan(spanId = "grandchild")) val child = SpanNode(createSpan(spanId = "child"), listOf(grandchild)) val parent = SpanNode(createSpan(spanId = "parent"), listOf(child)) parent.depth shouldBe 3 } test("spanCount should count all spans in tree") { val grandchild = SpanNode(createSpan(spanId = "grandchild")) val child1 = SpanNode(createSpan(spanId = "child1"), listOf(grandchild)) val child2 = SpanNode(createSpan(spanId = "child2")) val parent = SpanNode(createSpan(spanId = "parent"), listOf(child1, child2)) parent.spanCount shouldBe 4 } test("findFailurePoint should return failed leaf node") { val failedChild = SpanNode(createSpan(spanId = "failed", status = SpanStatus.ERROR)) val parent = SpanNode(createSpan(spanId = "parent", status = SpanStatus.OK), listOf(failedChild)) val failurePoint = parent.findFailurePoint() failurePoint.shouldNotBeNull() failurePoint.span.spanId shouldBe "failed" } test("findFailurePoint should return deepest failure in chain") { val deepFailed = SpanNode(createSpan(spanId = "deep", status = SpanStatus.ERROR)) val middleFailed = SpanNode(createSpan(spanId = "middle", status = SpanStatus.ERROR), listOf(deepFailed)) val parent = SpanNode(createSpan(spanId = "parent", status = SpanStatus.OK), listOf(middleFailed)) val failurePoint = parent.findFailurePoint() failurePoint.shouldNotBeNull() failurePoint.span.spanId shouldBe "deep" } test("findFailurePoint should return null when no failures") { val node = SpanNode(createSpan(status = SpanStatus.OK)) node.findFailurePoint().shouldBeNull() } test("flatten should return all spans in tree") { val grandchild = SpanNode(createSpan(spanId = "grandchild")) val child = SpanNode(createSpan(spanId = "child"), listOf(grandchild)) val parent = SpanNode(createSpan(spanId = "parent"), listOf(child)) val flattened = parent.flatten() flattened shouldHaveSize 3 flattened.map { it.spanId } shouldContainExactly listOf("parent", "child", "grandchild") } } context("SpanTree.build") { test("should return null for empty list") { val result = SpanTree.build(emptyList()) result.shouldBeNull() } test("should build single node tree") { val span = createSpan(spanId = "root", parentSpanId = null) val result = SpanTree.build(listOf(span)) result.shouldNotBeNull() result.span.spanId shouldBe "root" result.children shouldHaveSize 0 } test("should build parent-child relationship") { val parent = createSpan(spanId = "parent", parentSpanId = null, startTimeNanos = 0) val child = createSpan(spanId = "child", parentSpanId = "parent", startTimeNanos = 100) val result = SpanTree.build(listOf(parent, child)) result.shouldNotBeNull() result.span.spanId shouldBe "parent" result.children shouldHaveSize 1 result.children[0].span.spanId shouldBe "child" } test("should order children by start time") { val parent = createSpan(spanId = "parent", parentSpanId = null, startTimeNanos = 0) val child1 = createSpan(spanId = "child1", parentSpanId = "parent", startTimeNanos = 200) val child2 = createSpan(spanId = "child2", parentSpanId = "parent", startTimeNanos = 100) val result = SpanTree.build(listOf(parent, child1, child2)) result.shouldNotBeNull() result.children shouldHaveSize 2 result.children[0].span.spanId shouldBe "child2" result.children[1].span.spanId shouldBe "child1" } test("should handle orphaned spans as roots") { val orphan = createSpan(spanId = "orphan", parentSpanId = "nonexistent") val result = SpanTree.build(listOf(orphan)) result.shouldNotBeNull() result.span.spanId shouldBe "orphan" } test("should create virtual root for multiple roots") { val root1 = createSpan(spanId = "root1", parentSpanId = null, startTimeNanos = 0) val root2 = createSpan(spanId = "root2", parentSpanId = null, startTimeNanos = 100) val result = SpanTree.build(listOf(root1, root2)) result.shouldNotBeNull() result.span.operationName shouldBe "trace-root" result.children shouldHaveSize 2 } test("should build deep tree structure") { val root = createSpan(spanId = "root", parentSpanId = null, startTimeNanos = 0) val child = createSpan(spanId = "child", parentSpanId = "root", startTimeNanos = 100) val grandchild = createSpan(spanId = "grandchild", parentSpanId = "child", startTimeNanos = 200) val result = SpanTree.build(listOf(root, child, grandchild)) result.shouldNotBeNull() result.depth shouldBe 3 result.spanCount shouldBe 3 } } context("SpanTree.findSpan") { test("should find span matching predicate") { val root = createSpan(spanId = "root", operationName = "root-op") val child = createSpan(spanId = "child", operationName = "child-op", parentSpanId = "root") val tree = SpanTree.build(listOf(root, child))!! val found = SpanTree.findSpan(tree) { it.operationName == "child-op" } found.shouldNotBeNull() found.span.spanId shouldBe "child" } test("should return null when no match") { val root = createSpan(spanId = "root") val tree = SpanTree.build(listOf(root))!! val found = SpanTree.findSpan(tree) { it.operationName == "nonexistent" } found.shouldBeNull() } } context("SpanTree.filterSpans") { test("should filter spans matching predicate") { val root = createSpan(spanId = "root", status = SpanStatus.OK) val child1 = createSpan(spanId = "child1", status = SpanStatus.ERROR, parentSpanId = "root") val child2 = createSpan(spanId = "child2", status = SpanStatus.ERROR, parentSpanId = "root") val tree = SpanTree.build(listOf(root, child1, child2))!! val filtered = SpanTree.filterSpans(tree) { it.status == SpanStatus.ERROR } filtered shouldHaveSize 2 filtered.map { it.span.spanId } shouldContainExactly listOf("child1", "child2") } test("should return empty list when no match") { val root = createSpan(spanId = "root", status = SpanStatus.OK) val tree = SpanTree.build(listOf(root))!! val filtered = SpanTree.filterSpans(tree) { it.status == SpanStatus.ERROR } filtered shouldHaveSize 0 } } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span456", parentSpanId: String? = null, operationName: String = "test-operation", serviceName: String = "test-service", startTimeNanos: Long = 0L, endTimeNanos: Long = 1_000_000L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ): SpanInfo = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes, exception = exception ) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/tracing/TraceContextTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldHaveLength import io.kotest.matchers.string.shouldMatch import io.opentelemetry.api.baggage.Baggage import io.opentelemetry.context.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class TraceContextTest : FunSpec({ beforeTest { TraceContext.clear() } afterTest { TraceContext.clear() } test("start should create a new TraceContext with valid IDs") { val ctx = TraceContext.start("test-1") ctx.testId shouldBe "test-1" ctx.traceId shouldHaveLength 32 ctx.rootSpanId shouldHaveLength 16 ctx.traceId shouldMatch Regex("[a-f0-9]{32}") ctx.rootSpanId shouldMatch Regex("[a-f0-9]{16}") } test("current should return the active context") { TraceContext.current().shouldBeNull() val ctx = TraceContext.start("test-1") TraceContext.current().shouldNotBeNull() TraceContext.current() shouldBe ctx } test("clear should remove the current context") { TraceContext.start("test-1") TraceContext.current().shouldNotBeNull() TraceContext.clear() TraceContext.current().shouldBeNull() } test("withCurrentPropagation should keep trace context across dispatcher switches") { val ctx = TraceContext.start("test-1") TraceContext.withCurrentPropagation { withContext(Dispatchers.Default) { TraceContext.current() shouldBe ctx } } } test("withPropagation should restore the previous context after completion") { val outer = TraceContext.start("outer-test") val inner = TraceContext( traceId = TraceContext.generateTraceId(), testId = "inner-test", rootSpanId = TraceContext.generateSpanId() ) TraceContext.withPropagation(inner) { TraceContext.current() shouldBe inner } TraceContext.current() shouldBe outer } test("toTraceparent should generate valid W3C traceparent header") { val ctx = TraceContext.start("test-1") val traceparent = ctx.toTraceparent() traceparent shouldMatch Regex("00-[a-f0-9]{32}-[a-f0-9]{16}-01") traceparent shouldBe "00-${ctx.traceId}-${ctx.rootSpanId}-01" } test("parseTraceparent should extract traceId and spanId") { val traceparent = "00-abcd1234abcd1234abcd1234abcd1234-1234567890abcdef-01" val result = TraceContext.parseTraceparent(traceparent) result.shouldNotBeNull() result.first shouldBe "abcd1234abcd1234abcd1234abcd1234" result.second shouldBe "1234567890abcdef" } test("parseTraceparent should return null for invalid format") { val invalidTraceparent = "invalid" val result = TraceContext.parseTraceparent(invalidTraceparent) result.shouldBeNull() } test("generateTraceId should produce unique IDs") { val ids = (1..100).map { TraceContext.generateTraceId() }.toSet() ids.size shouldBe 100 } test("generateSpanId should produce unique IDs") { val ids = (1..100).map { TraceContext.generateSpanId() }.toSet() ids.size shouldBe 100 } test("start should preserve existing OTel baggage entries") { val existingBaggage = Baggage .builder() .put("tenant.id", "acme-corp") .put("region", "eu-west-1") .build() val preExistingScope = Context.current().with(existingBaggage).makeCurrent() try { TraceContext.start("test-baggage") val activeBaggage = Baggage.fromContext(Context.current()) activeBaggage.getEntryValue("tenant.id") shouldBe "acme-corp" activeBaggage.getEntryValue("region") shouldBe "eu-west-1" activeBaggage.getEntryValue(TraceContext.BAGGAGE_TEST_ID_KEY) shouldBe "test-baggage" } finally { TraceContext.clear() preExistingScope.close() } } test("start should work when no pre-existing baggage exists") { TraceContext.start("test-no-prior-baggage") val activeBaggage = Baggage.fromContext(Context.current()) activeBaggage.getEntryValue(TraceContext.BAGGAGE_TEST_ID_KEY) shouldBe "test-no-prior-baggage" } test("sanitizeToAscii should sanitize Turkish characters") { val input = "ProductCreateCodeValidationTests::Geçerli, benzersiz code ile ürün oluşturma (happy-path)" val sanitized = TraceContext.sanitizeToAscii(input) sanitized shouldBe "ProductCreateCodeValidationTests::Gecerli, benzersiz code ile urun olusturma (happy-path)" // Verify all characters are ASCII printable sanitized.all { it.code in 0x20..0x7E } shouldBe true } test("sanitizeToAscii should handle various non-ASCII characters") { val input = "Test::äöü ñ café résumé naïve" val sanitized = TraceContext.sanitizeToAscii(input) sanitized shouldBe "Test::aou n cafe resume naive" sanitized.all { it.code in 0x20..0x7E } shouldBe true } test("sanitizeToAscii should preserve ASCII characters") { val input = "SimpleTest::simple test name 123" val sanitized = TraceContext.sanitizeToAscii(input) sanitized shouldBe "SimpleTest::simple test name 123" } test("sanitizeToAscii should handle Japanese characters with hash for uniqueness") { val input1 = "MyTest::日本語テスト" val input2 = "MyTest::別のテスト" val sanitized1 = TraceContext.sanitizeToAscii(input1) val sanitized2 = TraceContext.sanitizeToAscii(input2) // Japanese characters become underscores, but hash suffix ensures uniqueness sanitized1.all { it.code in 0x20..0x7E } shouldBe true sanitized2.all { it.code in 0x20..0x7E } shouldBe true // Different inputs should produce different outputs sanitized1 shouldNotBe sanitized2 // Should contain hash suffix (underscore followed by hex chars) sanitized1 shouldMatch Regex("MyTest::_______.+") } test("sanitizeToAscii should handle mixed scripts with hash") { val input = "Test::Hello世界Test" val sanitized = TraceContext.sanitizeToAscii(input) // Contains non-decomposable chars, so hash is added sanitized.all { it.code in 0x20..0x7E } shouldBe true sanitized shouldMatch Regex("Test::Hello__Test_.+") } }) ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/tracing/TraceTreeRendererTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain class TraceTreeRendererTest : FunSpec({ context("render") { test("should render single node with operation name and duration") { val node = SpanNode(createSpan(operationName = "GET /api/test", durationMs = 100)) val result = TraceTreeRenderer.render(node) result shouldContain "GET /api/test" result shouldContain "[100ms]" result shouldContain "✓" } test("should render failed span with failure marker") { val node = SpanNode(createSpan(status = SpanStatus.ERROR)) val result = TraceTreeRenderer.render(node) result shouldContain "✗" result shouldContain "FAILURE POINT" } test("should render parent-child hierarchy") { val child = SpanNode(createSpan(spanId = "child", operationName = "child-op")) val parent = SpanNode(createSpan(spanId = "parent", operationName = "parent-op"), listOf(child)) val result = TraceTreeRenderer.render(parent) result shouldContain "parent-op" result shouldContain "child-op" } test("should render all children") { val child1 = SpanNode(createSpan(spanId = "child1", operationName = "first-child")) val child2 = SpanNode(createSpan(spanId = "child2", operationName = "second-child")) val parent = SpanNode(createSpan(spanId = "parent", operationName = "parent-op"), listOf(child1, child2)) val result = TraceTreeRenderer.render(parent) result shouldContain "parent-op" result shouldContain "first-child" result shouldContain "second-child" } test("should render exception info for failed span") { val exception = ExceptionInfo( type = "RuntimeException", message = "Something failed", stackTrace = listOf("at Test.method(Test.kt:10)") ) val node = SpanNode(createSpan(status = SpanStatus.ERROR, exception = exception)) val result = TraceTreeRenderer.render(node) result shouldContain "Error: RuntimeException: Something failed" result shouldContain "at Test.method(Test.kt:10)" } test("should render relevant attributes when enabled") { val node = SpanNode( createSpan( attributes = mapOf( "http.method" to "POST", "http.url" to "/api/users", "custom.attr" to "ignored" ) ) ) val result = TraceTreeRenderer.render(node, includeAttributes = true) result shouldContain "http.method: POST" result shouldContain "http.url: /api/users" result shouldNotContain "custom.attr" } test("should not render attributes when disabled") { val node = SpanNode( createSpan( attributes = mapOf("http.method" to "GET") ) ) val result = TraceTreeRenderer.render(node, includeAttributes = false) result shouldNotContain "http.method" } test("should use custom attribute prefixes") { val node = SpanNode( createSpan( attributes = mapOf( "custom.key" to "value", "http.method" to "GET" ) ) ) val result = TraceTreeRenderer.render( node, includeAttributes = true, attributePrefixes = listOf("custom.") ) result shouldContain "custom.key: value" result shouldNotContain "http.method" } test("should mark deepest failure point only") { val deepFailed = SpanNode(createSpan(spanId = "deep", status = SpanStatus.ERROR)) val middleFailed = SpanNode(createSpan(spanId = "middle", status = SpanStatus.ERROR), listOf(deepFailed)) val parent = SpanNode(createSpan(spanId = "parent", status = SpanStatus.OK), listOf(middleFailed)) val result = TraceTreeRenderer.render(parent) // Only the deepest failure should have the marker val lines = result.lines() val markerCount = lines.count { it.contains("FAILURE POINT") } markerCount == 1 } } context("renderColored") { test("should include ANSI color codes for failed spans") { val node = SpanNode(createSpan(status = SpanStatus.ERROR, operationName = "failed-op")) val result = TraceTreeRenderer.renderColored(node) // Should contain ANSI escape codes result shouldContain "\u001B[" result shouldContain "failed-op" result shouldContain "✗" result shouldContain "FAILURE POINT" } test("should color success spans green") { val node = SpanNode(createSpan(status = SpanStatus.OK, operationName = "success-op")) val result = TraceTreeRenderer.renderColored(node) // Should contain bright green color code for checkmark result shouldContain "\u001B[92m✓" } test("should color failure marker in bold yellow") { val node = SpanNode(createSpan(status = SpanStatus.ERROR)) val result = TraceTreeRenderer.renderColored(node) // Should contain bold + bright yellow for failure marker result shouldContain "\u001B[1m\u001B[93m◄── FAILURE POINT" } test("should color exception info with red and yellow") { val exception = ExceptionInfo( type = "RuntimeException", message = "Test error", stackTrace = listOf("at Test.method(Test.kt:10)") ) val node = SpanNode(createSpan(status = SpanStatus.ERROR, exception = exception)) val result = TraceTreeRenderer.renderColored(node) // Exception type should be yellow result shouldContain "\u001B[33mRuntimeException" } } context("renderCompact") { test("should render compact format with indentation") { val child = SpanNode(createSpan(spanId = "child", operationName = "child-op", durationMs = 50)) val parent = SpanNode(createSpan(spanId = "parent", operationName = "parent-op", durationMs = 100), listOf(child)) val result = TraceTreeRenderer.renderCompact(parent) result shouldContain "✓ parent-op (100ms)" result shouldContain " ✓ child-op (50ms)" } test("should show failure status in compact format") { val node = SpanNode(createSpan(status = SpanStatus.ERROR, operationName = "failed-op")) val result = TraceTreeRenderer.renderCompact(node) result shouldContain "✗ failed-op" } } context("renderSummary") { test("should render trace summary with counts") { val child = SpanNode(createSpan(spanId = "child")) val parent = SpanNode(createSpan(spanId = "parent", durationMs = 200), listOf(child)) val result = TraceTreeRenderer.renderSummary(parent) result shouldContain "Trace Summary:" result shouldContain "Total spans: 2" result shouldContain "Failed spans: 0" result shouldContain "Total duration: 200ms" result shouldContain "Max depth: 2" } test("should include failure point info when failures exist") { val exception = ExceptionInfo( type = "TestException", message = "Test error" ) val failed = SpanNode( createSpan( spanId = "failed", operationName = "failed-operation", status = SpanStatus.ERROR, exception = exception ) ) val parent = SpanNode(createSpan(spanId = "parent"), listOf(failed)) val result = TraceTreeRenderer.renderSummary(parent) result shouldContain "Failed spans: 1" result shouldContain "Failure point: failed-operation" result shouldContain "Error: TestException: Test error" } test("should not include failure info when no failures") { val node = SpanNode(createSpan(status = SpanStatus.OK)) val result = TraceTreeRenderer.renderSummary(node) result shouldNotContain "Failure point" result shouldNotContain "Error:" } } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span456", parentSpanId: String? = null, operationName: String = "test-operation", serviceName: String = "test-service", startTimeNanos: Long = 0L, endTimeNanos: Long = 1_000_000L, durationMs: Long = 1L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ): SpanInfo { val actualEndTime = if (durationMs != 1L) { startTimeNanos + (durationMs * 1_000_000L) } else { endTimeNanos } return SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = actualEndTime, status = status, attributes = attributes, exception = exception ) } ================================================ FILE: lib/stove/src/test/kotlin/com/trendyol/stove/tracing/TraceVisualizationTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.doubles.shouldBeGreaterThan import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain class TraceVisualizationTest : FunSpec({ context("TraceVisualization.from") { test("should create visualization from spans") { val spans = listOf( createSpan(spanId = "span1", operationName = "op1"), createSpan(spanId = "span2", operationName = "op2", parentSpanId = "span1") ) val viz = TraceVisualization.from("trace-123", "test-1", spans) viz.traceId shouldBe "trace-123" viz.testId shouldBe "test-1" viz.totalSpans shouldBe 2 viz.spans.size shouldBe 2 } test("should count failed spans") { val spans = listOf( createSpan(spanId = "span1", status = SpanStatus.OK), createSpan(spanId = "span2", status = SpanStatus.ERROR), createSpan(spanId = "span3", status = SpanStatus.ERROR) ) val viz = TraceVisualization.from("trace-123", "test-1", spans) viz.failedSpans shouldBe 2 } test("should build tree representation") { val spans = listOf( createSpan(spanId = "root", operationName = "root-op"), createSpan(spanId = "child", operationName = "child-op", parentSpanId = "root") ) val viz = TraceVisualization.from("trace-123", "test-1", spans) viz.tree shouldContain "root-op" viz.tree shouldContain "child-op" } test("should handle empty spans list") { val viz = TraceVisualization.from("trace-123", "test-1", emptyList()) viz.totalSpans shouldBe 0 viz.failedSpans shouldBe 0 viz.spans shouldBe emptyList() viz.tree shouldBe "No spans in trace" } } context("VisualSpan.from") { test("should convert SpanInfo to VisualSpan") { val span = createSpan( spanId = "span-123", parentSpanId = "parent-456", operationName = "GET /api/test", serviceName = "test-service", startTimeNanos = 1000000L, endTimeNanos = 6000000L, status = SpanStatus.OK, attributes = mapOf("http.method" to "GET") ) val visual = VisualSpan.from(span) visual.spanId shouldBe "span-123" visual.parentSpanId shouldBe "parent-456" visual.operationName shouldBe "GET /api/test" visual.serviceName shouldBe "test-service" visual.durationMs shouldBe 5.0 visual.status shouldBe "OK" visual.attributes shouldBe mapOf("http.method" to "GET") } test("should calculate duration in milliseconds") { val span = createSpan( startTimeNanos = 0L, endTimeNanos = 10_000_000L // 10ms ) val visual = VisualSpan.from(span) visual.durationMs shouldBe 10.0 } test("should handle zero duration") { val span = createSpan( startTimeNanos = 1000L, endTimeNanos = 1000L ) val visual = VisualSpan.from(span) visual.durationMs shouldBe 0.0 } test("should handle sub-millisecond duration") { val span = createSpan( startTimeNanos = 0L, endTimeNanos = 500_000L // 0.5ms ) val visual = VisualSpan.from(span) visual.durationMs shouldBe 0.5 } test("should convert ERROR status") { val span = createSpan(status = SpanStatus.ERROR) val visual = VisualSpan.from(span) visual.status shouldBe "ERROR" } test("should convert UNSET status") { val span = createSpan(status = SpanStatus.UNSET) val visual = VisualSpan.from(span) visual.status shouldBe "UNSET" } test("should handle null parent span id") { val span = createSpan(parentSpanId = null) val visual = VisualSpan.from(span) visual.parentSpanId shouldBe null } test("should preserve empty attributes") { val span = createSpan(attributes = emptyMap()) val visual = VisualSpan.from(span) visual.attributes shouldBe emptyMap() } test("should return zero duration for invalid end time") { val span = SpanInfo( traceId = "trace", spanId = "span", parentSpanId = null, operationName = "op", serviceName = "svc", startTimeNanos = 1000L, endTimeNanos = 0L, // Invalid: end before start status = SpanStatus.OK ) val visual = VisualSpan.from(span) visual.durationMs shouldBe 0.0 } test("should handle large duration values") { val span = createSpan( startTimeNanos = 0L, endTimeNanos = 60_000_000_000L // 60 seconds ) val visual = VisualSpan.from(span) visual.durationMs shouldBe 60000.0 visual.durationMs shouldBeGreaterThan 0.0 } } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span456", parentSpanId: String? = null, operationName: String = "test-operation", serviceName: String = "test-service", startTimeNanos: Long = 0L, endTimeNanos: Long = 1_000_000L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ): SpanInfo = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes, exception = exception ) ================================================ FILE: lib/stove/src/test/resources/logback.xml ================================================ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n ================================================ FILE: lib/stove/src/testFixtures/kotlin/com/trendyol/stove/CapturedOutput.kt ================================================ package com.trendyol.stove import io.kotest.core.spec.style.FunSpec import java.io.ByteArrayOutputStream import java.io.PrintStream class CapturedOutput( private val outBuffer: ByteArrayOutputStream, private val errBuffer: ByteArrayOutputStream ) { val out: String get() = outBuffer.toString() val err: String get() = errBuffer.toString() } abstract class ConsoleSpec( body: ConsoleSpec.(CapturedOutput) -> Unit = {} ) : FunSpec({ val originalOut = System.out val originalErr = System.err val outBuffer = ByteArrayOutputStream() val errBuffer = ByteArrayOutputStream() val capturedOutput = CapturedOutput(outBuffer, errBuffer) beforeSpec { System.setOut(PrintStream(outBuffer)) System.setErr(PrintStream(outBuffer)) } afterSpec { System.setOut(originalOut) System.setOut(originalErr) } beforeEach { outBuffer.reset() errBuffer.reset() } body(this as ConsoleSpec, capturedOutput) }) ================================================ FILE: lib/stove-bom/build.gradle.kts ================================================ plugins { `java-platform` alias(libs.plugins.maven.publish) } javaPlatform { allowDependencies() } dependencies { constraints { // Core api(projects.lib.stove) // Infrastructure api(projects.lib.stoveCouchbase) api(projects.lib.stoveElasticsearch) api(projects.lib.stoveGrpc) api(projects.lib.stoveHttp) api(projects.lib.stoveKafka) api(projects.lib.stoveMongodb) api(projects.lib.stoveRdbms) api(projects.lib.stovePostgres) api(projects.lib.stoveMysql) api(projects.lib.stoveMssql) api(projects.lib.stoveRedis) api(projects.lib.stoveCassandra) api(projects.lib.stoveWiremock) api(projects.lib.stoveGrpcMock) api(projects.lib.stoveTracing) api(projects.lib.stoveDashboard) api(projects.lib.stoveDashboardApi) // Starters api(projects.starters.spring.stoveSpring) api(projects.starters.spring.stoveSpringKafka) api(projects.starters.ktor.stoveKtor) api(projects.starters.quarkus.stoveQuarkus) api(projects.starters.micronaut.stoveMicronaut) api(projects.starters.container.stoveContainer) api(projects.starters.process.stoveProcess) // Extensions api(projects.testExtensions.stoveExtensionsKotest) api(projects.testExtensions.stoveExtensionsJunit) // Gradle Plugins api(projects.plugins.stoveTracingGradlePlugin) } } mavenPublishing { coordinates(groupId = rootProject.group.toString(), artifactId = project.name, version = rootProject.version.toString()) publishToMavenCentral() pom { name.set(project.name) description.set(project.properties["projectDescription"].toString()) url.set(project.properties["projectUrl"].toString()) licenses { license { name.set(project.properties["licence"].toString()) url.set(project.properties["licenceUrl"].toString()) } } developers { developer { id.set("osoykan") name.set("Oguzhan Soykan") email.set("oguzhan.soykan@trendyol.com") } } scm { connection.set("scm:git@github.com:Trendyol/stove.git") developerConnection.set("scm:git:ssh://github.com:Trendyol/stove.git") url.set(project.properties["projectUrl"].toString()) } } if (hasSigningKey) signAllPublications() } ================================================ FILE: lib/stove-cassandra/api/stove-cassandra.api ================================================ public final class com/trendyol/stove/cassandra/CassandraContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/cassandra/CassandraContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/cassandra/CassandraContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/cassandra/CassandraContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/cassandra/CassandraContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/cassandra/CassandraSystemOptions; public final fun component3 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;Ljava/lang/String;)Lcom/trendyol/stove/cassandra/CassandraContext; public static synthetic fun copy$default (Lcom/trendyol/stove/cassandra/CassandraContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/cassandra/CassandraContext; public fun equals (Ljava/lang/Object;)Z public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/cassandra/CassandraSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface annotation class com/trendyol/stove/cassandra/CassandraDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/cassandra/CassandraExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getDatacenter ()Ljava/lang/String; public final fun getHost ()Ljava/lang/String; public final fun getKeyspace ()Ljava/lang/String; public final fun getPort ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/cassandra/CassandraMigrationContext { public fun (Lcom/datastax/oss/driver/api/core/CqlSession;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;)V public final fun component1 ()Lcom/datastax/oss/driver/api/core/CqlSession; public final fun component2 ()Lcom/trendyol/stove/cassandra/CassandraSystemOptions; public final fun copy (Lcom/datastax/oss/driver/api/core/CqlSession;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;)Lcom/trendyol/stove/cassandra/CassandraMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/cassandra/CassandraMigrationContext;Lcom/datastax/oss/driver/api/core/CqlSession;Lcom/trendyol/stove/cassandra/CassandraSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/cassandra/CassandraMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getOptions ()Lcom/trendyol/stove/cassandra/CassandraSystemOptions; public final fun getSession ()Lcom/datastax/oss/driver/api/core/CqlSession; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/cassandra/CassandraSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field CASSANDRA_PORT I public static final field Companion Lcom/trendyol/stove/cassandra/CassandraSystem$Companion; public field cqlSession Lcom/datastax/oss/driver/api/core/CqlSession; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getCqlSession ()Lcom/datastax/oss/driver/api/core/CqlSession; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setCqlSession (Lcom/datastax/oss/driver/api/core/CqlSession;)V public final fun shouldExecute (Lcom/datastax/oss/driver/api/core/cql/BoundStatement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldExecute (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldQuery (Lcom/datastax/oss/driver/api/core/cql/BoundStatement;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldQuery (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/cassandra/CassandraSystem$Companion { public final fun session (Lcom/trendyol/stove/cassandra/CassandraSystem;)Lcom/datastax/oss/driver/api/core/CqlSession; } public class com/trendyol/stove/cassandra/CassandraSystemOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/cassandra/CassandraSystemOptions$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/cassandra/CassandraContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/cassandra/CassandraContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/cassandra/CassandraContainerOptions; public fun getDatacenter ()Ljava/lang/String; public fun getKeyspace ()Ljava/lang/String; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/cassandra/CassandraSystemOptions; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; } public final class com/trendyol/stove/cassandra/CassandraSystemOptions$Companion { public final fun provided (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/cassandra/ProvidedCassandraSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/cassandra/CassandraSystemOptions$Companion;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/cassandra/ProvidedCassandraSystemOptions; } public final class com/trendyol/stove/cassandra/OptionsKt { public static final fun cassandra-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun cassandra-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun cassandra-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun cassandra-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/cassandra/ProvidedCassandraSystemOptions : com/trendyol/stove/cassandra/CassandraSystemOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/cassandra/CassandraExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/cassandra/StoveCassandraContainer : org/testcontainers/cassandra/CassandraContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } ================================================ FILE: lib/stove-cassandra/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.cassandra.driver.core) api(libs.testcontainers.cassandra) } dependencies { testImplementation(projects.testExtensions.stoveExtensionsKotest) testImplementation(libs.logback.classic) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided Cassandra instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting Cassandra tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-cassandra/src/main/kotlin/com/trendyol/stove/cassandra/CassandraDsl.kt ================================================ package com.trendyol.stove.cassandra @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class CassandraDsl ================================================ FILE: lib/stove-cassandra/src/main/kotlin/com/trendyol/stove/cassandra/CassandraSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.cassandra import com.datastax.oss.driver.api.core.CqlSession import com.datastax.oss.driver.api.core.cql.* import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import kotlinx.coroutines.* import org.slf4j.* import java.net.InetSocketAddress /** * Cassandra database system for testing CQL operations. * * Provides a DSL for testing Cassandra operations: * - CQL statement execution * - Query result assertions * - Keyspace and table management * * ## Executing Statements * * ```kotlin * cassandra { * shouldExecute("INSERT INTO my_keyspace.users (id, name) VALUES (uuid(), 'John')") * } * ``` * * ## Querying Data * * ```kotlin * cassandra { * shouldQuery("SELECT * FROM my_keyspace.users") { resultSet -> * val rows = resultSet.all() * rows shouldHaveSize 1 * rows.first().getString("name") shouldBe "John" * } * } * ``` * * ## Using the Raw Session * * ```kotlin * cassandra { * session().execute("TRUNCATE my_keyspace.users") * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should store user in Cassandra via API") { * stove { * // Create user via API * http { * postAndExpectBody( * uri = "/users", * body = CreateUserRequest(name = "John").some() * ) { response -> * response.status shouldBe 201 * } * } * * // Verify in Cassandra * cassandra { * shouldQuery("SELECT * FROM my_keyspace.users WHERE name = 'John'") { resultSet -> * val rows = resultSet.all() * rows shouldHaveSize 1 * } * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * cassandra { * CassandraSystemOptions( * keyspace = "my_keyspace", * configureExposedConfiguration = { cfg -> * listOf( * "spring.cassandra.contact-points=${cfg.host}:${cfg.port}", * "spring.cassandra.local-datacenter=${cfg.datacenter}", * "spring.cassandra.keyspace-name=${cfg.keyspace}" * ) * } * ) * } * } * ``` * * @property stove The parent test system. * @see CassandraSystemOptions * @see CassandraExposedConfiguration */ @CassandraDsl class CassandraSystem internal constructor( override val stove: Stove, private val context: CassandraContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var cqlSession: CqlSession override val reportSystemName: String = "Cassandra" + (context.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: CassandraExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(context.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() cqlSession = createSession(exposedConfiguration) runMigrationsIfNeeded() rebindSessionToDefaultKeyspaceIfAvailable(exposedConfiguration) } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) override fun close(): Unit = runBlocking { Try { if (::cqlSession.isInitialized) { context.options.cleanup(cqlSession) cqlSession.close() } }.recover { logger.warn("Cassandra cleanup failed", it) } Try { executeWithReuseCheck { stop() } }.recover { logger.warn("Cassandra stop failed", it) } } /** * Executes a CQL statement and asserts that it completes without errors. * * @param cql The CQL statement to execute * @return This [CassandraSystem] for chaining */ suspend fun shouldExecute(cql: String): CassandraSystem { report( action = "Execute CQL", input = arrow.core.Some(mapOf("cql" to cql)) ) { cqlSession.execute(cql) } return this } /** * Executes a CQL query and passes the [ResultSet] to the [assertion] block. * * @param cql The CQL query to execute * @param assertion A block that receives the [ResultSet] for assertions * @return This [CassandraSystem] for chaining */ suspend fun shouldQuery( cql: String, assertion: (ResultSet) -> Unit ): CassandraSystem { report( action = "Query Cassandra", input = arrow.core.Some(mapOf("cql" to cql)) ) { val resultSet = cqlSession.execute(cql) assertion(resultSet) resultSet } return this } /** * Executes a [BoundStatement] and asserts that it completes without errors. * * @param statement The prepared [BoundStatement] to execute * @return This [CassandraSystem] for chaining */ suspend fun shouldExecute(statement: BoundStatement): CassandraSystem { report(action = "Execute Bound Statement") { cqlSession.execute(statement) } return this } /** * Executes a [BoundStatement] query and passes the [ResultSet] to the [assertion] block. * * @param statement The prepared [BoundStatement] to execute * @param assertion A block that receives the [ResultSet] for assertions * @return This [CassandraSystem] for chaining */ suspend fun shouldQuery( statement: BoundStatement, assertion: (ResultSet) -> Unit ): CassandraSystem { report(action = "Query Cassandra (bound)") { val resultSet = cqlSession.execute(statement) assertion(resultSet) resultSet } return this } /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return CassandraSystem */ suspend fun pause(): CassandraSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return CassandraSystem */ suspend fun unpause(): CassandraSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): CassandraExposedConfiguration = when { context.options is ProvidedCassandraSystemOptions -> context.options.config context.runtime is StoveCassandraContainer -> startCassandraContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startCassandraContainer(container: StoveCassandraContainer): CassandraExposedConfiguration = state.capture { container.start() CassandraExposedConfiguration( host = container.host, port = container.getMappedPort(CASSANDRA_PORT), datacenter = container.localDatacenter, keyspace = context.options.keyspace ) } @Suppress("TooGenericExceptionCaught") private suspend fun createSession( config: CassandraExposedConfiguration ): CqlSession = createSession(config, useKeyspace = false) @Suppress("TooGenericExceptionCaught") private suspend fun createSession( config: CassandraExposedConfiguration, useKeyspace: Boolean ): CqlSession { var lastException: Exception? = null repeat(SESSION_CREATE_MAX_ATTEMPTS) { attempt -> try { return CqlSession .builder() .addContactPoint(InetSocketAddress(config.host, config.port)) .withLocalDatacenter(config.datacenter) .apply { if (useKeyspace) { withKeyspace(config.keyspace) } }.build() } catch (e: CancellationException) { throw e } catch (e: Exception) { lastException = e logger.warn( "Failed to create CQL session (attempt ${attempt + 1}/$SESSION_CREATE_MAX_ATTEMPTS): " + "${e.message}. Retrying in ${SESSION_CREATE_RETRY_DELAY_MS}ms..." ) if (attempt < SESSION_CREATE_MAX_ATTEMPTS - 1) { delay(SESSION_CREATE_RETRY_DELAY_MS) } } } throw IllegalStateException( "Failed to create CQL session after $SESSION_CREATE_MAX_ATTEMPTS attempts", lastException ) } @Suppress("TooGenericExceptionCaught") private suspend fun rebindSessionToDefaultKeyspaceIfAvailable(config: CassandraExposedConfiguration) { if (!configuredKeyspaceExists(config.keyspace)) { logger.info( "Configured Cassandra keyspace '{}' is not available yet; continuing without a default keyspace", config.keyspace ) return } val currentSession = cqlSession try { cqlSession = createSession(config, useKeyspace = true) currentSession.close() } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.warn("Failed to rebind session to keyspace '${config.keyspace}', continuing with keyspace-less session", e) } } private fun configuredKeyspaceExists(keyspace: String): Boolean { val statement = cqlSession .prepare("SELECT keyspace_name FROM system_schema.keyspaces WHERE keyspace_name = ?") .bind(keyspace) return cqlSession.execute(statement).one() != null } private inline fun withContainerOrWarn( operation: String, action: (StoveCassandraContainer) -> Unit ): CassandraSystem = when (val runtime = context.runtime) { is StoveCassandraContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveCassandraContainer) -> Unit) { if (context.runtime is StoveCassandraContainer) { action(context.runtime) } } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { context.options.migrationCollection.run(CassandraMigrationContext(cqlSession, context.options)) } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedCassandraSystemOptions -> context.options.runMigrations context.runtime is StoveCassandraContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } companion object { const val CASSANDRA_PORT = 9042 private const val SESSION_CREATE_MAX_ATTEMPTS = 10 private const val SESSION_CREATE_RETRY_DELAY_MS = 3_000L /** * Exposes the [CqlSession] to the [CassandraSystem]. * Use this for advanced Cassandra operations not covered by the DSL. */ fun CassandraSystem.session(): CqlSession = cqlSession } } ================================================ FILE: lib/stove-cassandra/src/main/kotlin/com/trendyol/stove/cassandra/CassandraSystemOptions.kt ================================================ package com.trendyol.stove.cassandra import com.datastax.oss.driver.api.core.CqlSession import com.trendyol.stove.database.migrations.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl /** * Context provided to Cassandra migrations. * Contains the CQL session and options for performing setup operations. * * @property session The CQL session for executing statements * @property options The Cassandra system options */ @StoveDsl data class CassandraMigrationContext( val session: CqlSession, val options: CassandraSystemOptions ) /** * Convenience type alias for Cassandra migrations. * * Instead of writing `DatabaseMigration`, use `CassandraMigration`: * ```kotlin * class MyMigration : CassandraMigration { * override val order: Int = 1 * override suspend fun execute(connection: CassandraMigrationContext) { ... } * } * ``` */ typealias CassandraMigration = DatabaseMigration /** * Options for configuring the Cassandra system in container mode. */ @StoveDsl open class CassandraSystemOptions( open val keyspace: String = "stove", open val datacenter: String = "datacenter1", open val container: CassandraContainerOptions = CassandraContainerOptions(), open val cleanup: suspend (CqlSession) -> Unit = {}, override val configureExposedConfiguration: (CassandraExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided Cassandra instance * instead of a testcontainer. * * @param host The Cassandra host * @param port The Cassandra native transport port (default: 9042) * @param datacenter The local datacenter name (default: "datacenter1") * @param keyspace The default keyspace to use * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( host: String, port: Int = 9042, datacenter: String = "datacenter1", keyspace: String = "stove", runMigrations: Boolean = true, cleanup: suspend (CqlSession) -> Unit = {}, configureExposedConfiguration: (CassandraExposedConfiguration) -> List ): ProvidedCassandraSystemOptions = ProvidedCassandraSystemOptions( config = CassandraExposedConfiguration( host = host, port = port, datacenter = datacenter, keyspace = keyspace ), keyspace = keyspace, datacenter = datacenter, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided Cassandra instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedCassandraSystemOptions( /** * The configuration for the provided Cassandra instance. */ val config: CassandraExposedConfiguration, keyspace: String = "stove", datacenter: String = "datacenter1", /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, cleanup: suspend (CqlSession) -> Unit = {}, configureExposedConfiguration: (CassandraExposedConfiguration) -> List ) : CassandraSystemOptions( keyspace = keyspace, datacenter = datacenter, container = CassandraContainerOptions(), cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: CassandraExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } ================================================ FILE: lib/stove-cassandra/src/main/kotlin/com/trendyol/stove/cassandra/Options.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.cassandra import arrow.core.getOrElse import com.trendyol.stove.containers.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.testcontainers.cassandra.CassandraContainer import org.testcontainers.utility.DockerImageName @StoveDsl data class CassandraExposedConfiguration( val host: String, val port: Int, val datacenter: String, val keyspace: String ) : ExposedConfiguration @StoveDsl data class CassandraContext( val runtime: SystemRuntime, val options: CassandraSystemOptions, val keyName: String? = null ) open class StoveCassandraContainer( override val imageNameAccess: DockerImageName ) : CassandraContainer(imageNameAccess), StoveContainer @StoveDsl data class CassandraContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = "cassandra", override val tag: String = "4", override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveCassandraContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions internal fun Stove.withCassandra( options: CassandraSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(CassandraSystem(this, CassandraContext(runtime, options))) return this } internal fun Stove.withCassandra( key: SystemKey, options: CassandraSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, CassandraSystem(this, CassandraContext(runtime, options, keyName = keyDisplayName(key)))) return this } internal fun Stove.cassandra(): CassandraSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(CassandraSystem::class) } internal fun Stove.cassandra(key: SystemKey): CassandraSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(CassandraSystem::class, "No CassandraSystem registered with key '${keyDisplayName(key)}'") } /** * Configures Cassandra system. * * For container-based setup: * ```kotlin * cassandra { * CassandraSystemOptions( * keyspace = "my_keyspace", * cleanup = { session -> session.execute("TRUNCATE my_keyspace.my_table") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * cassandra { * CassandraSystemOptions.provided( * host = "localhost", * port = 9042, * datacenter = "datacenter1", * keyspace = "my_keyspace", * cleanup = { session -> session.execute("TRUNCATE my_keyspace.my_table") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.cassandra( configure: () -> CassandraSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedCassandraSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveCassandraContainer } .apply(options.container.containerFn) } } return stove.withCassandra(options, runtime) } fun WithDsl.cassandra( key: SystemKey, configure: () -> CassandraSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedCassandraSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveCassandraContainer } .apply(options.container.containerFn) } } return stove.withCassandra(key, options, runtime) } suspend fun ValidationDsl.cassandra( validation: @CassandraDsl suspend CassandraSystem.() -> Unit ): Unit = validation(this.stove.cassandra()) suspend fun ValidationDsl.cassandra( key: SystemKey, validation: @CassandraDsl suspend CassandraSystem.() -> Unit ): Unit = validation(this.stove.cassandra(key)) ================================================ FILE: lib/stove-cassandra/src/test/kotlin/com/trendyol/stove/cassandra/CassandraOptionsTests.kt ================================================ package com.trendyol.stove.cassandra import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class CassandraOptionsTests : FunSpec({ test("CassandraExposedConfiguration should hold connection details") { val cfg = CassandraExposedConfiguration( host = "localhost", port = 9042, datacenter = "datacenter1", keyspace = "my_keyspace" ) cfg.host shouldBe "localhost" cfg.port shouldBe 9042 cfg.datacenter shouldBe "datacenter1" cfg.keyspace shouldBe "my_keyspace" } test("CassandraSystemOptions.provided should create ProvidedCassandraSystemOptions with correct config") { val options = CassandraSystemOptions.provided( host = "cassandra-host", port = 9042, datacenter = "datacenter1", keyspace = "test_keyspace", configureExposedConfiguration = { cfg -> listOf( "cassandra.contact-points=${cfg.host}:${cfg.port}", "cassandra.local-datacenter=${cfg.datacenter}" ) } ) options.providedConfig.host shouldBe "cassandra-host" options.providedConfig.port shouldBe 9042 options.providedConfig.datacenter shouldBe "datacenter1" options.providedConfig.keyspace shouldBe "test_keyspace" options.runMigrationsForProvided shouldBe true } test("ProvidedCassandraSystemOptions should expose correct properties") { val config = CassandraExposedConfiguration( host = "remote", port = 9043, datacenter = "dc1", keyspace = "prod_keyspace" ) val options = ProvidedCassandraSystemOptions( config = config, keyspace = "prod_keyspace", datacenter = "dc1", runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("CassandraSystemOptions should have sensible defaults") { val options = object : CassandraSystemOptions( configureExposedConfiguration = { _ -> listOf() } ) {} options.keyspace shouldBe "stove" options.datacenter shouldBe "datacenter1" options.container shouldNotBe null } test("CassandraSystemOptions.provided should default port to 9042") { val options = CassandraSystemOptions.provided( host = "localhost", keyspace = "test", configureExposedConfiguration = { _ -> listOf() } ) options.providedConfig.port shouldBe 9042 } test("CassandraSystemOptions.provided should default datacenter to datacenter1") { val options = CassandraSystemOptions.provided( host = "localhost", keyspace = "test", configureExposedConfiguration = { _ -> listOf() } ) options.providedConfig.datacenter shouldBe "datacenter1" } }) ================================================ FILE: lib/stove-cassandra/src/test/kotlin/com/trendyol/stove/cassandra/CassandraSystemTests.kt ================================================ package com.trendyol.stove.cassandra import com.trendyol.stove.cassandra.CassandraSystem.Companion.session import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import org.slf4j.* import org.testcontainers.cassandra.CassandraContainer import org.testcontainers.utility.DockerImageName // ============================================================================ // Shared components // ============================================================================ class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } // ============================================================================ // Strategy interface // ============================================================================ sealed interface CassandraTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): CassandraTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedCassandraStrategy() else ContainerCassandraStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerCassandraStrategy : CassandraTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting Cassandra tests with container mode") val options = CassandraSystemOptions( keyspace = "stove", container = CassandraContainerOptions(), configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { cassandra { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("Cassandra container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedCassandraStrategy : CassandraTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: CassandraContainer override suspend fun start() { logger.info("Starting Cassandra tests with provided mode") externalContainer = CassandraContainer(DockerImageName.parse("cassandra:4")) .apply { start() } logger.info("External Cassandra container started at ${externalContainer.host}:${externalContainer.firstMappedPort}") val options = CassandraSystemOptions .provided( host = externalContainer.host, port = externalContainer.firstMappedPort, datacenter = externalContainer.localDatacenter, keyspace = "stove", runMigrations = true, cleanup = { _ -> logger.info("Running cleanup on provided instance") }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { cassandra { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { try { com.trendyol.stove.system.Stove .stop() } catch (e: IllegalStateException) { logger.warn("Stove.stop() failed (may not have been initialized): ${e.message}") } if (::externalContainer.isInitialized) { externalContainer.stop() } logger.info("Cassandra provided tests completed") } } // ============================================================================ // Migrations // ============================================================================ class CreateKeyspaceMigration : CassandraMigration { override val order: Int = 1 override suspend fun execute(connection: CassandraMigrationContext) { connection.session.execute( """ CREATE KEYSPACE IF NOT EXISTS ${connection.options.keyspace} WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1} """.trimIndent() ) } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = CassandraTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } // ============================================================================ // Tests // ============================================================================ class CassandraSystemTests : ShouldSpec({ should("execute a CQL statement without error") { stove { cassandra { shouldExecute("CREATE TABLE IF NOT EXISTS stove.test_table (id uuid PRIMARY KEY, value text)") } } } should("query data from Cassandra") { stove { cassandra { shouldExecute("CREATE TABLE IF NOT EXISTS stove.query_test (id uuid PRIMARY KEY, name text)") shouldExecute("INSERT INTO stove.query_test (id, name) VALUES (uuid(), 'test-value')") shouldQuery("SELECT * FROM stove.query_test") { resultSet -> val rows = resultSet.all() rows.isNotEmpty() shouldBe true rows.first().getString("name") shouldBe "test-value" } } } } should("use the configured keyspace as the default session keyspace") { stove { cassandra { shouldExecute("CREATE TABLE IF NOT EXISTS default_keyspace_test (id uuid PRIMARY KEY, name text)") shouldExecute("INSERT INTO default_keyspace_test (id, name) VALUES (uuid(), 'default-keyspace')") shouldQuery("SELECT * FROM default_keyspace_test") { resultSet -> val rows = resultSet.all() rows.isNotEmpty() shouldBe true rows.first().getString("name") shouldBe "default-keyspace" } } } } should("provide access to the raw CQL session") { stove { cassandra { val result = session().execute("SELECT release_version FROM system.local") result.one()?.getString("release_version") shouldNotBe null } } } }) ================================================ FILE: lib/stove-cassandra/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.cassandra.StoveConfig ================================================ FILE: lib/stove-cassandra/src/test/resources/logback.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: lib/stove-couchbase/api/stove-couchbase.api ================================================ public final class com/trendyol/stove/couchbase/CouchbaseContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/couchbase/CouchbaseContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/couchbase/CouchbaseContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/couchbase/CouchbaseContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/couchbase/CouchbaseContext { public fun (Lorg/testcontainers/couchbase/BucketDefinition;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions;Ljava/lang/String;)V public synthetic fun (Lorg/testcontainers/couchbase/BucketDefinition;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lorg/testcontainers/couchbase/BucketDefinition; public final fun component2 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component3 ()Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions; public final fun component4 ()Ljava/lang/String; public final fun copy (Lorg/testcontainers/couchbase/BucketDefinition;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions;Ljava/lang/String;)Lcom/trendyol/stove/couchbase/CouchbaseContext; public static synthetic fun copy$default (Lcom/trendyol/stove/couchbase/CouchbaseContext;Lorg/testcontainers/couchbase/BucketDefinition;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/couchbase/CouchbaseContext; public fun equals (Ljava/lang/Object;)Z public final fun getBucket ()Lorg/testcontainers/couchbase/BucketDefinition; public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface annotation class com/trendyol/stove/couchbase/CouchbaseDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/couchbase/CouchbaseExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getConnectionString ()Ljava/lang/String; public final fun getHostsWithPort ()Ljava/lang/String; public final fun getPassword ()Ljava/lang/String; public final fun getUsername ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/couchbase/CouchbaseSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/couchbase/CouchbaseSystem$Companion; public field cluster Lcom/couchbase/client/kotlin/Cluster; public field collection Lcom/couchbase/client/kotlin/Collection; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getCluster ()Lcom/couchbase/client/kotlin/Cluster; public final fun getCollection ()Lcom/couchbase/client/kotlin/Collection; public final fun getContext ()Lcom/trendyol/stove/couchbase/CouchbaseContext; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setCluster (Lcom/couchbase/client/kotlin/Cluster;)V public final fun setCollection (Lcom/couchbase/client/kotlin/Collection;)V public final fun shouldDelete (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldDelete (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldNotExist (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldNotExist (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/couchbase/CouchbaseSystem$Companion { public final fun bucket (Lcom/trendyol/stove/couchbase/CouchbaseSystem;)Lcom/couchbase/client/kotlin/Bucket; public final fun cluster (Lcom/trendyol/stove/couchbase/CouchbaseSystem;)Lcom/couchbase/client/kotlin/Cluster; } public class com/trendyol/stove/couchbase/CouchbaseSystemOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions$Companion; public fun (Ljava/lang/String;Lcom/trendyol/stove/couchbase/CouchbaseContainerOptions;Lcom/couchbase/client/kotlin/codec/JsonSerializer;Lcom/couchbase/client/kotlin/codec/Transcoder;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Lcom/trendyol/stove/couchbase/CouchbaseContainerOptions;Lcom/couchbase/client/kotlin/codec/JsonSerializer;Lcom/couchbase/client/kotlin/codec/Transcoder;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getClusterSerDe ()Lcom/couchbase/client/kotlin/codec/JsonSerializer; public fun getClusterTranscoder ()Lcom/couchbase/client/kotlin/codec/Transcoder; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainerOptions ()Lcom/trendyol/stove/couchbase/CouchbaseContainerOptions; public fun getDefaultBucket ()Ljava/lang/String; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; } public final class com/trendyol/stove/couchbase/CouchbaseSystemOptions$Companion { public final fun provided (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/couchbase/ProvidedCouchbaseSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/couchbase/CouchbaseSystemOptions$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/couchbase/ProvidedCouchbaseSystemOptions; } public final class com/trendyol/stove/couchbase/OptionsKt { public static final fun couchbase-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun couchbase-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun couchbase-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun couchbase-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/couchbase/ProvidedCouchbaseSystemOptions : com/trendyol/stove/couchbase/CouchbaseSystemOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration;Ljava/lang/String;Lcom/couchbase/client/kotlin/codec/JsonSerializer;Lcom/couchbase/client/kotlin/codec/Transcoder;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration;Ljava/lang/String;Lcom/couchbase/client/kotlin/codec/JsonSerializer;Lcom/couchbase/client/kotlin/codec/Transcoder;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/couchbase/CouchbaseExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/couchbase/StoveCouchbaseContainer : org/testcontainers/couchbase/CouchbaseContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public final class com/trendyol/stove/couchbase/UtilKt { public static final fun waitForKeySpaceAvailability-45ZY6uE (Lcom/couchbase/client/kotlin/Cluster;Ljava/lang/String;Ljava/lang/String;JJLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun waitForKeySpaceAvailability-45ZY6uE$default (Lcom/couchbase/client/kotlin/Cluster;Ljava/lang/String;Ljava/lang/String;JJLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun waitUntilIndexIsCreated-WPi__2c (Lcom/couchbase/client/kotlin/Cluster;Ljava/lang/String;JJLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun waitUntilIndexIsCreated-WPi__2c$default (Lcom/couchbase/client/kotlin/Cluster;Ljava/lang/String;JJLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun waitUntilSucceeds-SYHnMyU (Lcom/couchbase/client/kotlin/Cluster;Lkotlin/jvm/functions/Function1;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun waitUntilSucceeds-SYHnMyU$default (Lcom/couchbase/client/kotlin/Cluster;Lkotlin/jvm/functions/Function1;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } ================================================ FILE: lib/stove-couchbase/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.couchbase.kotlin) api(libs.testcontainers.couchbase) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.slf4j.simple) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided Couchbase instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting Couchbase tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-couchbase/src/main/kotlin/com/trendyol/stove/couchbase/CouchbaseDsl.kt ================================================ package com.trendyol.stove.couchbase @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class CouchbaseDsl ================================================ FILE: lib/stove-couchbase/src/main/kotlin/com/trendyol/stove/couchbase/CouchbaseSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.couchbase import com.couchbase.client.kotlin.* import com.couchbase.client.kotlin.Collection import com.couchbase.client.kotlin.codec.typeRef import com.couchbase.client.kotlin.query.* import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.runBlocking import org.slf4j.* /** * Couchbase document database system for testing document storage operations. * * Provides a DSL for testing Couchbase operations: * - Document CRUD operations (save, get, delete) * - N1QL queries * - Collection management * - Existence checks * * ## Saving Documents * * ```kotlin * couchbase { * // Save to default collection * save("user::123", User(id = "123", name = "John")) * * // Save to specific collection * save("users", "user::123", User(id = "123", name = "John")) * * // Save with custom options * saveWithOptions("user::123", user) { options -> * options.expiry(Duration.ofHours(24)) * } * } * ``` * * ## Retrieving Documents * * ```kotlin * couchbase { * // Get from default collection and assert * shouldGet("user::123") { user -> * user.name shouldBe "John" * user.email shouldBe "john@example.com" * } * * // Get from specific collection * shouldGet("users", "user::123") { user -> * user.name shouldBe "John" * } * } * ``` * * ## N1QL Queries * * ```kotlin * couchbase { * // Execute N1QL query and assert results * shouldQuery( * "SELECT * FROM `my-bucket` WHERE type = 'user' AND status = 'active'" * ) { users -> * users.size shouldBeGreaterThan 0 * users.all { it.status == "active" } shouldBe true * } * } * ``` * * ## Deleting Documents * * ```kotlin * couchbase { * // Delete from default collection * shouldDelete("user::123") * * // Delete from specific collection * shouldDelete("users", "user::123") * } * ``` * * ## Existence Checks * * ```kotlin * couchbase { * // Assert document doesn't exist * shouldNotExist("user::deleted") * * // In specific collection * shouldNotExist("users", "user::deleted") * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should create user via API and store in Couchbase") { * stove { * // Setup: ensure clean state * couchbase { * shouldNotExist("user::new-user") * } * * // Action: create user via API * http { * postAndExpectBodilessResponse( * uri = "/users", * body = CreateUserRequest(name = "New User").some() * ) { response -> * response.status shouldBe 201 * } * } * * // Assert: verify in Couchbase * couchbase { * shouldGet("users", "user::new-user") { user -> * user.name shouldBe "New User" * user.createdAt shouldNotBe null * } * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * couchbase { * CouchbaseSystemOptions( * defaultBucket = "my-bucket", * configureExposedConfiguration = { cfg -> * listOf( * "couchbase.connection-string=${cfg.connectionString}", * "couchbase.username=${cfg.username}", * "couchbase.password=${cfg.password}" * ) * } * ) * } * } * ``` * * @property stove The parent test system. * @property context Couchbase context containing bucket and options. * @see CouchbaseSystemOptions * @see CouchbaseExposedConfiguration */ @CouchbaseDsl class CouchbaseSystem internal constructor( override val stove: Stove, val context: CouchbaseContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var cluster: Cluster @PublishedApi internal lateinit var collection: Collection override val reportSystemName: String = "Couchbase" + (context.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: CouchbaseExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(context.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() cluster = createCluster(exposedConfiguration) collection = cluster.bucket(context.bucket.name).defaultCollection() runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) override fun close(): Unit = runBlocking { Try { context.options.cleanup(cluster) cluster.disconnect() executeWithReuseCheck { stop() } }.recover { logger.warn("Disconnecting the couchbase cluster got an error: $it") } } suspend inline fun shouldQuery( query: String, crossinline assertion: (List) -> Unit ): CouchbaseSystem { val typeRef = typeRef() report( action = "N1QL Query", input = arrow.core.Some(query) ) { val results = flow { cluster .query( statement = query, metrics = false, consistency = QueryScanConsistency.requestPlus(), serializer = context.options.clusterSerDe ).execute { row -> emit(context.options.clusterSerDe.deserialize(row.content, typeRef)) } }.toList() assertion(results) results } return this } suspend inline fun shouldGet( key: String, crossinline assertion: (T) -> Unit ): CouchbaseSystem { report( action = "Get document", input = arrow.core.Some(mapOf("id" to key)) ) { val document = collection.get(key).contentAs() assertion(document) document } return this } suspend inline fun shouldGet( collection: String, key: String, crossinline assertion: (T) -> Unit ): CouchbaseSystem { report( action = "Get document", input = arrow.core.Some(mapOf("collection" to collection, "id" to key)) ) { val document = cluster .bucket(context.bucket.name) .collection(collection) .get(key) .contentAs() assertion(document) document } return this } suspend fun shouldNotExist(key: String): CouchbaseSystem { report( action = "Document should not exist", input = arrow.core.Some(mapOf("id" to key)), expected = arrow.core.Some("Document not found") ) { val exists = collection.getOrNull(key) != null if (exists) throw AssertionError("The document with the given id($key) was not expected, but found!") } return this } suspend fun shouldNotExist( collection: String, key: String ): CouchbaseSystem { report( action = "Document should not exist", input = arrow.core.Some(mapOf("collection" to collection, "id" to key)), expected = arrow.core.Some("Document not found") ) { val exists = cluster .bucket(context.bucket.name) .collection(collection) .getOrNull(key) != null if (exists) throw AssertionError("The document with the given id($key) was not expected, but found!") } return this } suspend fun shouldDelete(key: String): CouchbaseSystem { report( action = "Delete document", input = arrow.core.Some(mapOf("id" to key)) ) { collection.remove(key) } return this } suspend fun shouldDelete( collection: String, key: String ): CouchbaseSystem { report( action = "Delete document", input = arrow.core.Some(mapOf("collection" to collection, "id" to key)) ) { cluster .bucket(context.bucket.name) .collection(collection) .remove(key) } return this } /** * Saves the [instance] with given [id] to the [collection] * To save to the default collection use [saveToDefaultCollection] */ suspend inline fun save( collection: String, id: String, instance: T ): CouchbaseSystem { report( action = "Save document", input = arrow.core.Some(instance), metadata = mapOf("collection" to collection, "id" to id) ) { cluster.bucket(context.bucket.name).collection(collection).insert(id, instance) } return this } /** * Saves the [instance] with given [id] to the default collection * In couchbase the default collection is `_default` */ suspend inline fun saveToDefaultCollection( id: String, instance: T ): CouchbaseSystem = this.save("_default", id, instance) /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return CouchbaseSystem */ suspend fun pause(): CouchbaseSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return CouchbaseSystem */ suspend fun unpause(): CouchbaseSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): CouchbaseExposedConfiguration = when { context.options is ProvidedCouchbaseSystemOptions -> context.options.config context.runtime is StoveCouchbaseContainer -> startCouchbaseContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startCouchbaseContainer(container: StoveCouchbaseContainer): CouchbaseExposedConfiguration = state.capture { container.start() CouchbaseExposedConfiguration( connectionString = container.connectionString, hostsWithPort = container.connectionString.replace("couchbase://", ""), username = container.username, password = container.password ) } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { context.options.migrationCollection.run(cluster) } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedCouchbaseSystemOptions -> context.options.runMigrations context.runtime is StoveCouchbaseContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private fun createCluster(exposedConfiguration: CouchbaseExposedConfiguration): Cluster = Cluster.connect( exposedConfiguration.hostsWithPort, exposedConfiguration.username, exposedConfiguration.password ) { jsonSerializer = context.options.clusterSerDe transcoder = context.options.clusterTranscoder } private inline fun withContainerOrWarn( operation: String, action: (StoveCouchbaseContainer) -> Unit ): CouchbaseSystem = when (val runtime = context.runtime) { is StoveCouchbaseContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveCouchbaseContainer) -> Unit) { if (context.runtime is StoveCouchbaseContainer) { action(context.runtime) } } companion object { /** * Exposes the [Cluster] to the [CouchbaseSystem]. * Use this for advanced Couchbase operations not covered by the DSL. */ fun CouchbaseSystem.cluster(): Cluster = this.cluster /** * Exposes the [Bucket] to the [CouchbaseSystem]. * Use this for advanced Couchbase operations not covered by the DSL. */ fun CouchbaseSystem.bucket(): Bucket = this.cluster.bucket(this.context.bucket.name) } } ================================================ FILE: lib/stove-couchbase/src/main/kotlin/com/trendyol/stove/couchbase/Options.kt ================================================ package com.trendyol.stove.couchbase import arrow.core.getOrElse import com.couchbase.client.kotlin.Cluster import com.couchbase.client.kotlin.codec.* import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.serialization.E2eObjectMapperConfig import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.testcontainers.couchbase.BucketDefinition data class CouchbaseExposedConfiguration( val connectionString: String, val hostsWithPort: String, val username: String, val password: String ) : ExposedConfiguration /** * Options for configuring the Couchbase system in container mode. */ @StoveDsl open class CouchbaseSystemOptions( open val defaultBucket: String, open val containerOptions: CouchbaseContainerOptions = CouchbaseContainerOptions(), open val clusterSerDe: JsonSerializer = JacksonJsonSerializer(E2eObjectMapperConfig.createObjectMapperWithDefaults()), open val clusterTranscoder: Transcoder = JsonTranscoder(clusterSerDe), open val cleanup: suspend (Cluster) -> Unit = {}, override val configureExposedConfiguration: (CouchbaseExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided Couchbase instance * instead of a testcontainer. * * @param connectionString The Couchbase connection string (e.g., "couchbase://localhost:8091") * @param username The username for authentication * @param password The password for authentication * @param defaultBucket The default bucket name * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( connectionString: String, username: String, password: String, defaultBucket: String, runMigrations: Boolean = true, cleanup: suspend (Cluster) -> Unit = {}, configureExposedConfiguration: (CouchbaseExposedConfiguration) -> List ): ProvidedCouchbaseSystemOptions { val hostsWithPort = connectionString.replace("couchbase://", "") return ProvidedCouchbaseSystemOptions( config = CouchbaseExposedConfiguration( connectionString = connectionString, hostsWithPort = hostsWithPort, username = username, password = password ), defaultBucket = defaultBucket, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } } /** * Options for using an externally provided Couchbase instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedCouchbaseSystemOptions( /** * The configuration for the provided Couchbase instance. */ val config: CouchbaseExposedConfiguration, defaultBucket: String, clusterSerDe: JsonSerializer = JacksonJsonSerializer(E2eObjectMapperConfig.createObjectMapperWithDefaults()), clusterTranscoder: Transcoder = JsonTranscoder(clusterSerDe), cleanup: suspend (Cluster) -> Unit = {}, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (CouchbaseExposedConfiguration) -> List ) : CouchbaseSystemOptions( defaultBucket = defaultBucket, containerOptions = CouchbaseContainerOptions(), clusterSerDe = clusterSerDe, clusterTranscoder = clusterTranscoder, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: CouchbaseExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } /** * Convenience type alias for Couchbase migrations. * * Instead of writing `DatabaseMigration`, use `CouchbaseMigration`: * ```kotlin * class MyMigration : CouchbaseMigration { * override val order: Int = 1 * override suspend fun execute(connection: Cluster) { ... } * } * ``` */ typealias CouchbaseMigration = DatabaseMigration @StoveDsl data class CouchbaseContext( val bucket: BucketDefinition, val runtime: SystemRuntime, val options: CouchbaseSystemOptions, val keyName: String? = null ) @StoveDsl data class CouchbaseContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = "couchbase/server", override val tag: String = "latest", override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveCouchbaseContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions internal fun Stove.withCouchbase( options: CouchbaseSystemOptions, runtime: SystemRuntime ): Stove { val bucketDefinition = BucketDefinition(options.defaultBucket) this.getOrRegister( CouchbaseSystem(this, CouchbaseContext(bucketDefinition, runtime, options)) ) return this } internal fun Stove.withCouchbase( key: SystemKey, options: CouchbaseSystemOptions, runtime: SystemRuntime ): Stove { val bucketDefinition = BucketDefinition(options.defaultBucket) this.getOrRegister( key, CouchbaseSystem(this, CouchbaseContext(bucketDefinition, runtime, options, keyName = keyDisplayName(key))) ) return this } internal fun Stove.couchbase(): CouchbaseSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(CouchbaseSystem::class) } internal fun Stove.couchbase(key: SystemKey): CouchbaseSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(CouchbaseSystem::class, "No CouchbaseSystem registered with key '${keyDisplayName(key)}'") } /** * Configures Couchbase system. * * For container-based setup: * ```kotlin * couchbase { * CouchbaseSystemOptions( * defaultBucket = "myBucket", * cleanup = { cluster -> cluster.query("DELETE FROM ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * couchbase { * CouchbaseSystemOptions.provided( * connectionString = "couchbase://localhost:8091", * username = "admin", * password = "password", * defaultBucket = "myBucket", * runMigrations = true, * cleanup = { cluster -> cluster.query("DELETE FROM ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.couchbase( configure: @StoveDsl () -> CouchbaseSystemOptions ): Stove { val options = configure() val bucketDefinition = BucketDefinition(options.defaultBucket) val runtime: SystemRuntime = if (options is ProvidedCouchbaseSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( imageName = options.containerOptions.imageWithTag, registry = options.containerOptions.registry, compatibleSubstitute = options.containerOptions.compatibleSubstitute ) { dockerImageName -> options.containerOptions .useContainerFn(dockerImageName) .withBucket(bucketDefinition) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveCouchbaseContainer } .apply(options.containerOptions.containerFn) } } return stove.withCouchbase(options, runtime) } fun WithDsl.couchbase( key: SystemKey, configure: @StoveDsl () -> CouchbaseSystemOptions ): Stove { val options = configure() val bucketDefinition = BucketDefinition(options.defaultBucket) val runtime: SystemRuntime = if (options is ProvidedCouchbaseSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( imageName = options.containerOptions.imageWithTag, registry = options.containerOptions.registry, compatibleSubstitute = options.containerOptions.compatibleSubstitute ) { dockerImageName -> options.containerOptions .useContainerFn(dockerImageName) .withBucket(bucketDefinition) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveCouchbaseContainer } .apply(options.containerOptions.containerFn) } } return stove.withCouchbase(key, options, runtime) } suspend fun ValidationDsl.couchbase( validation: @CouchbaseDsl suspend CouchbaseSystem.() -> Unit ): Unit = validation(this.stove.couchbase()) suspend fun ValidationDsl.couchbase( key: SystemKey, validation: @CouchbaseDsl suspend CouchbaseSystem.() -> Unit ): Unit = validation(this.stove.couchbase(key)) ================================================ FILE: lib/stove-couchbase/src/main/kotlin/com/trendyol/stove/couchbase/StoveCouchbaseContainer.kt ================================================ package com.trendyol.stove.couchbase import com.trendyol.stove.containers.StoveContainer import org.testcontainers.couchbase.CouchbaseContainer import org.testcontainers.utility.DockerImageName open class StoveCouchbaseContainer( override val imageNameAccess: DockerImageName ) : CouchbaseContainer(imageNameAccess), StoveContainer ================================================ FILE: lib/stove-couchbase/src/main/kotlin/com/trendyol/stove/couchbase/util.kt ================================================ package com.trendyol.stove.couchbase import com.couchbase.client.core.error.* import com.couchbase.client.kotlin.Cluster import com.couchbase.client.kotlin.query.execute import com.trendyol.stove.functional.* import kotlinx.coroutines.delay import java.util.concurrent.TimeoutException import kotlin.time.Duration.Companion.minutes suspend fun Cluster.waitForKeySpaceAvailability( bucketName: String, keyspaceName: String, duration: kotlin.time.Duration, delayMillis: Long = 1000, logger: (log: String) -> Unit = ::println ): Unit = waitUntilSucceeds( continueIf = { it is CollectionNotFoundException }, duration = duration, delayMillis = delayMillis, logger = logger ) { bucket(bucketName).defaultScope().collection(keyspaceName).exists("not-important") } suspend fun Cluster.waitUntilIndexIsCreated( query: String, duration: kotlin.time.Duration, delayMillis: Long = 50, logger: (log: String) -> Unit = ::println ): Unit = waitUntilSucceeds( continueIf = { it is IndexFailureException }, duration = duration, delayMillis = delayMillis, logger = logger ) { query(query, readonly = false).execute() } suspend fun Cluster.waitUntilSucceeds( continueIf: (Throwable) -> Boolean, duration: kotlin.time.Duration = 10.minutes, delayMillis: Long = 50, logger: (log: String) -> Unit = ::println, block: suspend Cluster.() -> Unit ) { val startTime = System.currentTimeMillis() while (System.currentTimeMillis() - startTime < duration.inWholeMilliseconds) { val executed = Try { this.block() true }.recover { throwable -> logger("Operation failed.\nBecause of: $throwable") when { continueIf(throwable) -> false else -> throw throwable } }.get() if (executed) { logger("Operation executed successfully") return } logger("Operation is not successful. Waiting for $delayMillis ms...") delay(delayMillis) } throw TimeoutException("Timed out waiting for the operation!") } ================================================ FILE: lib/stove-couchbase/src/test/kotlin/com/trendyol/stove/couchbase/CouchbaseOptionsTest.kt ================================================ package com.trendyol.stove.couchbase import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class CouchbaseOptionsTest : FunSpec({ test("CouchbaseExposedConfiguration should hold connection details") { val cfg = CouchbaseExposedConfiguration( connectionString = "couchbase://localhost:8091", hostsWithPort = "localhost:8091", username = "admin", password = "password" ) cfg.connectionString shouldBe "couchbase://localhost:8091" cfg.hostsWithPort shouldBe "localhost:8091" cfg.username shouldBe "admin" cfg.password shouldBe "password" } test("CouchbaseSystemOptions.provided should create ProvidedCouchbaseSystemOptions") { val options = CouchbaseSystemOptions.provided( connectionString = "couchbase://cb-host:8091", username = "admin", password = "pass", defaultBucket = "test-bucket", configureExposedConfiguration = { cfg -> listOf("couchbase.hosts=${cfg.hostsWithPort}") } ) options.providedConfig.connectionString shouldBe "couchbase://cb-host:8091" options.providedConfig.hostsWithPort shouldBe "cb-host:8091" options.providedConfig.username shouldBe "admin" options.providedConfig.password shouldBe "pass" options.runMigrationsForProvided shouldBe true } test("CouchbaseSystemOptions.provided should strip couchbase:// prefix for hostsWithPort") { val options = CouchbaseSystemOptions.provided( connectionString = "couchbase://node1:8091,node2:8091", username = "u", password = "p", defaultBucket = "b", configureExposedConfiguration = { _ -> listOf() } ) options.providedConfig.hostsWithPort shouldBe "node1:8091,node2:8091" } test("ProvidedCouchbaseSystemOptions should expose correct properties") { val config = CouchbaseExposedConfiguration( connectionString = "couchbase://remote:8091", hostsWithPort = "remote:8091", username = "u", password = "p" ) val options = ProvidedCouchbaseSystemOptions( config = config, defaultBucket = "bucket", runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("CouchbaseContainerOptions should have defaults") { val opts = CouchbaseContainerOptions() opts.image shouldBe "couchbase/server" opts.tag shouldBe "latest" } test("CouchbaseSystemOptions should have sensible defaults") { val options = object : CouchbaseSystemOptions( defaultBucket = "default", configureExposedConfiguration = { _ -> listOf() } ) {} options.defaultBucket shouldBe "default" options.containerOptions shouldNotBe null options.clusterSerDe shouldNotBe null options.clusterTranscoder shouldNotBe null } }) ================================================ FILE: lib/stove-couchbase/src/test/kotlin/com/trendyol/stove/couchbase/CouchbaseTestSystemTests.kt ================================================ package com.trendyol.stove.couchbase import com.couchbase.client.core.error.DocumentNotFoundException import com.trendyol.stove.couchbase.CouchbaseSystem.Companion.bucket import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.shouldBe import org.junit.jupiter.api.assertThrows import java.util.* /** * Couchbase system tests that run against both container-based and provided instances. * * These tests verify: * - Basic CRUD operations work correctly * - Migrations are executed properly (creating collections) * - The same test code works for both container and provided modes * * To run with provided instance mode: * ``` * ./gradlew :lib:stove-testing-e2e-couchbase:test -DuseProvided=true * ``` */ class CouchbaseTestSystemUsesDslTests : FunSpec({ data class ExampleInstance( val id: String, val description: String ) test("migration should create 'another' collection") { val id = UUID.randomUUID().toString() val anotherCollectionName = "another" stove { couchbase { // This test verifies that the migration created the 'another' collection save(anotherCollectionName, id = id, ExampleInstance(id = id, description = "migration test")) shouldGet(anotherCollectionName, id) { actual -> actual.id shouldBe id actual.description shouldBe "migration test" } shouldDelete(anotherCollectionName, id) } } } test("should save and get") { val id = UUID.randomUUID().toString() val anotherCollectionName = "another" stove { couchbase { saveToDefaultCollection(id, ExampleInstance(id = id, description = testCase.name.name)) save(anotherCollectionName, id = id, ExampleInstance(id = id, description = testCase.name.name)) shouldGet(id) { actual -> actual.id shouldBe id actual.description shouldBe testCase.name.name } shouldGet(anotherCollectionName, id) { actual -> actual.id shouldBe id actual.description shouldBe testCase.name.name } } } } test("should not get when document does not exist") { val id = UUID.randomUUID().toString() val notExistDocId = UUID.randomUUID().toString() stove { couchbase { saveToDefaultCollection(id, ExampleInstance(id = id, description = testCase.name.name)) shouldGet(id) { actual -> actual.id shouldBe id actual.description shouldBe testCase.name.name } shouldNotExist(notExistDocId) } } } test("should throw assertion exception when document exist") { val id = UUID.randomUUID().toString() stove { couchbase { saveToDefaultCollection(id, ExampleInstance(id = id, description = testCase.name.name)) shouldGet(id) { actual -> actual.id shouldBe id actual.description shouldBe testCase.name.name } assertThrows { shouldNotExist(id) } } } } test("should delete") { val id = UUID.randomUUID().toString() stove { couchbase { saveToDefaultCollection(id, ExampleInstance(id = id, description = testCase.name.name)) shouldGet(id) { actual -> actual.id shouldBe id actual.description shouldBe testCase.name.name } shouldDelete(id) shouldNotExist(id) } } } test("should delete from another collection") { val id = UUID.randomUUID().toString() val anotherCollectionName = "another" stove { couchbase { save(anotherCollectionName, id = id, ExampleInstance(id = id, description = testCase.name.name)) shouldGet(anotherCollectionName, id) { actual -> actual.id shouldBe id actual.description shouldBe testCase.name.name } shouldDelete(anotherCollectionName, id) shouldNotExist(anotherCollectionName, id) } } } test("should not delete when document does not exist") { val id = UUID.randomUUID().toString() stove { couchbase { shouldNotExist(id) assertThrows { shouldDelete(id) } } } } test("should not delete from another collection when document does not exist") { val id = UUID.randomUUID().toString() val anotherCollectionName = "another" stove { couchbase { shouldNotExist(anotherCollectionName, id) assertThrows { shouldDelete(anotherCollectionName, id) } } } } test("should query") { val id = UUID.randomUUID().toString() val id2 = UUID.randomUUID().toString() stove { couchbase { saveToDefaultCollection(id, ExampleInstance(id = id, description = testCase.name.name)) saveToDefaultCollection(id2, ExampleInstance(id = id2, description = testCase.name.name)) shouldQuery( "SELECT c.id, c.* FROM `${this.bucket().name}`.`${this.collection.scope.name}`.`${this.collection.name}` c" ) { result -> result.size shouldBeGreaterThanOrEqual 2 result.contains(ExampleInstance(id = id, description = testCase.name.name)) shouldBe true result.contains(ExampleInstance(id = id2, description = testCase.name.name)) shouldBe true } } } } }) ================================================ FILE: lib/stove-couchbase/src/test/kotlin/com/trendyol/stove/couchbase/TestSystemConfig.kt ================================================ package com.trendyol.stove.couchbase import com.couchbase.client.kotlin.Cluster import com.trendyol.stove.couchbase.CouchbaseSystem.Companion.bucket import com.trendyol.stove.database.migrations.* import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import org.testcontainers.couchbase.* import org.testcontainers.utility.DockerImageName import kotlin.time.Duration.Companion.seconds // ============================================================================ // Shared components // ============================================================================ const val TEST_BUCKET = "test-couchbase-bucket" class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } /** * Extended container for testing container customization. */ class ExtendedCouchbaseContainer( dockerImageName: DockerImageName ) : StoveCouchbaseContainer(dockerImageName) { private val logger: Logger = LoggerFactory.getLogger(javaClass) override fun start() { logger.info("starting extended couchbase container") super.start() } override fun stop() { logger.info("stopping extended couchbase container") super.stop() } } /** * Migration that creates the 'another' collection for testing. */ class DefaultMigration : CouchbaseMigration { private val logger: Logger = LoggerFactory.getLogger(javaClass) override val order: Int = MigrationPriority.HIGHEST.value override suspend fun execute(connection: Cluster) { connection .bucket(TEST_BUCKET) .collections .createCollection("_default", "another") connection.bucket(TEST_BUCKET).waitUntilReady(30.seconds) connection.waitUntilIndexIsCreated( "CREATE PRIMARY INDEX ON `${connection.bucket(TEST_BUCKET).name}`.`_default`.`another`", 30.seconds ) connection.waitForKeySpaceAvailability(TEST_BUCKET, "another", 30.seconds) logger.info("default migration is executed") } } // ============================================================================ // Strategy interface // ============================================================================ sealed interface CouchbaseTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): CouchbaseTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedCouchbaseStrategy() else ContainerCouchbaseStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerCouchbaseStrategy : CouchbaseTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting Couchbase tests with container mode") val options = CouchbaseSystemOptions( defaultBucket = TEST_BUCKET, configureExposedConfiguration = { _ -> listOf() }, containerOptions = CouchbaseContainerOptions( useContainerFn = { ExtendedCouchbaseContainer(it) }, tag = "7.6.1" ) { withStartupAttempts(3) withEnabledServices(CouchbaseService.KV, CouchbaseService.INDEX, CouchbaseService.QUERY) } ).migrations { register() } Stove {} .with { couchbase { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("Couchbase container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedCouchbaseStrategy : CouchbaseTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: CouchbaseContainer override suspend fun start() { logger.info("Starting Couchbase tests with provided mode") // Start an external container to simulate a provided instance externalContainer = CouchbaseContainer(DockerImageName.parse("couchbase/server:7.6.1")) .withBucket(BucketDefinition(TEST_BUCKET)) .withEnabledServices(CouchbaseService.KV, CouchbaseService.INDEX, CouchbaseService.QUERY) .apply { start() } logger.info("External Couchbase container started at ${externalContainer.connectionString}") val options = CouchbaseSystemOptions .provided( connectionString = externalContainer.connectionString, username = externalContainer.username, password = externalContainer.password, defaultBucket = TEST_BUCKET, runMigrations = true, cleanup = { cluster -> logger.info("Running cleanup on provided instance") // Clean up test data if needed }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove {} .with { couchbase { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("Couchbase provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = CouchbaseTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } ================================================ FILE: lib/stove-couchbase/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.couchbase.StoveConfig ================================================ FILE: lib/stove-dashboard/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension val generatedDashboardSourcesDir = layout.buildDirectory.dir("generated/source/stoveVersion/kotlin") val stoveCompatibilityVersionValue = providers .fileContents(rootProject.layout.projectDirectory.file("gradle.properties")) .asText .map { gradleProperties -> gradleProperties .lineSequence() .first { it.startsWith("version=") } .substringAfter("version=") .trim() } val generateDashboardVersionSource by tasks.registering(GenerateDashboardVersionSourceTask::class) { description = "Generates a source file containing the Stove version." stoveCompatibilityVersion.set(stoveCompatibilityVersionValue) outputDir.set(generatedDashboardSourcesDir) } extensions.configure { sourceSets.getByName("main").kotlin.srcDir(generatedDashboardSourcesDir) } tasks.named("compileKotlin") { dependsOn(generateDashboardVersionSource) } tasks.named("sourcesJar") { dependsOn(generateDashboardVersionSource) } dependencies { api(projects.lib.stove) api(projects.lib.stoveDashboardApi) implementation(libs.io.grpc.netty) implementation(libs.kotlinx.core) testImplementation(projects.lib.stoveTracing) } ================================================ FILE: lib/stove-dashboard/src/main/kotlin/com/trendyol/stove/dashboard/DashboardDsl.kt ================================================ package com.trendyol.stove.dashboard import com.trendyol.stove.system.Stove import com.trendyol.stove.system.WithDsl import com.trendyol.stove.system.annotations.StoveDsl /** * Registers the Dashboard system with Stove. * * Usage: * ```kotlin * Stove { }.with { * dashboard { DashboardSystemOptions(appName = "product-api") } * // ... other systems * } * ``` */ fun WithDsl.dashboard( configure: @StoveDsl () -> DashboardSystemOptions ): Stove { this.stove.getOrRegister(DashboardSystem(this.stove, configure())) return this.stove } ================================================ FILE: lib/stove-dashboard/src/main/kotlin/com/trendyol/stove/dashboard/DashboardEmitter.kt ================================================ @file:Suppress("TooGenericExceptionCaught") package com.trendyol.stove.dashboard import com.trendyol.stove.dashboard.api.* import com.trendyol.stove.dashboard.api.DashboardEventServiceGrpcKt.DashboardEventServiceCoroutineStub import io.grpc.* import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.* import kotlin.time.Duration.Companion.milliseconds /** * Emits dashboard events to the CLI via gRPC. * * Events are buffered in a coroutine channel and drained by a background coroutine. * On connection failure, retries with auto-disable after [maxFailures] consecutive failures. * * Thread-safe: [tryEmit] can be called from any thread. */ class DashboardEmitter( host: String, port: Int, private val maxFailures: Int = MAX_FAILURES ) { private val logger = LoggerFactory.getLogger(DashboardEmitter::class.java) private val channel: ManagedChannel = ManagedChannelBuilder .forAddress(host, port) .usePlaintext() .build() private val stub = DashboardEventServiceCoroutineStub(channel) // Test runs can emit thousands of spans/entries in a short burst. // A bounded queue silently drops lifecycle events and leaves the CLI in a stale state. private val eventQueue = Channel(Channel.UNLIMITED) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val disabled = AtomicBoolean(false) private val consecutiveFailures = AtomicInteger(0) private val drainJob: Job init { drainJob = scope.launch { drainLoop() } } /** * Non-blocking emit. Drops the event only if the emitter is disabled or already closed. */ fun tryEmit(event: DashboardEvent) { if (disabled.get()) return val result = eventQueue.trySend(event) if (result.isFailure) { if (!disabled.get()) { logger.debug("Dropping dashboard event because emitter queue is closed") } } } /** * Graceful shutdown: drains remaining events (with timeout), then closes the gRPC channel. */ fun close() { eventQueue.close() // Wait for the existing drainLoop to finish consuming buffered events. // Closing the channel causes the `for (event in eventQueue)` iterator to terminate // once all buffered events are consumed, so drainJob completes naturally. runBlocking { withTimeoutOrNull(DRAIN_TIMEOUT_MS.milliseconds) { drainJob.join() } } scope.cancel() channel.shutdown() try { channel.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) } catch (_: InterruptedException) { Thread.currentThread().interrupt() } if (!channel.isTerminated) { channel.shutdownNow() } } private suspend fun drainLoop() { for (event in eventQueue) { if (!scope.isActive || disabled.get()) break sendSafe(event) } } private suspend fun sendSafe(event: DashboardEvent): EventAck? = try { val ack = stub.sendEvent(event) consecutiveFailures.set(0) ack } catch (e: StatusException) { handleFailure(e, isGrpc = true) null } catch (e: Exception) { handleFailure(e, isGrpc = false) null } private fun handleFailure(e: Exception, isGrpc: Boolean) { val count = consecutiveFailures.incrementAndGet() if (count == 1) { if (isGrpc) { logger.warn("Dashboard CLI gRPC error: ${e.message}. Events will be dropped after $maxFailures consecutive failures.") } else { logger.error("Unexpected dashboard emitter error: ${e.message}", e) } } if (count >= maxFailures) { disabled.set(true) logger.info("Dashboard emitter disabled after $count consecutive failures. Tests will continue normally.") } } companion object { private const val MAX_FAILURES = 5 private const val DRAIN_TIMEOUT_MS = 30000L private const val SHUTDOWN_TIMEOUT_SECONDS = 5L } } ================================================ FILE: lib/stove-dashboard/src/main/kotlin/com/trendyol/stove/dashboard/DashboardOptions.kt ================================================ package com.trendyol.stove.dashboard import com.trendyol.stove.system.abstractions.SystemOptions /** * Configuration for the Dashboard system. * * @param appName Application name for grouping runs (e.g., "product-api"). * Required — identifies which application this test suite targets. * @param cliHost Hostname where the stove CLI is running. * @param cliPort gRPC port where the stove CLI is listening. */ data class DashboardSystemOptions( val appName: String, val cliHost: String = "localhost", val cliPort: Int = 4041 ) : SystemOptions ================================================ FILE: lib/stove-dashboard/src/main/kotlin/com/trendyol/stove/dashboard/DashboardSystem.kt ================================================ package com.trendyol.stove.dashboard import arrow.core.getOrElse import com.fasterxml.jackson.databind.ObjectMapper import com.google.protobuf.Timestamp import com.trendyol.stove.dashboard.api.DashboardEvent import com.trendyol.stove.dashboard.api.EntryRecordedEvent import com.trendyol.stove.dashboard.api.RunEndedEvent import com.trendyol.stove.dashboard.api.RunStartedEvent import com.trendyol.stove.dashboard.api.SpanRecordedEvent import com.trendyol.stove.dashboard.api.TestEndedEvent import com.trendyol.stove.dashboard.api.TestStartedEvent import com.trendyol.stove.reporting.ReportEntry import com.trendyol.stove.reporting.ReportEventListener import com.trendyol.stove.reporting.Reports import com.trendyol.stove.reporting.SpanEventListener import com.trendyol.stove.reporting.SpanListenerRegistry import com.trendyol.stove.reporting.StoveTestContext import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.PluggedSystem import com.trendyol.stove.system.abstractions.RunAware import com.trendyol.stove.tracing.SpanInfo import java.time.Duration import java.time.Instant import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock /** * Dashboard system that streams test events to the stove CLI via gRPC. * * Add to your Stove config: * ```kotlin * Stove { }.with { * dashboard { DashboardSystemOptions(appName = "my-api") } * } * ``` */ class DashboardSystem( override val stove: Stove, private val options: DashboardSystemOptions ) : PluggedSystem, RunAware, ReportEventListener, SpanEventListener { private val logger = org.slf4j.LoggerFactory.getLogger(DashboardSystem::class.java) private val jsonMapper = ObjectMapper() private val runId = UUID.randomUUID().toString() private lateinit var emitter: DashboardEmitter private var startTime: Instant = Instant.now() private var totalTests = 0 private var passedTests = 0 private var failedTests = 0 private val lifecycleLock = ReentrantLock() private val testStartTimes = ConcurrentHashMap() private val testFailures = ConcurrentHashMap() override suspend fun run() { emitter = DashboardEmitter(options.cliHost, options.cliPort) stove.addReportListener(this) registerSpanListener() startTime = Instant.now() emitter.tryEmit( dashboardEvent { runStarted = RunStartedEvent.newBuilder() .setTimestamp(now()) .setAppName(options.appName) .addAllSystems(stove.systemsOf().map { it.reportSystemName }) .apply { StoveCompatibilityVersion.VALUE .takeIf(String::isNotBlank) ?.let(::setStoveVersion) } .build() } ) } override suspend fun stop() { close() } override fun onTestStarted(ctx: StoveTestContext) { totalTests++ testStartTimes[ctx.testId] = Instant.now() emitter.tryEmit( dashboardEvent { testStarted = TestStartedEvent.newBuilder() .setTestId(ctx.testId) .setTestName(ctx.testName) .setSpecName(ctx.specName ?: "") .setTimestamp(now()) .addAllTestPath(ctx.testPath) .build() } ) } override fun onTestFailed(testId: String, error: String) { testFailures[testId] = error } override fun onTestEnded(testId: String) { lifecycleLock.withLock { finishTestIfOpen(testId) } } override fun onEntryRecorded(entry: ReportEntry) { emitter.tryEmit( dashboardEvent { entryRecorded = EntryRecordedEvent.newBuilder() .setTestId(entry.testId) .setTimestamp(now()) .setSystem(entry.system) .setAction(entry.action) .setResult(entry.result.name) .setInput(entry.input.getOrElse { "" }.toString()) .setOutput(entry.output.getOrElse { "" }.toString()) .putAllMetadata(entry.metadata.mapValues { it.value.toString() }) .setExpected(entry.expected.getOrElse { "" }.toString()) .setActual(entry.actual.getOrElse { "" }.toString()) .setError(entry.error.getOrElse { "" }) .setTraceId(entry.traceId.getOrElse { "" }) .build() } ) if (entry.isFailed) { testFailures.putIfAbsent(entry.testId, entry.error.getOrElse { "Assertion failed" }) } } override fun onSpanRecorded(span: SpanInfo) { emitter.tryEmit( dashboardEvent { spanRecorded = SpanRecordedEvent.newBuilder() .setTraceId(span.traceId) .setSpanId(span.spanId) .setParentSpanId(span.parentSpanId ?: "") .setOperationName(span.operationName) .setServiceName(span.serviceName) .setStartTimeNanos(span.startTimeNanos) .setEndTimeNanos(span.endTimeNanos) .setStatus(span.status.name) .putAllAttributes(span.attributes) .apply { span.exception?.let { ex -> exception = com.trendyol.stove.dashboard.api.ExceptionInfo.newBuilder() .setType(ex.type) .setMessage(ex.message) .addAllStackTrace(ex.stackTrace) .build() } } .build() } ) } override fun close() { lifecycleLock.withLock { if (!::emitter.isInitialized) return finalizeOpenTests() val duration = Duration.between(startTime, Instant.now()).toMillis() emitter.tryEmit( dashboardEvent { runEnded = RunEndedEvent.newBuilder() .setTimestamp(now()) .setTotalTests(totalTests) .setPassed(passedTests) .setFailed(failedTests) .setDurationMs(duration) .build() } ) stove.removeReportListener(this) emitter.close() } } private fun finalizeOpenTests() { val stillRunning = testStartTimes.keys.toList() stillRunning.forEach { testId -> logger.debug("Finalizing still-running test {} during dashboard shutdown", testId) finishTestIfOpen(testId) } } private fun finishTestIfOpen(testId: String) { val startedAt = testStartTimes.remove(testId) ?: run { logger.debug("Ignoring duplicate or late test end for {}", testId) return } emitSnapshots(testId) val durationMs = Duration.between(startedAt, Instant.now()).toMillis() val failure = testFailures.remove(testId) val status = if (failure != null) "FAILED" else "PASSED" emitter.tryEmit( dashboardEvent { testEnded = TestEndedEvent.newBuilder() .setTestId(testId) .setStatus(status) .setDurationMs(durationMs) .setError(failure ?: "") .setTimestamp(now()) .build() } ) if (failure != null) { failedTests++ } else { passedTests++ } } private fun emitSnapshots(testId: String) { stove.systemsOf() .forEach { system -> runCatching { system.snapshot() } .onFailure { e -> logger.warn("Failed to collect snapshot from ${system.reportSystemName}: ${e.message}") } .onSuccess { snap -> val stateJson = runCatching { jsonMapper.writeValueAsString(snap.state) } .getOrDefault("{}") emitter.tryEmit( dashboardEvent { snapshot = com.trendyol.stove.dashboard.api.SnapshotEvent.newBuilder() .setTestId(testId) .setSystem(snap.system) .setStateJson(stateJson) .setSummary(snap.summary) .build() } ) } } } private fun registerSpanListener() { stove.systemsOf() .firstOrNull() ?.addSpanListener(this) } private fun dashboardEvent(block: DashboardEvent.Builder.() -> Unit): DashboardEvent = DashboardEvent.newBuilder() .setRunId(runId) .apply(block) .build() private fun now(): Timestamp { val instant = Instant.now() return Timestamp.newBuilder() .setSeconds(instant.epochSecond) .setNanos(instant.nano) .build() } } ================================================ FILE: lib/stove-dashboard/src/test/kotlin/com/trendyol/stove/dashboard/DashboardEmitterTest.kt ================================================ package com.trendyol.stove.dashboard import com.trendyol.stove.dashboard.api.* import com.trendyol.stove.dashboard.api.DashboardEventServiceGrpcKt.DashboardEventServiceCoroutineImplBase import io.grpc.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class DashboardEmitterTest : FunSpec({ test("emits events to a running gRPC server") { val received = CopyOnWriteArrayList() val server = startMockServer(received, port = 0) val port = server.port try { val emitter = DashboardEmitter("localhost", port) val event = DashboardEvent.newBuilder() .setRunId("run-1") .setRunStarted( RunStartedEvent.newBuilder() .setAppName("test-app") .build() ) .build() emitter.tryEmit(event) emitter.tryEmit(event) // Wait for async drain delay(500) emitter.close() received.size shouldBe 2 received[0].runId shouldBe "run-1" } finally { server.shutdownNow() } } test("auto-disables after consecutive failures without throwing") { // Connect to a port that is not listening val emitter = DashboardEmitter("localhost", 1, maxFailures = 2) // These should not throw repeat(10) { emitter.tryEmit( DashboardEvent.newBuilder() .setRunId("run-1") .setRunStarted(RunStartedEvent.newBuilder().setAppName("test").build()) .build() ) } // Wait for the drain loop to process and fail delay(2000) emitter.close() // If we get here without exception, the test passes } test("does not drop burst events while receiver is temporarily blocked") { val received = CopyOnWriteArrayList() val firstRequestStarted = CountDownLatch(1) val releaseFirstRequest = CountDownLatch(1) val server = startMockServer(received, port = 0) { if (firstRequestStarted.count > 0) { firstRequestStarted.countDown() releaseFirstRequest.await(5, TimeUnit.SECONDS) } } val port = server.port try { val emitter = DashboardEmitter("localhost", port) val totalEvents = 700 repeat(totalEvents) { index -> emitter.tryEmit(runStartedEvent(index)) } firstRequestStarted.await(2, TimeUnit.SECONDS) shouldBe true releaseFirstRequest.countDown() delay(500) emitter.close() received.size shouldBe totalEvents } finally { server.shutdownNow() } } test("close drains queued events before shutting down") { val received = CopyOnWriteArrayList() val server = startMockServer(received, port = 0) { delay(12) } val port = server.port try { val emitter = DashboardEmitter("localhost", port) val totalEvents = 350 repeat(totalEvents) { index -> emitter.tryEmit(runStartedEvent(index)) } emitter.close() received.size shouldBe totalEvents } finally { server.shutdownNow() } } }) private fun startMockServer( received: MutableList, port: Int, beforeAck: suspend (DashboardEvent) -> Unit = {} ): Server { val service = object : DashboardEventServiceCoroutineImplBase() { override suspend fun sendEvent(request: DashboardEvent): EventAck { beforeAck(request) received.add(request) return EventAck.newBuilder().setAccepted(true).build() } override suspend fun streamEvents(requests: Flow): EventAck { requests.collect { received.add(it) } return EventAck.newBuilder().setAccepted(true).build() } } return ServerBuilder.forPort(port) .addService(service) .build() .start() } private fun runStartedEvent(index: Int): DashboardEvent = DashboardEvent.newBuilder() .setRunId("run-$index") .setRunStarted( RunStartedEvent.newBuilder() .setAppName("test-app") .build() ) .build() ================================================ FILE: lib/stove-dashboard/src/test/kotlin/com/trendyol/stove/dashboard/DashboardSystemTest.kt ================================================ package com.trendyol.stove.dashboard import com.trendyol.stove.dashboard.api.* import com.trendyol.stove.dashboard.api.DashboardEventServiceGrpcKt.DashboardEventServiceCoroutineImplBase import com.trendyol.stove.reporting.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.PluggedSystem import io.grpc.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration.Companion.milliseconds class DashboardSystemTest : FunSpec({ test("lifecycle: registers as listener, emits events, unregisters on stop") { val received = CopyOnWriteArrayList() val server = startMockServer(received, port = 0) val port = server.port try { val stove = Stove() val options = DashboardSystemOptions(appName = "test-api", cliPort = port) val system = DashboardSystem(stove, options) // Start the system — should emit RunStartedEvent system.run() delay(200.milliseconds) // Simulate test lifecycle via reporter val ctx = StoveTestContext("test-1", "my test", "MySpec") stove.startTest(ctx) stove.recordReport(ReportEntry.success("HTTP", "test-1", "GET /api")) stove.endTest() // Wait for async events to be processed before stopping delay(1000.milliseconds) // Stop the system — should emit RunEndedEvent system.stop() // Wait for close to drain delay(1000.milliseconds) // Verify we received the key lifecycle events val types = received.map { when { it.hasRunStarted() -> "RunStarted" it.hasTestStarted() -> "TestStarted" it.hasEntryRecorded() -> "EntryRecorded" it.hasTestEnded() -> "TestEnded" it.hasRunEnded() -> "RunEnded" else -> "Unknown" } } types.contains("RunStarted") shouldBe true received.first { it.hasRunStarted() }.runStarted.appName shouldBe "test-api" received.first { it.hasRunStarted() }.runStarted.stoveVersion shouldBe StoveCompatibilityVersion.VALUE types.contains("TestStarted") shouldBe true types.contains("EntryRecorded") shouldBe true } finally { server.shutdownNow() } } test("stop finalizes tests still marked running") { val received = CopyOnWriteArrayList() val server = startMockServer(received, port = 0) val port = server.port try { val stove = Stove() val options = DashboardSystemOptions(appName = "test-api", cliPort = port) val system = DashboardSystem(stove, options) system.run() delay(200.milliseconds) stove.startTest(StoveTestContext("test-still-running", "still running", "MySpec")) stove.recordReport(ReportEntry.success("HTTP", "test-still-running", "GET /health")) delay(300.milliseconds) system.stop() delay(1000.milliseconds) received.any { it.hasTestEnded() && it.testEnded.testId == "test-still-running" } shouldBe true received.first { it.hasRunEnded() }.runEnded.totalTests shouldBe 1 } finally { server.shutdownNow() } } test("stop does not re-finalize a test whose end callback is already in progress") { val received = CopyOnWriteArrayList() val server = startMockServer(received, port = 0) val port = server.port try { val stove = Stove() val snapshotSystem = BlockingSnapshotSystem(stove) stove.getOrRegister(snapshotSystem) val options = DashboardSystemOptions(appName = "test-api", cliPort = port) val system = DashboardSystem(stove, options) system.run() delay(200.milliseconds) stove.startTest(StoveTestContext("test-race", "race", "MySpec")) val endJob = async(Dispatchers.Default) { system.onTestEnded("test-race") } snapshotSystem.awaitFirstSnapshotCall() shouldBe true val stopJob = async(Dispatchers.Default) { system.stop() } try { snapshotSystem.awaitSecondSnapshotCall() shouldBe false } finally { snapshotSystem.releaseSnapshots() endJob.await() stopJob.await() } delay(1000.milliseconds) received.count { it.hasTestEnded() && it.testEnded.testId == "test-race" } shouldBe 1 received.first { it.hasRunEnded() }.runEnded.totalTests shouldBe 1 received.first { it.hasRunEnded() }.runEnded.passed shouldBe 1 received.first { it.hasRunEnded() }.runEnded.failed shouldBe 0 } finally { server.shutdownNow() } } }) private fun startMockServer(received: MutableList, port: Int): Server { val service = object : DashboardEventServiceCoroutineImplBase() { override suspend fun sendEvent(request: DashboardEvent): EventAck { received.add(request) return EventAck.newBuilder().setAccepted(true).build() } override suspend fun streamEvents(requests: Flow): EventAck { requests.collect { received.add(it) } return EventAck.newBuilder().setAccepted(true).build() } } return ServerBuilder.forPort(port) .addService(service) .build() .start() } private class BlockingSnapshotSystem( override val stove: Stove ) : PluggedSystem, Reports { private val snapshotCalls = AtomicInteger(0) private val firstSnapshotCall = CountDownLatch(1) private val secondSnapshotCall = CountDownLatch(1) private val releaseSnapshots = CountDownLatch(1) override fun snapshot(): SystemSnapshot { when (snapshotCalls.incrementAndGet()) { 1 -> { firstSnapshotCall.countDown() releaseSnapshots.await(5, TimeUnit.SECONDS) } 2 -> secondSnapshotCall.countDown() } return SystemSnapshot( system = "BlockingSnapshot", state = emptyMap(), summary = "blocking snapshot" ) } fun awaitFirstSnapshotCall(): Boolean = firstSnapshotCall.await(2, TimeUnit.SECONDS) fun awaitSecondSnapshotCall(): Boolean = secondSnapshotCall.await(1, TimeUnit.SECONDS) fun releaseSnapshots() { releaseSnapshots.countDown() } override fun close() = Unit } ================================================ FILE: lib/stove-dashboard-api/build.gradle.kts ================================================ plugins { alias(libs.plugins.protobuf) } dependencies { api(libs.io.grpc) api(libs.io.grpc.stub) api(libs.io.grpc.protobuf) api(libs.io.grpc.kotlin) api(libs.google.protobuf.kotlin) api(libs.kotlinx.core) } tasks.withType { // All Java sources in this module are protobuf-generated; suppress missing-comment warnings (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:none", true) } protobuf { protoc { artifact = libs.protoc.get().toString() } plugins { create("grpc").apply { artifact = libs.grpc.protoc.gen.java.get().toString() } create("grpckt").apply { artifact = "${libs.grpc.protoc.gen.kotlin.get()}:jdk8@jar" } } generateProtoTasks { all().forEach { task -> task.plugins { create("grpc") create("grpckt") } task.builtins { create("kotlin") } } } } ================================================ FILE: lib/stove-dashboard-api/src/main/proto/stove/dashboard/v1/dashboard_events.proto ================================================ syntax = "proto3"; package stove.dashboard.v1; option java_package = "com.trendyol.stove.dashboard.api"; option java_multiple_files = true; import "google/protobuf/timestamp.proto"; message DashboardEvent { string run_id = 1; oneof event { RunStartedEvent run_started = 10; RunEndedEvent run_ended = 11; TestStartedEvent test_started = 12; TestEndedEvent test_ended = 13; EntryRecordedEvent entry_recorded = 14; SpanRecordedEvent span_recorded = 15; SnapshotEvent snapshot = 16; } } message RunStartedEvent { google.protobuf.Timestamp timestamp = 1; string app_name = 2; repeated string systems = 3; string stove_version = 4; } message RunEndedEvent { google.protobuf.Timestamp timestamp = 1; int32 total_tests = 2; int32 passed = 3; int32 failed = 4; int64 duration_ms = 5; } message TestStartedEvent { string test_id = 1; string test_name = 2; string spec_name = 3; google.protobuf.Timestamp timestamp = 4; repeated string test_path = 5; } message TestEndedEvent { string test_id = 1; string status = 2; int64 duration_ms = 3; string error = 4; google.protobuf.Timestamp timestamp = 5; } message EntryRecordedEvent { string test_id = 1; google.protobuf.Timestamp timestamp = 2; string system = 3; string action = 4; string result = 5; string input = 6; string output = 7; map metadata = 8; string expected = 9; string actual = 10; string error = 11; string trace_id = 12; } message SpanRecordedEvent { string trace_id = 1; string span_id = 2; string parent_span_id = 3; string operation_name = 4; string service_name = 5; int64 start_time_nanos = 6; int64 end_time_nanos = 7; string status = 8; map attributes = 9; ExceptionInfo exception = 10; } message ExceptionInfo { string type = 1; string message = 2; repeated string stack_trace = 3; } message SnapshotEvent { string test_id = 1; string system = 2; string state_json = 3; string summary = 4; } message EventAck { bool accepted = 1; } ================================================ FILE: lib/stove-dashboard-api/src/main/proto/stove/dashboard/v1/dashboard_service.proto ================================================ syntax = "proto3"; package stove.dashboard.v1; option java_package = "com.trendyol.stove.dashboard.api"; option java_multiple_files = true; import "stove/dashboard/v1/dashboard_events.proto"; service DashboardEventService { rpc StreamEvents(stream DashboardEvent) returns (EventAck); rpc SendEvent(DashboardEvent) returns (EventAck); } ================================================ FILE: lib/stove-elasticsearch/api/stove-elasticsearch.api ================================================ public final class com/trendyol/stove/elasticsearch/ElasticClientConfigurer { public fun ()V public fun (Lkotlin/jvm/functions/Function1;Larrow/core/Option;)V public synthetic fun (Lkotlin/jvm/functions/Function1;Larrow/core/Option;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lkotlin/jvm/functions/Function1; public final fun component2 ()Larrow/core/Option; public final fun copy (Lkotlin/jvm/functions/Function1;Larrow/core/Option;)Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer; public static synthetic fun copy$default (Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lkotlin/jvm/functions/Function1;Larrow/core/Option;ILjava/lang/Object;)Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer; public fun equals (Ljava/lang/Object;)Z public final fun getHttpClientBuilder ()Lkotlin/jvm/functions/Function1; public final fun getRestClientOverrideFn ()Larrow/core/Option; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/elasticsearch/ElasticContainerOptions : com/trendyol/stove/containers/ContainerOptions { public static final field Companion Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions$Companion; public static final field DEFAULT_ELASTIC_PORT I public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/util/List; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Z public final fun component8 ()Lkotlin/jvm/functions/Function1; public final fun component9 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public final fun getDisableSecurity ()Z public final fun getExposedPorts ()Ljava/util/List; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public final fun getPassword ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/elasticsearch/ElasticContainerOptions$Companion { } public abstract interface annotation class com/trendyol/stove/elasticsearch/ElasticDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate; public final fun copy (Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate;)Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration;Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate;ILjava/lang/Object;)Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getCertificate ()Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate; public final fun getHost ()Ljava/lang/String; public final fun getPassword ()Ljava/lang/String; public final fun getPort ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/elasticsearch/ElasticsearchContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions; public final fun component3 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions;Ljava/lang/String;)Lcom/trendyol/stove/elasticsearch/ElasticsearchContext; public static synthetic fun copy$default (Lcom/trendyol/stove/elasticsearch/ElasticsearchContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/elasticsearch/ElasticsearchContext; public fun equals (Ljava/lang/Object;)Z public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate { public static final field Companion Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate$Companion; public fun ([B)V public final fun component1 ()[B public final fun copy ([B)Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate; public static synthetic fun copy$default (Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate;[BILjava/lang/Object;)Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate; public static final fun create ([B)Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate; public fun equals (Ljava/lang/Object;)Z public final fun getBytes ()[B public final fun getSslContext ()Ljavax/net/ssl/SSLContext; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate$Companion { public final fun create ([B)Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate; } public final class com/trendyol/stove/elasticsearch/ElasticsearchSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/AfterRunAware, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/elasticsearch/ElasticsearchSystem$Companion; public field esClient Lco/elastic/clients/elasticsearch/ElasticsearchClient; public fun afterRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getEsClient ()Lco/elastic/clients/elasticsearch/ElasticsearchClient; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun save (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setEsClient (Lco/elastic/clients/elasticsearch/ElasticsearchClient;)V public final fun shouldDelete (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldNotExist (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/elasticsearch/ElasticsearchSystem$Companion { public final fun client (Lcom/trendyol/stove/elasticsearch/ElasticsearchSystem;)Lco/elastic/clients/elasticsearch/ElasticsearchClient; } public class com/trendyol/stove/elasticsearch/ElasticsearchSystemOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions$Companion; public fun (Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions;Lco/elastic/clients/json/JsonpMapper;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions;Lco/elastic/clients/json/JsonpMapper;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getClientConfigurer ()Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/elasticsearch/ElasticContainerOptions; public fun getJsonpMapper ()Lco/elastic/clients/json/JsonpMapper; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions; } public final class com/trendyol/stove/elasticsearch/ElasticsearchSystemOptions$Companion { public final fun provided (Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate;Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lco/elastic/clients/json/JsonpMapper;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/elasticsearch/ProvidedElasticsearchSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/elasticsearch/ElasticsearchSystemOptions$Companion;Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate;Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lco/elastic/clients/json/JsonpMapper;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/elasticsearch/ProvidedElasticsearchSystemOptions; } public final class com/trendyol/stove/elasticsearch/ExtensionsKt { public static final fun elasticsearch-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun elasticsearch-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun elasticsearch-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun elasticsearch-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/elasticsearch/ProvidedElasticsearchSystemOptions : com/trendyol/stove/elasticsearch/ElasticsearchSystemOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration;Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lco/elastic/clients/json/JsonpMapper;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration;Lcom/trendyol/stove/elasticsearch/ElasticClientConfigurer;Lco/elastic/clients/json/JsonpMapper;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/elasticsearch/ElasticSearchExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/elasticsearch/StoveElasticSearchContainer : org/testcontainers/elasticsearch/ElasticsearchContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } ================================================ FILE: lib/stove-elasticsearch/build.gradle.kts ================================================ plugins {} val elasticsearchTestTag = providers .systemProperty("elasticsearchTestTag") .orElse(providers.environmentVariable("ELASTICSEARCH_TEST_TAG")) .orElse("8.9.0") dependencies { api(projects.lib.stove) api(libs.elastic) api(libs.elastic.rest.client) api(libs.testcontainers.elasticsearch) implementation(libs.jackson.databind) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.slf4j.simple) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided Elasticsearch instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") val tag = elasticsearchTestTag.get() systemProperty("elasticsearchTestTag", tag) doFirst { println("Starting Elasticsearch tests with provided instance and tag=$tag...") } } tasks.withType().configureEach { systemProperty("elasticsearchTestTag", elasticsearchTestTag.get()) } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-elasticsearch/src/main/kotlin/com/trendyol/stove/elasticsearch/ElasticsearchExposedCertificate.kt ================================================ package com.trendyol.stove.elasticsearch import com.fasterxml.jackson.annotation.* import org.testcontainers.elasticsearch.ElasticsearchContainer import java.io.ByteArrayInputStream import java.security.KeyStore import java.security.cert.CertificateFactory import javax.net.ssl.* data class ElasticsearchExposedCertificate( val bytes: ByteArray ) { @get:JsonIgnore @set:JsonIgnore var sslContext: SSLContext = SSLContext.getDefault() internal set override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as ElasticsearchExposedCertificate return bytes.contentEquals(other.bytes) } override fun hashCode(): Int = bytes.contentHashCode() companion object { @JsonCreator @JvmStatic fun create( @JsonProperty bytes: ByteArray ): ElasticsearchExposedCertificate = ElasticsearchExposedCertificate(bytes).apply { sslContext = createSslContextFromCa(bytes) } /** * An SSL context based on the self-signed CA, so that using this SSL Context allows to connect to the Elasticsearch service * @return a customized SSL Context * @see ElasticsearchContainer.createSslContextFromCa */ @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") private fun createSslContextFromCa(bytes: ByteArray): SSLContext = try { val factory = CertificateFactory.getInstance("X.509") val trustedCa = factory.generateCertificate( ByteArrayInputStream(bytes) ) val trustStore = KeyStore.getInstance("pkcs12") trustStore.load(null, null) trustStore.setCertificateEntry("ca", trustedCa) val sslContext = SSLContext.getInstance("TLSv1.3") val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) trustManagerFactory.init(trustStore) sslContext.init(null, trustManagerFactory.trustManagers, null) sslContext } catch (e: Exception) { throw RuntimeException(e) } } } ================================================ FILE: lib/stove-elasticsearch/src/main/kotlin/com/trendyol/stove/elasticsearch/ElasticsearchSystem.kt ================================================ package com.trendyol.stove.elasticsearch import arrow.core.* import co.elastic.clients.elasticsearch.ElasticsearchClient import co.elastic.clients.elasticsearch._types.Refresh import co.elastic.clients.elasticsearch._types.query_dsl.Query import co.elastic.clients.elasticsearch.core.* import co.elastic.clients.transport.rest_client.RestClientOptions import co.elastic.clients.transport.rest_client.RestClientTransport import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import kotlinx.coroutines.runBlocking import org.apache.http.HttpHost import org.apache.http.auth.* import org.apache.http.client.CredentialsProvider import org.apache.http.impl.client.BasicCredentialsProvider import org.apache.http.impl.nio.client.HttpAsyncClientBuilder import org.elasticsearch.client.RequestOptions import org.elasticsearch.client.RestClient import org.slf4j.* import javax.net.ssl.SSLContext import kotlin.jvm.optionals.getOrElse /** * Elasticsearch search engine system for testing search operations. * * Provides a DSL for testing Elasticsearch operations: * - Document indexing and retrieval * - Search queries (JSON and Query builder) * - Index management * - Document deletion * * ## Indexing Documents * * ```kotlin * elasticsearch { * // Save document with specific ID * save("products", "product-123", Product(id = "123", name = "Widget")) * * // Save with refresh (immediately searchable) * save("products", "product-123", product, refresh = Refresh.True) * } * ``` * * ## Retrieving Documents * * ```kotlin * elasticsearch { * // Get by ID and assert * shouldGet("products", "product-123") { product -> * product.name shouldBe "Widget" * product.price shouldBeGreaterThan 0.0 * } * } * ``` * * ## Search Queries * * ```kotlin * elasticsearch { * // Query with JSON syntax * shouldQuery( * query = """{ "match": { "name": "widget" } }""", * index = "products" * ) { products -> * products.size shouldBeGreaterThan 0 * } * * // Query with Elasticsearch Query builder * shouldQuery( * query = Query.of { q -> * q.bool { b -> * b.must { m -> m.match { t -> t.field("category").query("electronics") } } * b.filter { f -> f.range { r -> r.field("price").gte(JsonData.of(100)) } } * } * }, * index = "products" * ) { products -> * products.all { it.category == "electronics" } shouldBe true * } * * // Complex search with aggregations (using client directly) * client { es -> * val response = es.search(SearchRequest.of { s -> * s.index("products") * .query(Query.of { q -> q.matchAll { } }) * .aggregations("by_category", Aggregation.of { a -> * a.terms { t -> t.field("category.keyword") } * }) * }, Product::class.java) * * response.aggregations()["by_category"]?.sterms()?.buckets()?.array()?.size shouldBeGreaterThan 0 * } * } * ``` * * ## Deleting Documents * * ```kotlin * elasticsearch { * shouldDelete("products", "product-123") * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should index product and make it searchable") { * stove { * val productId = UUID.randomUUID().toString() * * // Create product via API * http { * postAndExpectBodilessResponse( * uri = "/products", * body = CreateProductRequest(id = productId, name = "Test Widget").some() * ) { response -> * response.status shouldBe 201 * } * } * * // Verify in Elasticsearch (with eventual consistency wait) * elasticsearch { * eventually(10.seconds) { * shouldGet("products", productId) { product -> * product.name shouldBe "Test Widget" * } * } * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * elasticsearch { * ElasticsearchSystemOptions( * configureExposedConfiguration = { cfg -> * listOf( * "elasticsearch.host=${cfg.host}", * "elasticsearch.port=${cfg.port}" * ) * } * ).migrations { * register() * } * } * } * ``` * * @property stove The parent test system. * @see ElasticsearchSystemOptions * @see ElasticSearchExposedConfiguration */ @ElasticDsl class ElasticsearchSystem internal constructor( override val stove: Stove, private val context: ElasticsearchContext ) : PluggedSystem, RunAware, AfterRunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var esClient: ElasticsearchClient override val reportSystemName: String = "Elasticsearch" + (context.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: ElasticSearchExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(context.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() } override suspend fun afterRun() { esClient = createEsClient(exposedConfiguration) runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun close(): Unit = runBlocking { Try { context.options.cleanup(esClient) esClient._transport().close() executeWithReuseCheck { stop() } }.recover { logger.warn("got an error while stopping elasticsearch: ${it.message}") } } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) suspend inline fun shouldQuery( query: String, index: String, crossinline assertion: (List) -> Unit ): ElasticsearchSystem { require(index.isNotBlank()) { "Index cannot be blank" } require(query.isNotBlank()) { "Query cannot be blank" } report( action = "Search '$index'", input = arrow.core.Some(mapOf("index" to index, "query" to query)) ) { val results = esClient .search( SearchRequest.of { req -> req.index(index).query { q -> q.withJson(query.reader()) } }, T::class.java ).hits() .hits() .mapNotNull { it.source() } assertion(results) results } return this } suspend inline fun shouldQuery( query: Query, crossinline assertion: (List) -> Unit ): ElasticsearchSystem { report(action = "Search with Query DSL") { val results = esClient .search( SearchRequest.of { q -> q.query(query) }, T::class.java ).hits() .hits() .mapNotNull { it.source() } assertion(results) results } return this } suspend inline fun shouldGet( index: String, key: String, crossinline assertion: (T) -> Unit ): ElasticsearchSystem { require(index.isNotBlank()) { "Index cannot be blank" } require(key.isNotBlank()) { "Key cannot be blank" } report( action = "Get document", input = arrow.core.Some(mapOf("index" to index, "id" to key)) ) { val document = esClient .get({ req -> req.index(index).id(key).refresh(true) }, T::class.java) .source() .toOption() document.map(assertion).getOrElse { throw AssertionError("Resource with key ($key) is not found") } document } return this } suspend fun shouldNotExist( key: String, index: String ): ElasticsearchSystem { require(index.isNotBlank()) { "Index cannot be blank" } require(key.isNotBlank()) { "Key cannot be blank" } report( action = "Document should not exist", input = arrow.core.Some(mapOf("index" to index, "id" to key)), expected = arrow.core.Some("Document not found") ) { val exists = esClient.exists { req -> req.index(index).id(key) }.value() if (exists) throw AssertionError("The document with the given id($key) was not expected, but found!") } return this } suspend fun shouldDelete( key: String, index: String ): ElasticsearchSystem { require(index.isNotBlank()) { "Index cannot be blank" } require(key.isNotBlank()) { "Key cannot be blank" } report( action = "Delete document", metadata = mapOf("index" to index, "id" to key) ) { esClient.delete(DeleteRequest.of { req -> req.index(index).id(key).refresh(Refresh.WaitFor) }) } return this } suspend fun save( id: String, instance: T, index: String ): ElasticsearchSystem { require(index.isNotBlank()) { "Index cannot be blank" } require(id.isNotBlank()) { "Id cannot be blank" } report( action = "Index document", input = arrow.core.Some(instance), metadata = mapOf("index" to index, "id" to id) ) { esClient.index { req -> req .index(index) .id(id) .document(instance) .refresh(Refresh.WaitFor) } } return this } /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return ElasticsearchSystem */ @Suppress("unused") suspend fun pause(): ElasticsearchSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return ElasticsearchSystem */ @Suppress("unused") suspend fun unpause(): ElasticsearchSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): ElasticSearchExposedConfiguration = when { context.options is ProvidedElasticsearchSystemOptions -> context.options.config context.runtime is StoveElasticSearchContainer -> startElasticsearchContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startElasticsearchContainer(container: StoveElasticSearchContainer): ElasticSearchExposedConfiguration = state.capture { container.start() ElasticSearchExposedConfiguration( host = container.host, port = container.firstMappedPort, password = context.options.container.password, certificate = determineCertificate(container).getOrNull() ) } private fun determineCertificate(container: StoveElasticSearchContainer): Option = when (context.options.container.disableSecurity) { true -> None false -> ElasticsearchExposedCertificate( container.caCertAsBytes().getOrElse { ByteArray(0) } ).apply { sslContext = container.createSslContextFromCa() }.some() } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { context.options.migrationCollection.run(esClient) } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedElasticsearchSystemOptions -> context.options.runMigrations context.runtime is StoveElasticSearchContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private fun createEsClient(exposedConfiguration: ElasticSearchExposedConfiguration): ElasticsearchClient = context.options.clientConfigurer.restClientOverrideFn .getOrElse { { cfg -> restClient(cfg) } } .let { it(exposedConfiguration) } .let { restClient -> RestClientTransport(restClient, context.options.jsonpMapper, restClientCompatibilityOptions()) }.let { ElasticsearchClient(it) } private fun restClientCompatibilityOptions(): RestClientOptions = RestClientOptions .Builder(RequestOptions.DEFAULT.toBuilder()) .apply { setHeader("Accept", ELASTICSEARCH_COMPATIBILITY_HEADER) setHeader("Content-Type", ELASTICSEARCH_COMPATIBILITY_HEADER) }.build() private fun restClient(cfg: ElasticSearchExposedConfiguration): RestClient = when (isSecurityDisabled(cfg)) { true -> createInsecureRestClient(cfg) false -> createSecureRestClient(cfg, obtainSslContext(cfg)) } private fun isSecurityDisabled(cfg: ElasticSearchExposedConfiguration): Boolean = when { context.options is ProvidedElasticsearchSystemOptions -> cfg.certificate == null context.runtime is StoveElasticSearchContainer -> context.options.container.disableSecurity else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private fun obtainSslContext(cfg: ElasticSearchExposedConfiguration): SSLContext = when { context.options is ProvidedElasticsearchSystemOptions -> cfg.certificate?.sslContext ?: throw IllegalStateException( "SSL context is required for secure connections with provided instances. " + "Set the certificate.sslContext in ElasticSearchExposedConfiguration." ) context.runtime is StoveElasticSearchContainer -> context.runtime.createSslContextFromCa() else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private fun createInsecureRestClient(cfg: ElasticSearchExposedConfiguration): RestClient = RestClient .builder(HttpHost(cfg.host, cfg.port)) .apply { setHttpClientConfigCallback { http -> http.also(context.options.clientConfigurer.httpClientBuilder) } } .build() private fun createSecureRestClient( cfg: ElasticSearchExposedConfiguration, sslContext: SSLContext ): RestClient { val credentialsProvider: CredentialsProvider = BasicCredentialsProvider().apply { setCredentials(AuthScope.ANY, UsernamePasswordCredentials("elastic", cfg.password)) } return RestClient .builder(HttpHost(cfg.host, cfg.port, "https")) .setHttpClientConfigCallback { clientBuilder: HttpAsyncClientBuilder -> clientBuilder.setSSLContext(sslContext) clientBuilder.setDefaultCredentialsProvider(credentialsProvider) context.options.clientConfigurer.httpClientBuilder(clientBuilder) clientBuilder }.build() } private inline fun withContainerOrWarn( operation: String, action: (StoveElasticSearchContainer) -> Unit ): ElasticsearchSystem = when (val runtime = context.runtime) { is StoveElasticSearchContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveElasticSearchContainer) -> Unit) { if (context.runtime is StoveElasticSearchContainer) { action(context.runtime) } } companion object { private const val ELASTICSEARCH_COMPATIBILITY_HEADER = "application/vnd.elasticsearch+json; compatible-with=8" /** * Exposes the [ElasticsearchClient] for the given [ElasticsearchSystem]. * Use this for advanced Elasticsearch operations not covered by the DSL. */ @Suppress("unused") fun ElasticsearchSystem.client(): ElasticsearchClient = this.esClient } } ================================================ FILE: lib/stove-elasticsearch/src/main/kotlin/com/trendyol/stove/elasticsearch/Extensions.kt ================================================ package com.trendyol.stove.elasticsearch import arrow.core.getOrElse import com.trendyol.stove.containers.withProvidedRegistry import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl internal fun Stove.withElasticsearch( options: ElasticsearchSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(ElasticsearchSystem(this, ElasticsearchContext(runtime, options))) return this } internal fun Stove.withElasticsearch( key: SystemKey, options: ElasticsearchSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, ElasticsearchSystem(this, ElasticsearchContext(runtime, options, keyName = keyDisplayName(key)))) return this } internal fun Stove.elasticsearch(): ElasticsearchSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(ElasticsearchSystem::class) } internal fun Stove.elasticsearch(key: SystemKey): ElasticsearchSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(ElasticsearchSystem::class, "No ElasticsearchSystem registered with key '${keyDisplayName(key)}'") } /** * Configures Elasticsearch system. * * For container-based setup: * ```kotlin * elasticsearch { * ElasticsearchSystemOptions( * cleanup = { client -> client.indices().delete { it.index("*") } }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * elasticsearch { * ElasticsearchSystemOptions.provided( * host = "localhost", * port = 9200, * password = "password", * runMigrations = true, * cleanup = { client -> client.indices().delete { it.index("*") } }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.elasticsearch( configure: @StoveDsl () -> ElasticsearchSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedElasticsearchSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( imageName = options.container.imageWithTag, registry = options.container.registry, compatibleSubstitute = options.container.compatibleSubstitute ) { dockerImageName -> StoveElasticSearchContainer(dockerImageName) } .apply { addExposedPorts(*options.container.exposedPorts.toIntArray()) withPassword(options.container.password) if (options.container.disableSecurity) { withEnv("xpack.security.enabled", "false") } withReuse(stove.keepDependenciesRunning) options.container.containerFn(this) } } return stove.withElasticsearch(options, runtime) } fun WithDsl.elasticsearch( key: SystemKey, configure: @StoveDsl () -> ElasticsearchSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedElasticsearchSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( imageName = options.container.imageWithTag, registry = options.container.registry, compatibleSubstitute = options.container.compatibleSubstitute ) { dockerImageName -> StoveElasticSearchContainer(dockerImageName) } .apply { addExposedPorts(*options.container.exposedPorts.toIntArray()) withPassword(options.container.password) if (options.container.disableSecurity) { withEnv("xpack.security.enabled", "false") } withReuse(stove.keepDependenciesRunning) options.container.containerFn(this) } } return stove.withElasticsearch(key, options, runtime) } suspend fun ValidationDsl.elasticsearch(validation: @ElasticDsl suspend ElasticsearchSystem.() -> Unit): Unit = validation(this.stove.elasticsearch()) suspend fun ValidationDsl.elasticsearch(key: SystemKey, validation: @ElasticDsl suspend ElasticsearchSystem.() -> Unit): Unit = validation(this.stove.elasticsearch(key)) ================================================ FILE: lib/stove-elasticsearch/src/main/kotlin/com/trendyol/stove/elasticsearch/Options.kt ================================================ package com.trendyol.stove.elasticsearch import arrow.core.* import co.elastic.clients.elasticsearch.ElasticsearchClient import co.elastic.clients.json.JsonpMapper import co.elastic.clients.json.jackson.JacksonJsonpMapper import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.apache.http.client.config.RequestConfig import org.apache.http.impl.nio.client.HttpAsyncClientBuilder import org.elasticsearch.client.RestClient import org.testcontainers.elasticsearch.ElasticsearchContainer import org.testcontainers.utility.DockerImageName import kotlin.time.Duration.Companion.minutes @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class ElasticDsl /** * Options for configuring the Elasticsearch system in container mode. */ @StoveDsl open class ElasticsearchSystemOptions( open val clientConfigurer: ElasticClientConfigurer = ElasticClientConfigurer(), open val container: ElasticContainerOptions = ElasticContainerOptions(), open val jsonpMapper: JsonpMapper = JacksonJsonpMapper(StoveSerde.jackson.default), open val cleanup: suspend (ElasticsearchClient) -> Unit = {}, override val configureExposedConfiguration: (ElasticSearchExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided Elasticsearch instance * instead of a testcontainer. * * @param host The Elasticsearch host * @param port The Elasticsearch port * @param password The Elasticsearch password (for authentication) * @param certificate Optional SSL certificate for secure connections * @param clientConfigurer Client configuration * @param jsonpMapper JSON mapper for serialization * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( host: String, port: Int, password: String = "", certificate: ElasticsearchExposedCertificate? = null, clientConfigurer: ElasticClientConfigurer = ElasticClientConfigurer(), jsonpMapper: JsonpMapper = JacksonJsonpMapper(StoveSerde.jackson.default), runMigrations: Boolean = true, cleanup: suspend (ElasticsearchClient) -> Unit = {}, configureExposedConfiguration: (ElasticSearchExposedConfiguration) -> List ): ProvidedElasticsearchSystemOptions = ProvidedElasticsearchSystemOptions( config = ElasticSearchExposedConfiguration( host = host, port = port, password = password, certificate = certificate ), clientConfigurer = clientConfigurer, jsonpMapper = jsonpMapper, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided Elasticsearch instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedElasticsearchSystemOptions( /** * The configuration for the provided Elasticsearch instance. */ val config: ElasticSearchExposedConfiguration, clientConfigurer: ElasticClientConfigurer = ElasticClientConfigurer(), jsonpMapper: JsonpMapper = JacksonJsonpMapper(StoveSerde.jackson.default), cleanup: suspend (ElasticsearchClient) -> Unit = {}, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (ElasticSearchExposedConfiguration) -> List ) : ElasticsearchSystemOptions( clientConfigurer = clientConfigurer, container = ElasticContainerOptions(), jsonpMapper = jsonpMapper, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: ElasticSearchExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } /** * Convenience type alias for Elasticsearch migrations. * * Instead of writing `DatabaseMigration`, use `ElasticsearchMigration`: * ```kotlin * class MyMigration : ElasticsearchMigration { * override val order: Int = 1 * override suspend fun execute(connection: ElasticsearchClient) { ... } * } * ``` */ typealias ElasticsearchMigration = DatabaseMigration data class ElasticSearchExposedConfiguration( val host: String, val port: Int, val password: String, val certificate: ElasticsearchExposedCertificate? ) : ExposedConfiguration @StoveDsl data class ElasticsearchContext( val runtime: SystemRuntime, val options: ElasticsearchSystemOptions, val keyName: String? = null ) open class StoveElasticSearchContainer( override val imageNameAccess: DockerImageName ) : ElasticsearchContainer(imageNameAccess), StoveContainer data class ElasticContainerOptions( override val registry: String = "docker.elastic.co/", override val tag: String = "8.6.1", override val image: String = "elasticsearch/elasticsearch", override val compatibleSubstitute: String? = null, val exposedPorts: List = listOf(DEFAULT_ELASTIC_PORT), val password: String = "password", val disableSecurity: Boolean = true, override val useContainerFn: UseContainerFn = { StoveElasticSearchContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions { companion object { const val DEFAULT_ELASTIC_PORT = 9200 } } data class ElasticClientConfigurer( val httpClientBuilder: HttpAsyncClientBuilder.() -> Unit = { setDefaultRequestConfig( RequestConfig .custom() .setSocketTimeout(5.minutes.inWholeMilliseconds.toInt()) .setConnectTimeout(5.minutes.inWholeMilliseconds.toInt()) .setConnectionRequestTimeout(5.minutes.inWholeMilliseconds.toInt()) .build() ) }, val restClientOverrideFn: Option<(cfg: ElasticSearchExposedConfiguration) -> RestClient> = none() ) ================================================ FILE: lib/stove-elasticsearch/src/main/kotlin/com/trendyol/stove/elasticsearch/util.kt ================================================ package com.trendyol.stove.elasticsearch import co.elastic.clients.elasticsearch._types.query_dsl.QueryVariant internal fun QueryVariant.asJsonString(): String = this._toQuery().toString().removePrefix("Query:") ================================================ FILE: lib/stove-elasticsearch/src/test/kotlin/com/trendyol/stove/elasticsearch/ElasticsearchExposedCertificateTest.kt ================================================ package com.trendyol.stove.elasticsearch import com.fasterxml.jackson.module.kotlin.readValue import com.trendyol.stove.functional.get import com.trendyol.stove.serialization.* import com.trendyol.stove.system.abstractions.StateWithProcess import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.ints.shouldBeGreaterThan class ElasticsearchExposedCertificateTest : FunSpec({ test("equals should return true for same byte content") { val bytes1 = "test-cert-bytes".toByteArray() val bytes2 = "test-cert-bytes".toByteArray() val cert1 = ElasticsearchExposedCertificate(bytes1) val cert2 = ElasticsearchExposedCertificate(bytes2) (cert1 == cert2) shouldBe true } test("equals should return false for different byte content") { val cert1 = ElasticsearchExposedCertificate("cert-a".toByteArray()) val cert2 = ElasticsearchExposedCertificate("cert-b".toByteArray()) (cert1 == cert2) shouldBe false } test("equals should return true for same instance") { val cert = ElasticsearchExposedCertificate("test".toByteArray()) (cert == cert) shouldBe true } test("equals should return false for different type") { val cert = ElasticsearchExposedCertificate("test".toByteArray()) cert.equals("not a certificate") shouldBe false } test("hashCode should be consistent for same content") { val bytes1 = "test-cert".toByteArray() val bytes2 = "test-cert".toByteArray() val cert1 = ElasticsearchExposedCertificate(bytes1) val cert2 = ElasticsearchExposedCertificate(bytes2) cert1.hashCode() shouldBe cert2.hashCode() } test("hashCode should differ for different content") { val cert1 = ElasticsearchExposedCertificate("cert-a".toByteArray()) val cert2 = ElasticsearchExposedCertificate("cert-b".toByteArray()) cert1.hashCode() shouldNotBe cert2.hashCode() } test("ser/de") { val state = """ { "state": { "host": "localhost", "port": 50543, "password": "password", "certificate": { "bytes": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZXakNDQTBLZ0F3SUJBZ0lWQU4wclloSXpaMS90Rmg5NmR3WGI1b1lWWnl4UU1BMEdDU3FHU0liM0RRRUIKQ3dVQU1Ed3hPakE0QmdOVkJBTVRNVVZzWVhOMGFXTnpaV0Z5WTJnZ2MyVmpkWEpwZEhrZ1lYVjBieTFqYjI1bQphV2QxY21GMGFXOXVJRWhVVkZBZ1EwRXdIaGNOTWpNd09ERTFNVGd5TWpJNFdoY05Nall3T0RFME1UZ3lNakk0CldqQThNVG93T0FZRFZRUURFekZGYkdGemRHbGpjMlZoY21Ob0lITmxZM1Z5YVhSNUlHRjFkRzh0WTI5dVptbG4KZFhKaGRHbHZiaUJJVkZSUUlFTkJNSUlDSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQWc4QU1JSUNDZ0tDQWdFQQppcDVGaWwySUMyRGhYbHkyV3RYTFliTnJUbHNkQklZQ3JvK0N1QU40djJHM3RuMkNQTmVoMnM2V0ovRUNrRitVCndJUVVKWEN0Mm43aEJDWkY4M1BlQ1JabWZyWkE0VXNtdzBYWS9OWWpTcnJRQXVtODYvamFZM0lMVGYzU1Jnei8KaWNFVEJCRVM1eTdmSFZlU0xmTjl1ME9hSC9tTnN5Q3FoMERMRFZrWXR5MHNJZXorb1paNmtxN2UrRE1OeHB5Sgp1cjRvUGJ5ekdmY1dnZDdnMll5T2RxNEd1TmFOck8ySjFVZG5BV3B5TVdnME5TSzd1TGlEZ0w5a25ZZlBnMmhQCisrWVpnVkJNNXJBMXJhY2x3c0U0NWJNVlErKzRyWEhhbDUwdS83VmN6a3M5QTREVDc3ZHVyOC9aM1hONWtpMm0KaWNTemJDTHlLdTZwdUhLQWhCdFMyWnlMMXBYN09RSVA2aWZLaVpaU1ZYUGZnMGk5cjVQL3ZFZTJoVXpTTDRVLwpsSWQvaWJUQWtIcmZGbEErN2FreFNzcFJoalMra1ZTQndyR05KQ3BDbWsraitxSnB5Sis5aTNWb0pkanVvemprClk2bS9EZG9kc21LdUZFYUdZNytBT1RVMjAwN0ZjZWdXUEJWejgzU051WmpwbURCczMzS25oeG5WM0RBb0QzUm4KbkNoV2ZQTGo4TUl1OG9tMll3RUpZMUtLR1hzUzU5TWtyclpuQnNjdzl0S0prMFQyRHlLM2dWckY5UnJpMk1mTwpKY3FCWUhBSHRQTHRQZU5STHJLUmxtYkh4NXJFMkNCWEJWQWJ1bU1EaGRIbE1lTWtwT1p3WnoyQWljWUV1anhlClU0TUl5LzczU1RHakhtcGpVT3dKcjNMdVdqVlBMNDlZeTZZWmNPbENsTThDQXdFQUFhTlRNRkV3SFFZRFZSME8KQkJZRUZNUTlobHVXN3VzdGQwZkZDNU5zSkNyTDhaTStNQjhHQTFVZEl3UVlNQmFBRk1ROWhsdVc3dXN0ZDBmRgpDNU5zSkNyTDhaTStNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEUVlKS29aSWh2Y05BUUVMQlFBRGdnSUJBSEdOCjQ1Nm1iYXdKUHNLTVgvQlowanB2LytTbCtMTTB6U2gxeEF1YXlmbDk3WnBlbS80QkhGcU5vTUxGOEVqczhXcHoKUHU1Y3Y5VVFaZXNaWVVsNHE4ODY5TW03QnQ5UHVRcUJBR25VbTU3alhMRkRsdDRvVTFvZmVpalF1YkZ3M0wwMwpGa2NsQ3psZ2JhV21vb2ZKRTdKK2FEMGo5bHNOWllKem9tQlN6QnZGTC9uK0ptS0poQVk4SDNwTkNqdExtbXZjClZPbmluQWFScGxLQndSS1RRYm1ZVE53QXVTcEhvSUk4empqK3pGWm54MzVqSitJY0YwblQ1Q3FQT0tCcllxTmwKN21kTnU0OGs0eUpiY0JtYXNoa3BRdkQra2Q1RFJBWmZXZ2tjZzVZUk1RUnE3RnVpWkhxcmFVdWV2WmZ3dnB2UApqMmV5M0QwMG5aSUVIN3I0alVpVnl0SGNGejVQU29zRmIwZDlmWkRJYmJGanRQblpSTEVxbS8wd3N1V25VSVdRCnlSWTNvclNiMUZIYjdYQUlUdHlnZlZQZnlUV0lnemdtbjFCR3Z2eE5sYjIyVnB4TXcvaEpLWTU0WDRjc2s1RzkKbHZMNUVzT3BvYnZvWVJRNU9taHlJT1ZGSHUwcjRKZWkzcGJ3dTczWmlnLzNFanJLY0lRS0ttYzdhQUFkbGREeQpid0dRWDdvYzRLS1lra2JPNFNNQTRTZzUxQjJFZFEzVGYrSHJlUjFTcHN1TlB1U2p0aGY5MGY2eWYrU1d0NU04CnY2RmpVRy9sR0NGTndJTTd2N1o3SHhMVnIvbVg4MTRKVzBGREdLUmhHRHd3SDUzcTJYSmRaaEl5RlNaeWtuc1UKdmJLeW51Vm43czZrU1pYbnh2NnYyTTNsL09ZMjdpNHdUVnB6bzhXbgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" } }, "processId": 10496 } """.trimIndent() val j = StoveSerde.jackson.default val stateWithProcess = j.readValue>(state) val serialize = j.writeValueAsString(stateWithProcess) val stateWithProcess2 = j.readValue>(serialize) val cert = stateWithProcess2.state.certificate!! cert.bytes.size shouldBeGreaterThan 0 cert.sslContext shouldNotBe null cert.sslContext.protocol shouldBe "TLSv1.3" } }) ================================================ FILE: lib/stove-elasticsearch/src/test/kotlin/com/trendyol/stove/elasticsearch/ElasticsearchOptionsTest.kt ================================================ package com.trendyol.stove.elasticsearch import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class ElasticsearchOptionsTest : FunSpec({ test("ElasticSearchExposedConfiguration should hold connection details") { val cfg = ElasticSearchExposedConfiguration( host = "localhost", port = 9200, password = "secret", certificate = null ) cfg.host shouldBe "localhost" cfg.port shouldBe 9200 cfg.password shouldBe "secret" cfg.certificate shouldBe null } test("ElasticsearchSystemOptions.provided should create ProvidedElasticsearchSystemOptions") { val options = ElasticsearchSystemOptions.provided( host = "es-host", port = 9200, password = "pass", configureExposedConfiguration = { cfg -> listOf("es.host=${cfg.host}", "es.port=${cfg.port}") } ) options.providedConfig.host shouldBe "es-host" options.providedConfig.port shouldBe 9200 options.providedConfig.password shouldBe "pass" options.providedConfig.certificate shouldBe null options.runMigrationsForProvided shouldBe true } test("ProvidedElasticsearchSystemOptions should expose correct properties") { val config = ElasticSearchExposedConfiguration( host = "remote-es", port = 9201, password = "p", certificate = null ) val options = ProvidedElasticsearchSystemOptions( config = config, runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("ElasticClientConfigurer should have default configuration") { val configurer = ElasticClientConfigurer() configurer.httpClientBuilder shouldNotBe null configurer.restClientOverrideFn.isNone() shouldBe true } test("ElasticContainerOptions should have sensible defaults") { val opts = ElasticContainerOptions() opts.password shouldBe "password" opts.disableSecurity shouldBe true opts.exposedPorts shouldBe listOf(ElasticContainerOptions.DEFAULT_ELASTIC_PORT) } }) ================================================ FILE: lib/stove-elasticsearch/src/test/kotlin/com/trendyol/stove/elasticsearch/ElasticsearchTestSystemTests.kt ================================================ package com.trendyol.stove.elasticsearch import arrow.core.Some import co.elastic.clients.elasticsearch.ElasticsearchClient import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders import co.elastic.clients.elasticsearch.indices.CreateIndexRequest import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.trendyol.stove.database.migrations.* import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.apache.http.HttpHost import org.elasticsearch.client.RestClient import org.junit.jupiter.api.assertThrows import org.slf4j.* import org.testcontainers.elasticsearch.ElasticsearchContainer import org.testcontainers.utility.DockerImageName import java.util.* // ============================================================================ // Shared components // ============================================================================ const val TEST_INDEX = "stove-test-index" const val ANOTHER_INDEX = "stove-another-index" const val DEFAULT_ELASTICSEARCH_TEST_TAG = "8.9.0" object ElasticsearchTestRuntimeConfig { val tag: String = System.getenv("ELASTICSEARCH_TEST_TAG") ?: System.getProperty("elasticsearchTestTag") ?: DEFAULT_ELASTICSEARCH_TEST_TAG val imageName: DockerImageName = DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:$tag") } class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } class TestIndexMigrator : ElasticsearchMigration { override val order: Int = MigrationPriority.HIGHEST.value private val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun execute(connection: ElasticsearchClient) { val createIndexRequest: CreateIndexRequest = CreateIndexRequest .Builder() .index(TEST_INDEX) .build() connection.indices().create(createIndexRequest) logger.info("$TEST_INDEX is created") } } class AnotherIndexMigrator : ElasticsearchMigration { override val order: Int = MigrationPriority.HIGHEST.value + 1 private val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun execute(connection: ElasticsearchClient) { val createIndexRequest: CreateIndexRequest = CreateIndexRequest .Builder() .index(ANOTHER_INDEX) .build() connection.indices().create(createIndexRequest) logger.info("$ANOTHER_INDEX is created") } } // ============================================================================ // Strategy interface // ============================================================================ sealed interface ElasticsearchTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): ElasticsearchTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedElasticsearchStrategy() else ContainerElasticsearchStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerElasticsearchStrategy : ElasticsearchTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting Elasticsearch tests with container mode. tag=${ElasticsearchTestRuntimeConfig.tag}") val options = ElasticsearchSystemOptions( clientConfigurer = ElasticClientConfigurer( restClientOverrideFn = Some { cfg -> RestClient.builder(HttpHost(cfg.host, cfg.port)).build() } ), ElasticContainerOptions(tag = ElasticsearchTestRuntimeConfig.tag), configureExposedConfiguration = { _ -> listOf() } ).migrations { register() register() } Stove() .with { elasticsearch { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("Elasticsearch container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedElasticsearchStrategy : ElasticsearchTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: ElasticsearchContainer override suspend fun start() { logger.info("Starting Elasticsearch tests with provided mode. tag=${ElasticsearchTestRuntimeConfig.tag}") // Start an external container to simulate a provided instance externalContainer = ElasticsearchContainer(ElasticsearchTestRuntimeConfig.imageName) .withEnv("xpack.security.enabled", "false") .withEnv("discovery.type", "single-node") .apply { start() } logger.info("External Elasticsearch container started at ${externalContainer.httpHostAddress}") val hostPort = externalContainer.httpHostAddress.split(":") val options = ElasticsearchSystemOptions .provided( host = hostPort[0], port = hostPort[1].toInt(), runMigrations = true, clientConfigurer = ElasticClientConfigurer( restClientOverrideFn = Some { cfg -> RestClient.builder(HttpHost(cfg.host, cfg.port)).build() } ), cleanup = { client -> logger.info("Running cleanup on provided instance") // Clean up test data if needed }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() register() } Stove() .with { elasticsearch { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("Elasticsearch provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = ElasticsearchTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } // ============================================================================ // Tests // ============================================================================ class ElasticsearchTestSystemTests : FunSpec({ @JsonIgnoreProperties data class ExampleInstance( val id: String, val description: String ) test("should save and get") { val exampleInstance = ExampleInstance("1", "1312") stove { elasticsearch { save(exampleInstance.id, exampleInstance, TEST_INDEX) shouldGet(key = exampleInstance.id, index = TEST_INDEX) { it.description shouldBe exampleInstance.description } } } } test("should save and get from another index") { val exampleInstance = ExampleInstance("1", "1312") stove { elasticsearch { save(exampleInstance.id, exampleInstance, ANOTHER_INDEX) shouldGet(ANOTHER_INDEX, exampleInstance.id) { it.description shouldBe exampleInstance.description } } } } test("should save 2 documents with the same description, then delete first one and query by description") { val desc = "some description" val exampleInstance1 = ExampleInstance("1", desc) val exampleInstance2 = ExampleInstance("2", desc) val queryByDesc = QueryBuilders .term() .field("description.keyword") .value(desc) .queryName("query_name") .build() val queryAsString = queryByDesc.asJsonString() stove { elasticsearch { save(exampleInstance1.id, exampleInstance1, TEST_INDEX) save(exampleInstance2.id, exampleInstance2, TEST_INDEX) shouldQuery(queryByDesc._toQuery()) { it.size shouldBe 2 } shouldDelete(exampleInstance1.id, TEST_INDEX) shouldGet(key = exampleInstance2.id, index = TEST_INDEX) {} shouldQuery(queryAsString, TEST_INDEX) { it.size shouldBe 1 } } } } test("should throw assertion error when document does exist") { val existDocId = UUID.randomUUID().toString() val exampleInstance = ExampleInstance(existDocId, "1312") stove { elasticsearch { save(exampleInstance.id, exampleInstance, TEST_INDEX) shouldGet(key = exampleInstance.id, index = TEST_INDEX) { it.description shouldBe exampleInstance.description } assertThrows { shouldNotExist(existDocId, index = TEST_INDEX) } } } } test("should does not throw exception when given does not exist id") { val notExistDocId = UUID.randomUUID().toString() stove { elasticsearch { shouldNotExist(notExistDocId, index = TEST_INDEX) } } } }) ================================================ FILE: lib/stove-elasticsearch/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.elasticsearch.StoveConfig ================================================ FILE: lib/stove-grpc/api/stove-grpc.api ================================================ public abstract interface annotation class com/trendyol/stove/grpc/GrpcDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/grpc/GrpcSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/PluggedSystem { public static final field Companion Lcom/trendyol/stove/grpc/GrpcSystem$Companion; public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/grpc/GrpcSystemOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/grpc/GrpcSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun channelWithMetadata (Ljava/util/Map;)Lio/grpc/Channel; public fun close ()V public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getGrpcChannel ()Lio/grpc/ManagedChannel; public final fun getOptions ()Lcom/trendyol/stove/grpc/GrpcSystemOptions; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun getWireClientResources ()Lcom/trendyol/stove/grpc/WireClientResources; public final fun grpcClient ()Lcom/squareup/wire/GrpcClient; public final fun managedChannel ()Lio/grpc/ManagedChannel; public final fun rawChannel (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/grpc/GrpcSystem; public final fun rawWireClient (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/grpc/GrpcSystem; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun withEndpoint (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/grpc/GrpcSystem; } public final class com/trendyol/stove/grpc/GrpcSystem$Companion { } public final class com/trendyol/stove/grpc/GrpcSystemKt { public static final fun grpc-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun grpc-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun grpc-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun grpc-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/grpc/GrpcSystemOptions : com/trendyol/stove/system/abstractions/SystemOptions { public synthetic fun (Ljava/lang/String;IZJLjava/util/List;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;IZJLjava/util/List;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I public final fun component3 ()Z public final fun component4-UwyO8pc ()J public final fun component5 ()Ljava/util/List; public final fun component6 ()Ljava/util/Map; public final fun component7 ()Lkotlin/jvm/functions/Function2; public final fun component8 ()Lkotlin/jvm/functions/Function2; public final fun copy-mGOUYlo (Ljava/lang/String;IZJLjava/util/List;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/grpc/GrpcSystemOptions; public static synthetic fun copy-mGOUYlo$default (Lcom/trendyol/stove/grpc/GrpcSystemOptions;Ljava/lang/String;IZJLjava/util/List;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/grpc/GrpcSystemOptions; public fun equals (Ljava/lang/Object;)Z public final fun getCreateChannel ()Lkotlin/jvm/functions/Function2; public final fun getCreateWireClient ()Lkotlin/jvm/functions/Function2; public final fun getHost ()Ljava/lang/String; public final fun getInterceptors ()Ljava/util/List; public final fun getMetadata ()Ljava/util/Map; public final fun getPort ()I public final fun getTimeout-UwyO8pc ()J public final fun getUsePlaintext ()Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/grpc/MetadataInterceptor : io/grpc/ClientInterceptor { public fun (Ljava/util/Map;)V public fun interceptCall (Lio/grpc/MethodDescriptor;Lio/grpc/CallOptions;Lio/grpc/Channel;)Lio/grpc/ClientCall; } public final class com/trendyol/stove/grpc/WireClientResources { public fun (Lcom/squareup/wire/GrpcClient;Lokhttp3/OkHttpClient;)V public final fun close ()V public final fun component1 ()Lcom/squareup/wire/GrpcClient; public final fun component2 ()Lokhttp3/OkHttpClient; public final fun copy (Lcom/squareup/wire/GrpcClient;Lokhttp3/OkHttpClient;)Lcom/trendyol/stove/grpc/WireClientResources; public static synthetic fun copy$default (Lcom/trendyol/stove/grpc/WireClientResources;Lcom/squareup/wire/GrpcClient;Lokhttp3/OkHttpClient;ILjava/lang/Object;)Lcom/trendyol/stove/grpc/WireClientResources; public fun equals (Ljava/lang/Object;)Z public final fun getGrpcClient ()Lcom/squareup/wire/GrpcClient; public final fun getOkHttpClient ()Lokhttp3/OkHttpClient; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/grpc/test/GrpcTestServiceClient : com/trendyol/stove/grpc/test/TestServiceClient { public fun (Lcom/squareup/wire/GrpcClient;)V public fun AuthenticatedCall ()Lcom/squareup/wire/GrpcCall; public fun BidiStream ()Lcom/squareup/wire/GrpcStreamingCall; public fun ClientStream ()Lcom/squareup/wire/GrpcStreamingCall; public fun ServerStream ()Lcom/squareup/wire/GrpcStreamingCall; public fun Unary ()Lcom/squareup/wire/GrpcCall; } public final class com/trendyol/stove/grpc/test/TestRequest : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/grpc/test/TestRequest$Companion; public fun ()V public fun (Ljava/lang/String;ILokio/ByteString;)V public synthetic fun (Ljava/lang/String;ILokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;ILokio/ByteString;)Lcom/trendyol/stove/grpc/test/TestRequest; public static synthetic fun copy$default (Lcom/trendyol/stove/grpc/test/TestRequest;Ljava/lang/String;ILokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/grpc/test/TestRequest; public fun equals (Ljava/lang/Object;)Z public final fun getCount ()I public final fun getMessage ()Ljava/lang/String; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/grpc/test/TestRequest$Companion { } public final class com/trendyol/stove/grpc/test/TestResponse : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/grpc/test/TestResponse$Companion; public fun ()V public fun (Ljava/lang/String;IZLokio/ByteString;)V public synthetic fun (Ljava/lang/String;IZLokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;IZLokio/ByteString;)Lcom/trendyol/stove/grpc/test/TestResponse; public static synthetic fun copy$default (Lcom/trendyol/stove/grpc/test/TestResponse;Ljava/lang/String;IZLokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/grpc/test/TestResponse; public fun equals (Ljava/lang/Object;)Z public final fun getCount ()I public final fun getMessage ()Ljava/lang/String; public final fun getSuccess ()Z public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/grpc/test/TestResponse$Companion { } public abstract interface class com/trendyol/stove/grpc/test/TestServiceClient : com/squareup/wire/Service { public abstract fun AuthenticatedCall ()Lcom/squareup/wire/GrpcCall; public abstract fun BidiStream ()Lcom/squareup/wire/GrpcStreamingCall; public abstract fun ClientStream ()Lcom/squareup/wire/GrpcStreamingCall; public abstract fun ServerStream ()Lcom/squareup/wire/GrpcStreamingCall; public abstract fun Unary ()Lcom/squareup/wire/GrpcCall; } public abstract interface class com/trendyol/stove/grpc/test/TestServiceServer : com/squareup/wire/Service { public abstract fun AuthenticatedCall (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun BidiStream (Lkotlinx/coroutines/channels/ReceiveChannel;Lkotlinx/coroutines/channels/SendChannel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun ClientStream (Lkotlinx/coroutines/channels/ReceiveChannel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun ServerStream (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlinx/coroutines/channels/SendChannel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun Unary (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/grpc/test/TestServiceWireGrpc { public static final field INSTANCE Lcom/trendyol/stove/grpc/test/TestServiceWireGrpc; public static final field SERVICE_NAME Ljava/lang/String; public final fun getAuthenticatedCallMethod ()Lio/grpc/MethodDescriptor; public final fun getBidiStreamMethod ()Lio/grpc/MethodDescriptor; public final fun getClientStreamMethod ()Lio/grpc/MethodDescriptor; public final fun getServerStreamMethod ()Lio/grpc/MethodDescriptor; public final fun getServiceDescriptor ()Lio/grpc/ServiceDescriptor; public final fun getUnaryMethod ()Lio/grpc/MethodDescriptor; public final fun newStub (Lio/grpc/Channel;)Lcom/trendyol/stove/grpc/test/TestServiceWireGrpc$TestServiceStub; } public final class com/trendyol/stove/grpc/test/TestServiceWireGrpc$BindableAdapter : com/trendyol/stove/grpc/test/TestServiceWireGrpc$TestServiceImplBase { public fun (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;)V public synthetic fun (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lkotlin/jvm/functions/Function0;)V public fun AuthenticatedCall (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun BidiStream (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; public fun ClientStream (Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun ServerStream (Lcom/trendyol/stove/grpc/test/TestRequest;)Lkotlinx/coroutines/flow/Flow; public fun Unary (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract class com/trendyol/stove/grpc/test/TestServiceWireGrpc$TestServiceImplBase : com/squareup/wire/kotlin/grpcserver/WireBindableService { public fun ()V public fun (Lkotlin/coroutines/CoroutineContext;)V public synthetic fun (Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun AuthenticatedCall (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun BidiStream (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; public fun ClientStream (Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun ServerStream (Lcom/trendyol/stove/grpc/test/TestRequest;)Lkotlinx/coroutines/flow/Flow; public fun Unary (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun bindService ()Lio/grpc/ServerServiceDefinition; protected final fun getContext ()Lkotlin/coroutines/CoroutineContext; } public final class com/trendyol/stove/grpc/test/TestServiceWireGrpc$TestServiceImplBase$TestRequestMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/grpc/test/TestRequest; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/grpc/test/TestRequest;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/grpc/test/TestServiceWireGrpc$TestServiceImplBase$TestResponseMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/grpc/test/TestResponse; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/grpc/test/TestResponse;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/grpc/test/TestServiceWireGrpc$TestServiceStub : io/grpc/kotlin/AbstractCoroutineStub { public final fun AuthenticatedCall (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun BidiStream (Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun ClientStream (Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun ServerStream (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun Unary (Lcom/trendyol/stove/grpc/test/TestRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public synthetic fun build (Lio/grpc/Channel;Lio/grpc/CallOptions;)Lio/grpc/stub/AbstractStub; } ================================================ FILE: lib/stove-grpc/build.gradle.kts ================================================ plugins { alias(libs.plugins.wire) } dependencies { api(projects.lib.stove) api(libs.io.grpc) api(libs.io.grpc.stub) api(libs.io.grpc.kotlin) api(libs.io.grpc.netty) api(libs.io.grpc.protobuf) api(libs.wire.grpc.client) api(libs.wire.grpc.runtime) api(libs.google.protobuf.kotlin) implementation(libs.wire.grpc.server) implementation(libs.kotlin.reflect) implementation(libs.kotlinx.core) implementation(libs.kotlinx.jdk8) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) testImplementation(libs.kotest.runner.junit5) testImplementation(testFixtures(projects.lib.stove)) } buildscript { dependencies { classpath(libs.wire.grpc.server.generator) } } wire { sourcePath("src/test/proto") kotlin { rpcRole = "client" rpcCallStyle = "suspending" exclusive = false javaInterop = false } kotlin { custom { schemaHandlerFactory = com.squareup.wire.kotlin.grpcserver.GrpcServerSchemaHandler.Factory() options = mapOf( "singleMethodServices" to "false", "rpcCallStyle" to "suspending" ) } rpcRole = "server" rpcCallStyle = "suspending" exclusive = false singleMethodServices = false javaInterop = true includes = listOf("com.trendyol.stove.grpc.test.TestService") } } ================================================ FILE: lib/stove-grpc/src/main/kotlin/com/trendyol/stove/grpc/GrpcDsl.kt ================================================ package com.trendyol.stove.grpc /** * DSL marker for gRPC testing operations. * * This annotation is used to scope the DSL functions and prevent * accidental nesting of incompatible DSL blocks. */ @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class GrpcDsl ================================================ FILE: lib/stove-grpc/src/main/kotlin/com/trendyol/stove/grpc/GrpcSystem.kt ================================================ package com.trendyol.stove.grpc import arrow.core.getOrElse import com.squareup.wire.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import com.trendyol.stove.tracing.TraceContext import io.grpc.* import java.util.concurrent.TimeUnit /** * gRPC client system for testing gRPC APIs. * * Provides a fluent DSL for making gRPC requests and asserting responses. * Supports multiple gRPC providers through a provider-agnostic design. * * ## Typed Channel (Recommended) * * For any stub type with a Channel constructor (grpc-kotlin, Wire, etc.): * * ```kotlin * grpc { * channel { * val response = sayHello(HelloRequest(name = "World")) * response.message shouldBe "Hello, World!" * } * } * ``` * * ## Wire Clients * * For Wire-generated service clients: * * ```kotlin * grpc { * wireClient { * val response = SayHello().execute(HelloRequest(name = "World")) * response.message shouldBe "Hello, World!" * } * } * ``` * * ## Custom Providers * * For any other gRPC library: * * ```kotlin * grpc { * withEndpoint({ host, port -> CustomGrpcLib.connect(host, port) }) { * call(...) shouldBe expected * } * } * ``` * * ## Streaming * * All streaming types work naturally with Kotlin coroutines: * * ```kotlin * grpc { * channel { * // Server streaming * serverStream(request).collect { response -> * // assertions on each response * } * * // Client streaming * val response = clientStream(flow { emit(request1); emit(request2) }) * * // Bidirectional streaming * bidiStream(requestFlow).collect { response -> * // assertions * } * } * } * ``` * * ## Per-Call Metadata * * Add metadata (headers) to specific calls: * * ```kotlin * grpc { * channel( * metadata = mapOf("authorization" to "Bearer $token") * ) { * sayHello(request) * } * } * ``` * * @property stove The parent test system. * @property options gRPC client configuration options. * @see GrpcSystemOptions */ @GrpcDsl class GrpcSystem( override val stove: Stove, @PublishedApi internal val options: GrpcSystemOptions, private val keyName: String? = null ) : PluggedSystem, Reports { private val lazyGrpcChannel = lazy { options.createChannel(options.host, options.port) } override val reportSystemName: String = "gRPC" + (keyName?.let { " [$it]" } ?: "") private val lazyWireClientResources = lazy { options.createWireClient(options.host, options.port) } @PublishedApi internal val grpcChannel: ManagedChannel get() = lazyGrpcChannel.value @PublishedApi internal val wireClientResources: WireClientResources get() = lazyWireClientResources.value /** * Execute gRPC calls using a Wire-generated client. * * The client is automatically created from the GrpcClient, and `this` in the block * refers to the client instance. * * ```kotlin * grpc { * wireClient { * val response = SayHello().execute(HelloRequest(name = "World")) * response.message shouldBe "Hello!" * } * } * ``` * * @param T The Wire service client type. * @param block The block to execute with the client as receiver. */ suspend inline fun wireClient( crossinline block: @GrpcDsl suspend T.() -> Unit ): GrpcSystem { val serviceName = T::class.simpleName ?: "Unknown" val client = wireClientResources.grpcClient.create(T::class) report( action = "Wire client: $serviceName", metadata = mapOf("service" to serviceName) ) { block(client) } return this } /** * Execute gRPC calls using a custom client created by the provided factory. * * This allows integration with any gRPC library by providing a factory function * that takes host and port and returns the client. * * ```kotlin * grpc { * withEndpoint({ h, p -> CustomGrpcLib.connect(h, p) }) { * call(...) shouldBe expected * } * } * ``` * * @param T The custom client type. * @param factory Factory function that creates the client from host and port. * @param block The block to execute with the client as receiver. */ inline fun withEndpoint( factory: (host: String, port: Int) -> T, block: @GrpcDsl T.() -> Unit ): GrpcSystem { val client = factory(options.host, options.port) block(client) return this } /** * Execute gRPC calls using any stub type that has a Channel constructor. * * The stub is automatically created from the channel using reflection. * Works with grpc-kotlin stubs, Wire-generated stubs, and any other * stub that takes a Channel as constructor parameter. * * ```kotlin * grpc { * channel { * val response = sayHello(HelloRequest(name = "World")) * response.message shouldBe "Hello!" * } * } * ``` * * @param T The stub type with a Channel constructor. * @param metadata Optional per-call metadata to add to all requests in this block. * @param block The block to execute with the stub as receiver. */ suspend inline fun channel( metadata: Map = emptyMap(), crossinline block: @GrpcDsl suspend T.() -> Unit ): GrpcSystem { val stubName = T::class.simpleName ?: "Unknown" val stubInstance = createStubFromChannel(metadata) report( action = "Channel stub: $stubName", metadata = mapOf("stub" to stubName, "hasMetadata" to metadata.isNotEmpty()) ) { block(stubInstance) } return this } /** * Execute operations with direct access to the ManagedChannel. * * Use this for advanced scenarios where you need full control over * stub creation, custom interceptors, or channel operations. * * ```kotlin * grpc { * rawChannel { ch -> * val interceptedChannel = ClientInterceptors.intercept(ch, myInterceptor) * val stub = GreeterGrpc.newBlockingStub(interceptedChannel) * // ... use stub * } * } * ``` * * @param block The block to execute with the channel. */ inline fun rawChannel( block: @GrpcDsl (ManagedChannel) -> Unit ): GrpcSystem { block(grpcChannel) return this } /** * Execute operations with direct access to the Wire GrpcClient. * * Use this for advanced Wire scenarios where you need direct client access. * * ```kotlin * grpc { * rawWireClient { client -> * val service = client.create(MyServiceClient::class) * // ... use service * } * } * ``` * * @param block The block to execute with the Wire GrpcClient. */ inline fun rawWireClient( block: @GrpcDsl (GrpcClient) -> Unit ): GrpcSystem { block(wireClientResources.grpcClient) return this } /** * Exposes the [ManagedChannel] used by this system. */ @Suppress("unused") fun managedChannel(): ManagedChannel = grpcChannel /** * Exposes the Wire [GrpcClient] used by this system. */ @Suppress("unused") fun grpcClient(): GrpcClient = wireClientResources.grpcClient override fun then(): Stove = stove override fun close() { if (lazyGrpcChannel.isInitialized()) { grpcChannel.shutdown() grpcChannel.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) } if (lazyWireClientResources.isInitialized()) { wireClientResources.close() } } companion object { private const val SHUTDOWN_TIMEOUT_SECONDS = 5L } /** * Returns a channel with optional metadata interceptor applied. * Automatically injects trace context headers when a trace is active. */ @PublishedApi internal fun channelWithMetadata(metadata: Map): Channel { val metadataWithTrace = buildMap { putAll(metadata) TraceContext.current()?.let { ctx -> put(TraceContext.TRACEPARENT_HEADER, ctx.toTraceparent()) put(TraceContext.STOVE_TEST_ID_HEADER, ctx.testId) } } return if (metadataWithTrace.isNotEmpty()) { ClientInterceptors.intercept(grpcChannel, MetadataInterceptor(metadataWithTrace)) } else { grpcChannel } } /** * Creates a stub instance from a Channel using Java reflection. * * This method handles internal constructors (like Wire-generated stubs) by * using setAccessible(true). It looks for constructors that take: * - Just a Channel * - Channel and CallOptions */ @PublishedApi internal inline fun createStubFromChannel( metadata: Map ): T { val stubClass = T::class.java val channelToUse = channelWithMetadata(metadata) // Find a constructor that takes Channel (or Channel + CallOptions) val constructor = stubClass.declaredConstructors .filter { ctor -> val params = ctor.parameterTypes params.isNotEmpty() && Channel::class.java.isAssignableFrom(params[0]) } .minByOrNull { it.parameterCount } ?: throw IllegalArgumentException( "Cannot find suitable constructor for stub ${stubClass.simpleName}. " + "Expected a constructor with Channel parameter." ) constructor.isAccessible = true return when (constructor.parameterCount) { 1 -> constructor.newInstance(channelToUse) as T 2 -> constructor.newInstance(channelToUse, CallOptions.DEFAULT) as T else -> throw IllegalArgumentException( "Unexpected constructor signature for stub ${stubClass.simpleName}" ) } } } internal fun Stove.withGrpc(options: GrpcSystemOptions): Stove { this.getOrRegister(GrpcSystem(this, options)) return this } internal fun Stove.withGrpc(key: SystemKey, options: GrpcSystemOptions): Stove { this.getOrRegister(key, GrpcSystem(this, options, keyName = keyDisplayName(key))) return this } internal fun Stove.grpc(): GrpcSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(GrpcSystem::class) } internal fun Stove.grpc(key: SystemKey): GrpcSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(GrpcSystem::class, "No GrpcSystem registered with key '${keyDisplayName(key)}'") } /** * Registers the gRPC client system with the test system. * * ```kotlin * Stove() * .with { * grpc { * GrpcSystemOptions(host = "localhost", port = 50051) * } * } * ``` * * @param configure Configuration block returning [GrpcSystemOptions]. * @return The test system for fluent chaining. */ fun WithDsl.grpc(configure: @StoveDsl () -> GrpcSystemOptions): Stove = this.stove.withGrpc(configure()) /** * Registers a keyed gRPC client system for testing multiple gRPC services. * * ```kotlin * Stove().with { * grpc(PaymentService) { * GrpcSystemOptions(host = "localhost", port = 50051) * } * } * ``` * * @param key The [SystemKey] identifying this gRPC client instance. * @param configure Configuration block returning [GrpcSystemOptions]. * @return The test system for fluent chaining. */ fun WithDsl.grpc(key: SystemKey, configure: @StoveDsl () -> GrpcSystemOptions): Stove = this.stove.withGrpc(key, configure()) /** * Executes gRPC assertions within the validation DSL. * * ```kotlin * stove { * grpc { * channel { * sayHello(request).message shouldBe "Hello!" * } * } * } * ``` * * @param validation The gRPC assertion block. */ suspend fun ValidationDsl.grpc( validation: @GrpcDsl suspend GrpcSystem.() -> Unit ): Unit = validation(this.stove.grpc()) /** * Executes gRPC assertions against a keyed gRPC client within the validation DSL. * * ```kotlin * stove { * grpc(PaymentService) { * channel { * processPayment(request).status shouldBe "OK" * } * } * } * ``` * * @param key The [SystemKey] identifying the gRPC client instance. * @param validation The gRPC assertion block. */ suspend fun ValidationDsl.grpc( key: SystemKey, validation: @GrpcDsl suspend GrpcSystem.() -> Unit ): Unit = validation(this.stove.grpc(key)) ================================================ FILE: lib/stove-grpc/src/main/kotlin/com/trendyol/stove/grpc/GrpcSystemOptions.kt ================================================ package com.trendyol.stove.grpc import com.squareup.wire.GrpcClient import com.trendyol.stove.system.abstractions.SystemOptions import io.grpc.* import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Protocol import java.util.concurrent.TimeUnit import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration /** * Holds both the Wire GrpcClient and its underlying OkHttpClient for proper resource management. */ data class WireClientResources( val grpcClient: GrpcClient, val okHttpClient: OkHttpClient ) { fun close() { okHttpClient.dispatcher.executorService.shutdown() okHttpClient.connectionPool.evictAll() } } /** * Configuration options for the gRPC client system. * * ## Basic Configuration * * ```kotlin * Stove() * .with { * grpc { * GrpcSystemOptions( * host = "localhost", * port = 50051 * ) * } * } * ``` * * ## With Authentication * * ```kotlin * grpc { * GrpcSystemOptions( * host = "localhost", * port = 50051, * metadata = mapOf("authorization" to "Bearer $token"), * interceptors = listOf(LoggingInterceptor()) * ) * } * ``` * * ## Custom Channel * * For advanced scenarios (custom TLS, load balancing, etc.): * * ```kotlin * grpc { * GrpcSystemOptions( * host = "localhost", * port = 50051, * createChannel = { host, port -> * ManagedChannelBuilder.forAddress(host, port) * .usePlaintext() * .enableRetry() * .build() * } * ) * } * ``` * * @property host The gRPC server host. * @property port The gRPC server port. * @property usePlaintext Whether to use plaintext (no TLS). Default is true for testing. * @property timeout Request timeout duration (default: 30 seconds). * @property interceptors List of client interceptors for logging, auth, tracing, etc. * @property metadata Default metadata (headers) to send with every request. * @property createChannel Factory function for creating the underlying ManagedChannel. * @property createWireClient Factory function for creating Wire's GrpcClient with resources. */ @GrpcDsl data class GrpcSystemOptions( val host: String, val port: Int, val usePlaintext: Boolean = true, val timeout: Duration = 30.seconds, val interceptors: List = emptyList(), val metadata: Map = emptyMap(), val createChannel: (host: String, port: Int) -> ManagedChannel = { h, p -> defaultChannelBuilder(h, p, usePlaintext, timeout, interceptors, metadata) }, val createWireClient: (host: String, port: Int) -> WireClientResources = { h, p -> defaultWireGrpcClient(h, p, timeout, metadata) } ) : SystemOptions /** * Creates a default ManagedChannel with standard configuration. */ internal fun defaultChannelBuilder( host: String, port: Int, usePlaintext: Boolean, timeout: Duration, interceptors: List, metadata: Map ): ManagedChannel { val builder = ManagedChannelBuilder .forAddress(host, port) .keepAliveTime(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) .keepAliveTimeout(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) if (usePlaintext) { builder.usePlaintext() } // Add metadata interceptor if metadata is provided if (metadata.isNotEmpty()) { builder.intercept(MetadataInterceptor(metadata)) } // Add user-provided interceptors if (interceptors.isNotEmpty()) { builder.intercept(interceptors) } return builder.build() } /** * Creates a default Wire GrpcClient with standard configuration and metadata support. */ internal fun defaultWireGrpcClient( host: String, port: Int, timeout: Duration, metadata: Map ): WireClientResources { val okHttpClientBuilder = OkHttpClient .Builder() .protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE)) .callTimeout(timeout.toJavaDuration()) .readTimeout(timeout.toJavaDuration()) .writeTimeout(timeout.toJavaDuration()) .connectTimeout(timeout.toJavaDuration()) // Add metadata headers via OkHttp interceptor if (metadata.isNotEmpty()) { okHttpClientBuilder.addInterceptor( Interceptor { chain -> val requestBuilder = chain.request().newBuilder() metadata.forEach { (key, value) -> requestBuilder.addHeader(key, value) } chain.proceed(requestBuilder.build()) } ) } val okHttpClient = okHttpClientBuilder.build() val grpcClient = GrpcClient .Builder() .client(okHttpClient) .baseUrl("http://$host:$port") .build() return WireClientResources(grpcClient, okHttpClient) } /** * Interceptor that adds metadata (headers) to every gRPC call. */ @PublishedApi internal class MetadataInterceptor( private val headers: Map ) : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall = object : ForwardingClientCall.SimpleForwardingClientCall( next.newCall(method, callOptions) ) { override fun start(responseListener: Listener, metadata: Metadata) { headers.forEach { (key, value) -> metadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value) } super.start(responseListener, metadata) } } } ================================================ FILE: lib/stove-grpc/src/test/kotlin/com/trendyol/stove/grpc/GrpcAuthInterceptorTest.kt ================================================ package com.trendyol.stove.grpc import com.squareup.wire.* import com.trendyol.stove.grpc.test.* import com.trendyol.stove.system.stove import io.grpc.* import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import org.slf4j.LoggerFactory /** * Tests for authentication and interceptor functionality in GrpcSystem. */ class GrpcAuthInterceptorTest : FunSpec({ test("authenticated call should fail without authorization header (Wire client)") { // The default setup has auth headers, but we can verify the error handling // by testing a fresh gRPC client without the headers stove { grpc { // Create a wire client without auth headers withEndpoint({ host, port -> val okHttpClient = okhttp3.OkHttpClient .Builder() .protocols(listOf(okhttp3.Protocol.H2_PRIOR_KNOWLEDGE)) .build() com.squareup.wire.GrpcClient .Builder() .client(okHttpClient) .baseUrl("http://$host:$port") .build() .create(TestServiceClient::class) }) { val exception = shouldThrow { AuthenticatedCall().execute(TestRequest(message = "Hello", count = 1)) } exception.grpcStatus shouldBe GrpcStatus.UNAUTHENTICATED exception.grpcMessage shouldContain "authorization" } } } } test("wire client with auth header should succeed") { stove { grpc { withEndpoint({ host, port -> val okHttpClient = okhttp3.OkHttpClient .Builder() .protocols(listOf(okhttp3.Protocol.H2_PRIOR_KNOWLEDGE)) .addInterceptor { chain -> val request = chain .request() .newBuilder() .addHeader("authorization", "Bearer my-token") .build() chain.proceed(request) }.build() com.squareup.wire.GrpcClient .Builder() .client(okHttpClient) .baseUrl("http://$host:$port") .build() .create(TestServiceClient::class) }) { val response = AuthenticatedCall().execute(TestRequest(message = "Secure", count = 42)) response.message shouldBe "Authenticated: Secure" response.success shouldBe true } } } } }) /** * A logging interceptor for testing purposes. */ class TestLoggingInterceptor : ClientInterceptor { private val logger = LoggerFactory.getLogger(javaClass) override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall { logger.info("gRPC call: ${method.fullMethodName}") return next.newCall(method, callOptions) } } ================================================ FILE: lib/stove-grpc/src/test/kotlin/com/trendyol/stove/grpc/GrpcSystemStubTest.kt ================================================ package com.trendyol.stove.grpc import com.trendyol.stove.grpc.test.* import com.trendyol.stove.system.stove import io.grpc.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.collections.shouldContain import kotlinx.coroutines.flow.* import java.util.concurrent.CopyOnWriteArrayList /** * Tests for typed channel DSL functionality in GrpcSystem. * * These tests verify the `channel` DSL method which creates stubs * automatically from the managed channel, hiding the boilerplate. */ class GrpcSystemStubTest : FunSpec({ test("channel should execute unary call successfully") { stove { grpc { channel { val response = Unary(TestRequest(message = "Hello Stub", count = 42)) response.message shouldBe "Echo: Hello Stub" response.count shouldBe 42 response.success shouldBe true } } } } test("channel should handle server streaming") { stove { grpc { channel { val responses = ServerStream(TestRequest(message = "Stream", count = 3)).toList() responses.size shouldBe 3 responses[0].message shouldBe "Stream - Item 0" responses[1].message shouldBe "Stream - Item 1" responses[2].message shouldBe "Stream - Item 2" } } } } test("channel should handle client streaming") { stove { grpc { channel { val requestFlow = flow { emit(TestRequest(message = "First", count = 1)) emit(TestRequest(message = "Second", count = 2)) emit(TestRequest(message = "Third", count = 3)) } val response = ClientStream(requestFlow) response.message shouldBe "Received: First, Second, Third" response.count shouldBe 6 response.success shouldBe true } } } } test("channel should handle bidirectional streaming") { stove { grpc { channel { val requestFlow = flow { emit(TestRequest(message = "A", count = 1)) emit(TestRequest(message = "B", count = 2)) } val responses = BidiStream(requestFlow).toList() responses.size shouldBe 2 responses[0].message shouldBe "Echo: A" responses[1].message shouldBe "Echo: B" } } } } test("channel should support multiple sequential calls") { stove { grpc { channel { val response1 = Unary(TestRequest(message = "Call 1", count = 1)) response1.message shouldBe "Echo: Call 1" val response2 = Unary(TestRequest(message = "Call 2", count = 2)) response2.message shouldBe "Echo: Call 2" val response3 = Unary(TestRequest(message = "Call 3", count = 3)) response3.message shouldBe "Echo: Call 3" } } } } test("channel with per-call metadata should work") { stove { grpc { channel( metadata = mapOf("authorization" to "Bearer custom-token") ) { val response = AuthenticatedCall(TestRequest(message = "Authenticated", count = 1)) response.message shouldBe "Authenticated: Authenticated" response.success shouldBe true } } } } test("rawChannel should provide direct access to ManagedChannel") { stove { grpc { rawChannel { ch -> ch shouldNotBe null } } } } test("rawChannel with custom interceptor should intercept calls") { val interceptedMethods = CopyOnWriteArrayList() stove { grpc { rawChannel { ch -> // Create a logging interceptor val loggingInterceptor = object : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall { interceptedMethods.add(method.fullMethodName) return next.newCall(method, callOptions) } } val interceptedChannel = ClientInterceptors.intercept(ch, loggingInterceptor) val stub = TestServiceWireGrpc.TestServiceStub(interceptedChannel) stub.Unary(TestRequest(message = "Intercepted", count = 1)) } } } interceptedMethods shouldContain "com.trendyol.stove.grpc.test.TestService/Unary" } test("rawChannel with auth interceptor should add headers") { stove { grpc { rawChannel { ch -> // Create an auth interceptor that adds authorization header val authInterceptor = object : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall = object : ForwardingClientCall.SimpleForwardingClientCall( next.newCall(method, callOptions) ) { override fun start(responseListener: Listener, headers: Metadata) { headers.put( Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer raw-channel-token" ) super.start(responseListener, headers) } } } val interceptedChannel = ClientInterceptors.intercept(ch, authInterceptor) val stub = TestServiceWireGrpc.TestServiceStub(interceptedChannel) // This should succeed because we added the auth header val response = stub.AuthenticatedCall(TestRequest(message = "RawAuth", count = 1)) response.message shouldBe "Authenticated: RawAuth" response.success shouldBe true } } } } test("rawChannel with multiple interceptors should chain them") { val interceptorOrder = CopyOnWriteArrayList() stove { grpc { rawChannel { ch -> val firstInterceptor = object : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall { interceptorOrder.add("first") return next.newCall(method, callOptions) } } val secondInterceptor = object : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall { interceptorOrder.add("second") return next.newCall(method, callOptions) } } // Interceptors are applied in reverse order (last added runs first) val interceptedChannel = ClientInterceptors.intercept(ch, firstInterceptor, secondInterceptor) val stub = TestServiceWireGrpc.TestServiceStub(interceptedChannel) stub.Unary(TestRequest(message = "Chained", count = 1)) } } } // ClientInterceptors.intercept applies in reverse order interceptorOrder shouldBe listOf("second", "first") } test("rawChannel should allow manual stub creation with custom call options") { stove { grpc { rawChannel { ch -> val stub = TestServiceWireGrpc.TestServiceStub(ch) // Execute a call val response = stub.Unary(TestRequest(message = "Manual", count = 99)) response.message shouldBe "Echo: Manual" response.count shouldBe 99 } } } } }) ================================================ FILE: lib/stove-grpc/src/test/kotlin/com/trendyol/stove/grpc/GrpcSystemWireTest.kt ================================================ package com.trendyol.stove.grpc import com.trendyol.stove.grpc.test.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldStartWith /** * Tests for Wire client functionality in GrpcSystem. */ class GrpcSystemWireTest : FunSpec({ test("wireClient should execute unary call successfully") { stove { grpc { wireClient { val response = Unary().execute(TestRequest(message = "Hello Wire", count = 42)) response.message shouldBe "Echo: Hello Wire" response.count shouldBe 42 response.success shouldBe true } } } } test("rawWireClient should provide direct access to GrpcClient") { stove { grpc { rawWireClient { client -> val service = client.create(TestServiceClient::class) val response = service.Unary().execute(TestRequest(message = "Direct", count = 1)) response.message shouldStartWith "Echo:" } } } } test("withEndpoint should work with Wire client factory") { stove { grpc { withEndpoint({ host, port -> val okHttpClient = okhttp3.OkHttpClient .Builder() .protocols(listOf(okhttp3.Protocol.H2_PRIOR_KNOWLEDGE)) .build() com.squareup.wire.GrpcClient .Builder() .client(okHttpClient) .baseUrl("http://$host:$port") .build() .create(TestServiceClient::class) }) { val response = Unary().execute(TestRequest(message = "Custom", count = 99)) response.message shouldBe "Echo: Custom" response.count shouldBe 99 } } } } test("multiple sequential calls should work") { stove { grpc { wireClient { val response1 = Unary().execute(TestRequest(message = "First", count = 1)) response1.message shouldBe "Echo: First" val response2 = Unary().execute(TestRequest(message = "Second", count = 2)) response2.message shouldBe "Echo: Second" val response3 = Unary().execute(TestRequest(message = "Third", count = 3)) response3.message shouldBe "Echo: Third" } } } } }) ================================================ FILE: lib/stove-grpc/src/test/kotlin/com/trendyol/stove/grpc/StoveConfig.kt ================================================ package com.trendyol.stove.grpc import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.PortFinder import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension /** * Test application that wraps the [TestGrpcServer]. * This integrates the gRPC server lifecycle with Stove's test system. */ class TestGrpcApp( private val port: Int ) : ApplicationUnderTest { private lateinit var server: TestGrpcServer override suspend fun start(configurations: List): TestGrpcServer { server = TestGrpcServer(port).start() return server } override suspend fun stop() { if (::server.isInitialized) { server.close() } } } class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { grpc { GrpcSystemOptions( host = TEST_HOST, port = TEST_PORT, metadata = mapOf("authorization" to "Bearer test-token") ) } applicationUnderTest(TestGrpcApp(TEST_PORT)) }.run() } override suspend fun afterProject() { Stove.stop() } companion object { const val TEST_HOST = "localhost" val TEST_PORT = PortFinder.findAvailablePort() } } ================================================ FILE: lib/stove-grpc/src/test/kotlin/com/trendyol/stove/grpc/TestGrpcServer.kt ================================================ package com.trendyol.stove.grpc import com.trendyol.stove.grpc.test.* import io.grpc.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit /** * Test gRPC server for testing the GrpcSystem. * * Implements all RPC types: unary, server streaming, client streaming, and bidirectional streaming. */ class TestGrpcServer( private val port: Int ) : AutoCloseable { private val logger = LoggerFactory.getLogger(javaClass) private lateinit var server: Server fun start(): TestGrpcServer { server = ServerBuilder .forPort(port) .addService(TestServiceImpl()) .intercept(AuthorizationServerInterceptor()) .build() .start() logger.info("Test gRPC server started on port $port") return this } fun awaitTermination() { server.awaitTermination() } override fun close() { if (::server.isInitialized) { logger.info("Shutting down test gRPC server") server.shutdown() server.awaitTermination(5, TimeUnit.SECONDS) } } } /** * Implementation of the TestService for testing purposes. */ class TestServiceImpl : TestServiceWireGrpc.TestServiceImplBase() { override suspend fun Unary(request: TestRequest): TestResponse = TestResponse( message = "Echo: ${request.message}", count = request.count, success = true ) override fun ServerStream(request: TestRequest): Flow = flow { repeat(request.count) { i -> emit( TestResponse( message = "${request.message} - Item $i", count = i, success = true ) ) delay(10) // Small delay to simulate streaming } } override suspend fun ClientStream(request: Flow): TestResponse { var totalCount = 0 val messages = mutableListOf() request.collect { req -> totalCount += req.count messages.add(req.message) } return TestResponse( message = "Received: ${messages.joinToString(", ")}", count = totalCount, success = true ) } override fun BidiStream(request: Flow): Flow = flow { request.collect { req -> emit( TestResponse( message = "Echo: ${req.message}", count = req.count, success = true ) ) } } override suspend fun AuthenticatedCall(request: TestRequest): TestResponse { val authToken = AUTH_TOKEN_KEY.get() return if (authToken != null && authToken.startsWith("Bearer ")) { TestResponse( message = "Authenticated: ${request.message}", count = request.count, success = true ) } else { throw StatusException(Status.UNAUTHENTICATED.withDescription("Missing or invalid authorization")) } } companion object { val AUTH_TOKEN_KEY: Context.Key = Context.key("auth-token") } } /** * Server interceptor that extracts authorization header and stores in context. */ class AuthorizationServerInterceptor : ServerInterceptor { override fun interceptCall( call: ServerCall, headers: Metadata, next: ServerCallHandler ): ServerCall.Listener { val authHeader = headers.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)) val context = if (authHeader != null) { Context.current().withValue(TestServiceImpl.AUTH_TOKEN_KEY, authHeader) } else { Context.current() } return Contexts.interceptCall(context, call, headers, next) } } ================================================ FILE: lib/stove-grpc/src/test/proto/test_service.proto ================================================ syntax = "proto3"; package com.trendyol.stove.grpc.test; option java_multiple_files = true; option java_package = "com.trendyol.stove.grpc.test"; // Request message for test service message TestRequest { string message = 1; int32 count = 2; } // Response message for test service message TestResponse { string message = 1; int32 count = 2; bool success = 3; } // Test service with all RPC types service TestService { // Unary RPC - single request, single response rpc Unary(TestRequest) returns (TestResponse); // Server streaming RPC - single request, stream of responses rpc ServerStream(TestRequest) returns (stream TestResponse); // Client streaming RPC - stream of requests, single response rpc ClientStream(stream TestRequest) returns (TestResponse); // Bidirectional streaming RPC - stream of requests, stream of responses rpc BidiStream(stream TestRequest) returns (stream TestResponse); // Authenticated endpoint that checks for authorization header rpc AuthenticatedCall(TestRequest) returns (TestResponse); } ================================================ FILE: lib/stove-grpc/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.grpc.StoveConfig ================================================ FILE: lib/stove-grpc/src/test/resources/logback-test.xml ================================================ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n ================================================ FILE: lib/stove-grpc-mock/api/stove-grpc-mock.api ================================================ public final class com/trendyol/stove/testing/grpcmock/GrpcMetadataKeys { public static final field INSTANCE Lcom/trendyol/stove/testing/grpcmock/GrpcMetadataKeys; public final fun ascii (Ljava/lang/String;)Lio/grpc/Metadata$Key; public final fun binary (Ljava/lang/String;)Lio/grpc/Metadata$Key; public final fun getAUTHORIZATION ()Lio/grpc/Metadata$Key; public final fun getCONTENT_TYPE ()Lio/grpc/Metadata$Key; } public abstract interface annotation class com/trendyol/stove/testing/grpcmock/GrpcMockDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/testing/grpcmock/GrpcMockExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;I)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I public final fun copy (Ljava/lang/String;I)Lcom/trendyol/stove/testing/grpcmock/GrpcMockExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockExposedConfiguration;Ljava/lang/String;IILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/GrpcMockExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getHost ()Ljava/lang/String; public final fun getPort ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/GrpcMockSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware, com/trendyol/stove/system/abstractions/ValidatedSystem { public static final field Companion Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem$Companion; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun mockBidiStream (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockBidiStream$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockClientStream (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockClientStream$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockError (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lio/grpc/Status$Code;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockError$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lio/grpc/Status$Code;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockServerStream (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockServerStream$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Ljava/util/List;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockUnary (Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockUnary$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public fun validate (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/testing/grpcmock/GrpcMockSystem$Companion { public final fun server (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystem;)Lio/grpc/Server; } public final class com/trendyol/stove/testing/grpcmock/GrpcMockSystemOptions : com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public fun ()V public fun (IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()Z public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystemOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystemOptions;IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/GrpcMockSystemOptions; public fun equals (Ljava/lang/Object;)Z public final fun getAfterStubMatched ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public final fun getOnRequestReceived ()Lkotlin/jvm/functions/Function2; public final fun getPort ()I public final fun getRemoveStubAfterRequestMatched ()Z public final fun getServerBuilder ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/GrpcMockSystemOptionsKt { public static final fun grpcMock-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun grpcMock-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun grpcMock-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun grpcMock-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public abstract class com/trendyol/stove/testing/grpcmock/MetadataMatcher { } public final class com/trendyol/stove/testing/grpcmock/MetadataMatcher$All : com/trendyol/stove/testing/grpcmock/MetadataMatcher { public fun (Ljava/util/List;)V public fun ([Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;)V public final fun component1 ()Ljava/util/List; public final fun copy (Ljava/util/List;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$All; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$All;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$All; public fun equals (Ljava/lang/Object;)Z public final fun getMatchers ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/MetadataMatcher$Any : com/trendyol/stove/testing/grpcmock/MetadataMatcher { public static final field INSTANCE Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$Any; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/MetadataMatcher$BearerToken : com/trendyol/stove/testing/grpcmock/MetadataMatcher { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$BearerToken; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$BearerToken;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$BearerToken; public fun equals (Ljava/lang/Object;)Z public final fun getToken ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/MetadataMatcher$Custom : com/trendyol/stove/testing/grpcmock/MetadataMatcher { public fun (Lkotlin/jvm/functions/Function1;)V public final fun component1 ()Lkotlin/jvm/functions/Function1; public final fun copy (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$Custom; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$Custom;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$Custom; public fun equals (Ljava/lang/Object;)Z public final fun getMatcher ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/MetadataMatcher$HasHeader : com/trendyol/stove/testing/grpcmock/MetadataMatcher { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$HasHeader; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$HasHeader;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$HasHeader; public fun equals (Ljava/lang/Object;)Z public final fun getKey ()Ljava/lang/String; public final fun getValue ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/MetadataMatcher$RequiresAuth : com/trendyol/stove/testing/grpcmock/MetadataMatcher { public static final field INSTANCE Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher$RequiresAuth; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/ReceivedRequest { public fun (Lcom/trendyol/stove/testing/grpcmock/StubKey;[BLio/grpc/Metadata;JZLjava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/testing/grpcmock/StubKey;[BLio/grpc/Metadata;JZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/testing/grpcmock/StubKey; public final fun component2 ()[B public final fun component3 ()Lio/grpc/Metadata; public final fun component4 ()J public final fun component5 ()Z public final fun component6 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/testing/grpcmock/StubKey;[BLio/grpc/Metadata;JZLjava/lang/String;)Lcom/trendyol/stove/testing/grpcmock/ReceivedRequest; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/ReceivedRequest;Lcom/trendyol/stove/testing/grpcmock/StubKey;[BLio/grpc/Metadata;JZLjava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/ReceivedRequest; public fun equals (Ljava/lang/Object;)Z public final fun getAuthorizationHeader ()Ljava/lang/String; public final fun getBearerToken ()Ljava/lang/String; public final fun getMatched ()Z public final fun getMetadata ()Lio/grpc/Metadata; public final fun getRequestBytes ()[B public final fun getStubId ()Ljava/lang/String; public final fun getStubKey ()Lcom/trendyol/stove/testing/grpcmock/StubKey; public final fun getTimestamp ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract class com/trendyol/stove/testing/grpcmock/RequestMatcher { } public final class com/trendyol/stove/testing/grpcmock/RequestMatcher$Any : com/trendyol/stove/testing/grpcmock/RequestMatcher { public static final field INSTANCE Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$Any; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/RequestMatcher$Custom : com/trendyol/stove/testing/grpcmock/RequestMatcher { public fun (Lkotlin/jvm/functions/Function1;)V public final fun component1 ()Lkotlin/jvm/functions/Function1; public final fun copy (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$Custom; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$Custom;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$Custom; public fun equals (Ljava/lang/Object;)Z public final fun getMatcher ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/RequestMatcher$ExactBytes : com/trendyol/stove/testing/grpcmock/RequestMatcher { public fun ([B)V public final fun component1 ()[B public final fun copy ([B)Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$ExactBytes; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$ExactBytes;[BILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$ExactBytes; public fun equals (Ljava/lang/Object;)Z public final fun getBytes ()[B public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/RequestMatcher$ExactMessage : com/trendyol/stove/testing/grpcmock/RequestMatcher { public fun (Lcom/google/protobuf/Message;)V public final fun component1 ()Lcom/google/protobuf/Message; public final fun copy (Lcom/google/protobuf/Message;)Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$ExactMessage; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$ExactMessage;Lcom/google/protobuf/Message;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/RequestMatcher$ExactMessage; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Lcom/google/protobuf/Message; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract class com/trendyol/stove/testing/grpcmock/StubDefinition { public abstract fun getMetadataMatcher ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public abstract fun getRequestMatcher ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; } public final class com/trendyol/stove/testing/grpcmock/StubDefinition$BidiStream : com/trendyol/stove/testing/grpcmock/StubDefinition { public fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun component2 ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun copy (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$BidiStream; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/StubDefinition$BidiStream;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$BidiStream; public fun equals (Ljava/lang/Object;)Z public final fun getHandler ()Lkotlin/jvm/functions/Function2; public fun getMetadataMatcher ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public fun getRequestMatcher ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/StubDefinition$ClientStream : com/trendyol/stove/testing/grpcmock/StubDefinition { public fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;)V public synthetic fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun component2 ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public final fun component3 ()Lcom/google/protobuf/Message; public final fun copy (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$ClientStream; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/StubDefinition$ClientStream;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$ClientStream; public fun equals (Ljava/lang/Object;)Z public fun getMetadataMatcher ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public fun getRequestMatcher ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun getResponse ()Lcom/google/protobuf/Message; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/StubDefinition$Error : com/trendyol/stove/testing/grpcmock/StubDefinition { public fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lio/grpc/Status;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lio/grpc/Status;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun component2 ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public final fun component3 ()Lio/grpc/Status; public final fun component4 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lio/grpc/Status;Ljava/lang/String;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$Error; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/StubDefinition$Error;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lio/grpc/Status;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$Error; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Ljava/lang/String; public fun getMetadataMatcher ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public fun getRequestMatcher ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun getStatus ()Lio/grpc/Status; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/StubDefinition$ServerStream : com/trendyol/stove/testing/grpcmock/StubDefinition { public fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Ljava/util/List;)V public synthetic fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun component2 ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public final fun component3 ()Ljava/util/List; public final fun copy (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Ljava/util/List;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$ServerStream; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/StubDefinition$ServerStream;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$ServerStream; public fun equals (Ljava/lang/Object;)Z public fun getMetadataMatcher ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public fun getRequestMatcher ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun getResponses ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/StubDefinition$Unary : com/trendyol/stove/testing/grpcmock/StubDefinition { public fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;)V public synthetic fun (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun component2 ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public final fun component3 ()Lcom/google/protobuf/Message; public final fun copy (Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$Unary; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/StubDefinition$Unary;Lcom/trendyol/stove/testing/grpcmock/RequestMatcher;Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher;Lcom/google/protobuf/Message;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/StubDefinition$Unary; public fun equals (Ljava/lang/Object;)Z public fun getMetadataMatcher ()Lcom/trendyol/stove/testing/grpcmock/MetadataMatcher; public fun getRequestMatcher ()Lcom/trendyol/stove/testing/grpcmock/RequestMatcher; public final fun getResponse ()Lcom/google/protobuf/Message; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/grpcmock/StubKey { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/testing/grpcmock/StubKey; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/grpcmock/StubKey;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/grpcmock/StubKey; public fun equals (Ljava/lang/Object;)Z public final fun getFullMethodName ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getMethodName ()Ljava/lang/String; public final fun getServiceName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } ================================================ FILE: lib/stove-grpc-mock/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.io.grpc) api(libs.io.grpc.stub) api(libs.io.grpc.protobuf) api(libs.google.protobuf.util) api(libs.caffeine) implementation(libs.kotlinx.core) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) testImplementation(libs.kotest.runner.junit5) testImplementation(testFixtures(projects.lib.stove)) testImplementation(projects.lib.stoveGrpc) testImplementation(libs.io.grpc.netty) testImplementation(libs.io.grpc.kotlin) testImplementation(libs.google.protobuf.kotlin) } plugins { alias(libs.plugins.protobuf) } protobuf { protoc { artifact = libs.protoc.get().toString() } plugins { create("grpc").apply { artifact = libs.grpc.protoc.gen.java.get().toString() } create("grpckt").apply { artifact = "${libs.grpc.protoc.gen.kotlin.get()}:jdk8@jar" } } generateProtoTasks { all().forEach { task -> task.plugins { create("grpc") create("grpckt") } task.builtins { create("kotlin") } // Generate descriptor set for tests task.generateDescriptorSet = true task.descriptorSetOptions.includeImports = true } } } tasks.named("compileTestKotlin") { dependsOn("generateTestProto") } ================================================ FILE: lib/stove-grpc-mock/src/main/kotlin/com/trendyol/stove/testing/grpcmock/GrpcMockDsl.kt ================================================ package com.trendyol.stove.testing.grpcmock @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class GrpcMockDsl ================================================ FILE: lib/stove-grpc-mock/src/main/kotlin/com/trendyol/stove/testing/grpcmock/GrpcMockSystem.kt ================================================ @file:Suppress("unused", "MagicNumber", "TooManyFunctions") package com.trendyol.stove.testing.grpcmock import arrow.core.* import com.github.benmanes.caffeine.cache.* import com.google.protobuf.Message import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import io.grpc.* import io.grpc.stub.ServerCalls import io.grpc.stub.StreamObserver import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.InputStream import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** * Native gRPC mock server for testing gRPC service integrations. * * This implementation provides full support for all gRPC RPC types: * - Unary (request-response) * - Server streaming (single request, stream of responses) * - Client streaming (stream of requests, single response) * - Bidirectional streaming (stream of requests, stream of responses) * * ## Configuration * * ```kotlin * Stove() * .with { * grpcMock { * GrpcMockSystemOptions(port = 9090) * } * grpc { * GrpcSystemOptions(host = "localhost", port = 9090) * } * } * ``` * * ## Mocking Unary Calls * * ```kotlin * stove { * grpcMock { * mockUnary( * serviceName = "greeting.GreeterService", * methodName = "SayHello", * response = HelloResponse.newBuilder().setMessage("Hello!").build() * ) * } * } * ``` * * ## Mocking Authenticated Calls * * ```kotlin * stove { * grpcMock { * // Require specific bearer token * mockUnary( * serviceName = "secure.SecureService", * methodName = "GetSecret", * metadataMatcher = MetadataMatcher.BearerToken("valid-token"), * response = SecretResponse.newBuilder().setData("secret").build() * ) * * // Or use custom header matching * mockUnary( * serviceName = "secure.SecureService", * methodName = "GetSecret", * metadataMatcher = MetadataMatcher.HasHeader("x-api-key", "my-api-key"), * response = SecretResponse.newBuilder().setData("secret").build() * ) * } * } * ``` */ @GrpcMockDsl class GrpcMockSystem internal constructor( override val stove: Stove, private val ctx: GrpcMockContext ) : PluggedSystem, ValidatedSystem, RunAware, ExposesConfiguration, Reports { private val logger = LoggerFactory.getLogger(javaClass) override val reportSystemName: String = "gRPC Mock" + (ctx.keyName?.let { " [$it]" } ?: "") private val stubs = ConcurrentHashMap>>() private val requestLog: Cache = Caffeine .newBuilder() .maximumSize(10_000) .build() private lateinit var server: Server private lateinit var exposedConfiguration: GrpcMockExposedConfiguration override fun configuration(): List = ctx.configureExposedConfiguration(exposedConfiguration) // ==================== Lifecycle ==================== override suspend fun run() { server = ctx .serverBuilder(ServerBuilder.forPort(ctx.port)) .intercept(MetadataCapturingInterceptor) .fallbackHandlerRegistry(DynamicHandlerRegistry()) .build() .also { it.start() } exposedConfiguration = GrpcMockExposedConfiguration( host = "localhost", port = server.port ) logger.info("gRPC Mock server started on port ${server.port}") } override suspend fun stop() { if (::server.isInitialized) { server.shutdown().awaitTermination(5, TimeUnit.SECONDS) logger.info("gRPC Mock server stopped") } } override fun close(): Unit = runBlocking { Try { stop() }.recover { logger.warn("Error stopping gRPC mock: ${it.message}") } } // ==================== Stub Registration ==================== /** * Mocks a unary RPC (single request → single response). * * @param serviceName The fully qualified gRPC service name (e.g., "greeting.GreeterService") * @param methodName The RPC method name (e.g., "SayHello") * @param requestMatcher Optional matcher for filtering requests. Defaults to [RequestMatcher.Any]. * @param metadataMatcher Optional matcher for filtering by gRPC metadata/headers. Defaults to [MetadataMatcher.Any]. * Use [MetadataMatcher.BearerToken] for Bearer token auth, [MetadataMatcher.HasHeader] for custom headers. * @param response The protobuf [Message] to return when this stub is matched * @return This [GrpcMockSystem] instance for chaining * * @sample * ```kotlin * grpcMock { * mockUnary( * serviceName = "greeting.GreeterService", * methodName = "SayHello", * response = HelloResponse.newBuilder().setMessage("Hello!").build() * ) * } * ``` */ suspend fun mockUnary( serviceName: String, methodName: String, requestMatcher: RequestMatcher = RequestMatcher.Any, metadataMatcher: MetadataMatcher = MetadataMatcher.Any, response: Message ): GrpcMockSystem = registerStub( serviceName, methodName, StubDefinition.Unary(requestMatcher, metadataMatcher, response), "unary" ) { response.toString().take(200).some() } /** * Mocks a server streaming RPC (single request → stream of responses). * * The mock will send all responses in sequence when a matching request is received. * * @param serviceName The fully qualified gRPC service name (e.g., "streaming.ItemService") * @param methodName The RPC method name (e.g., "ListItems") * @param requestMatcher Optional matcher for filtering requests. Defaults to [RequestMatcher.Any]. * @param metadataMatcher Optional matcher for filtering by gRPC metadata/headers. Defaults to [MetadataMatcher.Any]. * @param responses The list of protobuf [Message]s to stream back. Must not be empty. * @return This [GrpcMockSystem] instance for chaining * @throws IllegalArgumentException if [responses] is empty * * @sample * ```kotlin * grpcMock { * mockServerStream( * serviceName = "streaming.ItemService", * methodName = "ListItems", * responses = listOf( * Item.newBuilder().setId("1").build(), * Item.newBuilder().setId("2").build() * ) * ) * } * ``` */ suspend fun mockServerStream( serviceName: String, methodName: String, requestMatcher: RequestMatcher = RequestMatcher.Any, metadataMatcher: MetadataMatcher = MetadataMatcher.Any, responses: List ): GrpcMockSystem { require(responses.isNotEmpty()) { "responses must not be empty" } return registerStub( serviceName, methodName, StubDefinition.ServerStream(requestMatcher, metadataMatcher, responses), "server stream", metadata = mapOf("responseCount" to responses.size) ) } /** * Mocks a client streaming RPC (stream of requests → single response). * * The mock will collect all incoming requests and return the configured response * when the client completes the stream. * * **Note:** The [requestMatcher] is evaluated against **only the first request** in the stream, * because stub matching happens before the full stream is received. If you need to validate * all requests in a client stream, consider using [mockBidiStream] with a custom handler. * * @param serviceName The fully qualified gRPC service name (e.g., "upload.UploadService") * @param methodName The RPC method name (e.g., "UploadChunks") * @param requestMatcher Optional matcher for the first request in the stream. Defaults to [RequestMatcher.Any]. * @param metadataMatcher Optional matcher for filtering by gRPC metadata/headers. Defaults to [MetadataMatcher.Any]. * @param response The protobuf [Message] to return after the client completes streaming * @return This [GrpcMockSystem] instance for chaining * * @sample * ```kotlin * grpcMock { * mockClientStream( * serviceName = "upload.UploadService", * methodName = "UploadChunks", * response = UploadResponse.newBuilder().setSuccess(true).build() * ) * } * ``` */ suspend fun mockClientStream( serviceName: String, methodName: String, requestMatcher: RequestMatcher = RequestMatcher.Any, metadataMatcher: MetadataMatcher = MetadataMatcher.Any, response: Message ): GrpcMockSystem = registerStub( serviceName, methodName, StubDefinition.ClientStream(requestMatcher, metadataMatcher, response), "client stream" ) { response.toString().take(200).some() } /** * Mocks a bidirectional streaming RPC (stream of requests ↔ stream of responses). * * The [handler] receives a flow of raw request bytes and should return a flow of response messages. * This allows full control over the streaming behavior, including transforming requests into responses. * * @param serviceName The fully qualified gRPC service name (e.g., "chat.ChatService") * @param methodName The RPC method name (e.g., "Chat") * @param requestMatcher Optional matcher. Currently not used for bidi streams as matching happens dynamically. * @param metadataMatcher Optional matcher for filtering by gRPC metadata/headers. Defaults to [MetadataMatcher.Any]. * @param handler A suspending function that transforms the incoming request flow into a response flow. * The handler receives raw [ByteArray] request bytes which can be parsed using protobuf's `parseFrom`. * @return This [GrpcMockSystem] instance for chaining * * @sample * ```kotlin * grpcMock { * mockBidiStream( * serviceName = "chat.ChatService", * methodName = "Chat" * ) { requestFlow -> * requestFlow.map { bytes -> * val request = ChatMessage.parseFrom(bytes) * ChatMessage.newBuilder() * .setMessage("Echo: ${request.message}") * .build() * } * } * } * ``` */ suspend fun mockBidiStream( serviceName: String, methodName: String, requestMatcher: RequestMatcher = RequestMatcher.Any, metadataMatcher: MetadataMatcher = MetadataMatcher.Any, handler: suspend (Flow) -> Flow ): GrpcMockSystem = registerStub( serviceName, methodName, StubDefinition.BidiStream(requestMatcher, metadataMatcher, handler), "bidi stream" ) /** * Mocks a gRPC error response for any RPC type. * * When a matching request is received, the mock will respond with the specified gRPC error status. * * @param serviceName The fully qualified gRPC service name (e.g., "users.UserService") * @param methodName The RPC method name (e.g., "GetUser") * @param requestMatcher Optional matcher for filtering requests. Defaults to [RequestMatcher.Any]. * @param metadataMatcher Optional matcher for filtering by gRPC metadata/headers. Defaults to [MetadataMatcher.Any]. * @param status The gRPC [Status.Code] to return (e.g., NOT_FOUND, UNAUTHENTICATED, PERMISSION_DENIED) * @param message Optional error message description. Defaults to the status code name. * @return This [GrpcMockSystem] instance for chaining * * @sample * ```kotlin * grpcMock { * mockError( * serviceName = "users.UserService", * methodName = "GetUser", * status = Status.Code.NOT_FOUND, * message = "User not found" * ) * } * ``` */ suspend fun mockError( serviceName: String, methodName: String, requestMatcher: RequestMatcher = RequestMatcher.Any, metadataMatcher: MetadataMatcher = MetadataMatcher.Any, status: Status.Code, message: String = status.name ): GrpcMockSystem = registerStub( serviceName, methodName, StubDefinition.Error(requestMatcher, metadataMatcher, Status.fromCode(status), message), "error", metadata = mapOf("status" to status.name, "message" to message) ) // ==================== Validation & Reporting ==================== override suspend fun validate() { val unmatched = requestLog.asMap().values.filter { !it.matched } if (unmatched.isNotEmpty()) { val error = AssertionError( "There are ${unmatched.size} unmatched gRPC requests:\n" + unmatched.joinToString("\n") { " - ${it.stubKey.fullMethodName}" } ) reporter.record( ReportEntry.failure( system = reportSystemName, testId = reporter.currentTestId(), action = "Validate: All gRPC requests should match registered stubs", error = error.message.orEmpty(), expected = "0 unmatched requests".some(), actual = "${unmatched.size} unmatched request(s)".some() ) ) throw error } reporter.record( ReportEntry.success( system = reportSystemName, testId = reporter.currentTestId(), action = "Validate: All gRPC requests matched registered stubs" ) ) } override fun then(): Stove = stove override fun snapshot(): SystemSnapshot { val allStubs = stubs.values.flatten() val allRequests = requestLog.asMap().values return SystemSnapshot( system = reportSystemName, state = mapOf( "registeredStubs" to allStubs.map { (key, def) -> mapOf("id" to key.id, "service" to key.serviceName, "method" to key.methodName, "type" to def::class.simpleName) }, "receivedRequests" to allRequests.map { req -> mapOf( "method" to req.stubKey.fullMethodName, "matched" to req.matched, "timestamp" to req.timestamp, "hasAuth" to (req.authorizationHeader != null) ) } ), summary = """ |Registered stubs: ${allStubs.size} |Received requests: ${allRequests.size} |Matched requests: ${allRequests.count { it.matched }} |Unmatched requests: ${allRequests.count { !it.matched }} |Authenticated requests: ${allRequests.count { it.authorizationHeader != null }} """.trimMargin() ) } // ==================== Internal: Registration ==================== private suspend fun registerStub( serviceName: String, methodName: String, stub: StubDefinition, stubType: String, metadata: Map = emptyMap(), outputProvider: () -> Option = { None } ): GrpcMockSystem { val key = StubKey(serviceName, methodName) val authInfo = when (val matcher = stub.metadataMatcher) { is MetadataMatcher.BearerToken -> " (authenticated)" is MetadataMatcher.RequiresAuth -> " (requires auth)" is MetadataMatcher.HasHeader -> " (header: ${matcher.key})" else -> "" } report( action = "Register $stubType stub: $serviceName/$methodName$authInfo", output = outputProvider(), metadata = metadata ) { stubs.computeIfAbsent(key.fullMethodName) { mutableListOf() }.add(key to stub) logger.debug("Registered stub for ${key.fullMethodName} (id: ${key.id})") } return this } // ==================== Internal: Stub Lookup ==================== private fun findAndProcessStub( fullMethodName: String, requestBytes: ByteArray, metadata: Metadata ): Option> { val stubKey = fullMethodName.toStubKey() ctx.onRequestReceived(stubKey, requestBytes) return stubs[fullMethodName] ?.find { (_, stub) -> stub.requestMatcher.matches(requestBytes) && stub.metadataMatcher.matches(metadata) }?.also { (key, stub) -> logRequest(key, requestBytes, metadata, matched = true) removeStubIfNeeded(key, fullMethodName) ctx.afterStubMatched(key, stub) }.toOption() .onNone { logRequest(stubKey, requestBytes, metadata, matched = false) } } private fun removeStubIfNeeded(key: StubKey, fullMethodName: String) { if (ctx.removeStubAfterRequestMatched) { stubs[fullMethodName]?.removeIf { it.first.id == key.id } logger.debug("Removed stub ${key.id} after match") } } private fun logRequest(key: StubKey, requestBytes: ByteArray, metadata: Metadata, matched: Boolean) { requestLog.put( UUID.randomUUID().toString(), ReceivedRequest(key, requestBytes, metadata, matched = matched, stubId = if (matched) key.id else null) ) } // ==================== Internal: Handler Registry ==================== private inner class DynamicHandlerRegistry : HandlerRegistry() { override fun lookupMethod(methodName: String, authority: String?): ServerMethodDefinition<*, *>? { logger.debug("Looking up method: $methodName") return stubs[methodName]?.firstOrNull()?.let { (_, stub) -> createHandler(methodName, stub.methodType) } ?: run { logger.warn("No stub registered for method: $methodName") null } } } private fun createHandler( fullMethodName: String, methodType: MethodDescriptor.MethodType ): ServerMethodDefinition { val method = MethodDescriptor .newBuilder() .setType(methodType) .setFullMethodName(fullMethodName) .setRequestMarshaller(ByteArrayMarshaller) .setResponseMarshaller(ByteArrayMarshaller) .build() val handler = when (methodType) { MethodDescriptor.MethodType.UNARY -> unaryHandler(fullMethodName) MethodDescriptor.MethodType.SERVER_STREAMING -> serverStreamHandler(fullMethodName) MethodDescriptor.MethodType.CLIENT_STREAMING -> clientStreamHandler(fullMethodName) MethodDescriptor.MethodType.BIDI_STREAMING -> bidiStreamHandler(fullMethodName) else -> unaryHandler(fullMethodName) } return ServerMethodDefinition.create(method, handler) } // ==================== Internal: Call Handlers ==================== private fun unaryHandler(fullMethodName: String): ServerCallHandler = ServerCalls.asyncUnaryCall { request, observer -> val metadata = MetadataCapturingInterceptor.currentMetadata() findAndProcessStub(fullMethodName, request, metadata).fold( ifEmpty = { observer.sendUnimplemented(fullMethodName) }, ifSome = { (_, stub) -> stub.sendResponse(observer) } ) } private fun serverStreamHandler(fullMethodName: String): ServerCallHandler = ServerCalls.asyncServerStreamingCall { request, observer -> val metadata = MetadataCapturingInterceptor.currentMetadata() findAndProcessStub(fullMethodName, request, metadata).fold( ifEmpty = { observer.sendUnimplemented(fullMethodName) }, ifSome = { (_, stub) -> stub.sendResponse(observer) } ) } private fun clientStreamHandler(fullMethodName: String): ServerCallHandler = ServerCalls.asyncClientStreamingCall { responseObserver -> val metadata = MetadataCapturingInterceptor.currentMetadata() CollectingStreamObserver( onComplete = { requests -> val requestBytes = requests.firstOrNull() ?: ByteArray(0) findAndProcessStub(fullMethodName, requestBytes, metadata).fold( ifEmpty = { responseObserver.sendUnimplemented(fullMethodName) }, ifSome = { (_, stub) -> stub.sendResponse(responseObserver) } ) }, onStreamError = { responseObserver.onError(it) } ) } private fun bidiStreamHandler(fullMethodName: String): ServerCallHandler = ServerCalls.asyncBidiStreamingCall { responseObserver -> val metadata = MetadataCapturingInterceptor.currentMetadata() val requestChannel = Channel(Channel.UNLIMITED) CoroutineScope(Dispatchers.IO).launch { stubs[fullMethodName]?.find { (_, stub) -> stub.metadataMatcher.matches(metadata) }?.let { (_, stub) -> when (stub) { is StubDefinition.BidiStream -> runCatching { stub.handler(requestChannel.consumeAsFlow()).collect { response -> responseObserver.onNext(response.toByteArray()) } responseObserver.onCompleted() }.onFailure { e -> responseObserver.onError(Status.INTERNAL.withCause(e).asException()) } is StubDefinition.Error -> responseObserver.onError(stub.toStatusException()) else -> responseObserver.sendUnexpectedStubType("bidi stream") } } ?: responseObserver.sendUnimplemented(fullMethodName) } ChannelForwardingObserver(requestChannel, CoroutineScope(Dispatchers.IO)) } // ==================== Extension Functions ==================== private val StubDefinition.methodType: MethodDescriptor.MethodType get() = when (this) { is StubDefinition.Unary, is StubDefinition.Error -> MethodDescriptor.MethodType.UNARY is StubDefinition.ServerStream -> MethodDescriptor.MethodType.SERVER_STREAMING is StubDefinition.ClientStream -> MethodDescriptor.MethodType.CLIENT_STREAMING is StubDefinition.BidiStream -> MethodDescriptor.MethodType.BIDI_STREAMING } private fun RequestMatcher.matches(requestBytes: ByteArray): Boolean = when (this) { is RequestMatcher.Any -> true is RequestMatcher.ExactBytes -> requestBytes.contentEquals(bytes) is RequestMatcher.ExactMessage -> requestBytes.contentEquals(message.toByteArray()) is RequestMatcher.Custom -> matcher(requestBytes) } private fun MetadataMatcher.matches(metadata: Metadata): Boolean = when (this) { is MetadataMatcher.Any -> { true } is MetadataMatcher.HasHeader -> { val headerKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER) metadata.get(headerKey) == value } is MetadataMatcher.BearerToken -> { val auth = metadata.get(GrpcMetadataKeys.AUTHORIZATION) auth == "Bearer $token" } is MetadataMatcher.RequiresAuth -> { val auth = metadata.get(GrpcMetadataKeys.AUTHORIZATION) !auth.isNullOrBlank() } is MetadataMatcher.Custom -> { matcher(metadata) } is MetadataMatcher.All -> { matchers.all { it.matches(metadata) } } } private fun StubDefinition.sendResponse(observer: StreamObserver) { when (this) { is StubDefinition.Unary -> observer.sendSingleAndComplete(response.toByteArray()) is StubDefinition.ServerStream -> observer.sendAllAndComplete(responses.map { it.toByteArray() }) is StubDefinition.ClientStream -> observer.sendSingleAndComplete(response.toByteArray()) is StubDefinition.Error -> observer.onError(toStatusException()) is StubDefinition.BidiStream -> observer.sendUnexpectedStubType("non-bidi call") } } private fun StubDefinition.Error.toStatusException(): StatusException = (message?.let { status.withDescription(it) } ?: status).asException() private fun StreamObserver.sendSingleAndComplete(bytes: ByteArray) { onNext(bytes) onCompleted() } private fun StreamObserver.sendAllAndComplete(bytesList: List) { bytesList.forEach { onNext(it) } onCompleted() } private fun StreamObserver<*>.sendUnimplemented(fullMethodName: String) { onError(Status.UNIMPLEMENTED.withDescription("No matching stub for $fullMethodName").asException()) } private fun StreamObserver<*>.sendUnexpectedStubType(context: String) { onError(Status.INTERNAL.withDescription("Unexpected stub type for $context").asException()) } private fun String.toStubKey(): StubKey { val parts = split("/") require(parts.size >= 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) { "Invalid gRPC method name format: '$this'. Expected format: 'serviceName/methodName'" } return StubKey(parts[0], parts[1]) } // ==================== Helper Classes ==================== private class CollectingStreamObserver( private val onComplete: (List) -> Unit, private val onStreamError: (Throwable) -> Unit ) : StreamObserver { private val collected = mutableListOf() override fun onNext(value: ByteArray) { collected.add(value) } override fun onError(t: Throwable) { onStreamError(t) } override fun onCompleted() { onComplete(collected) } } private class ChannelForwardingObserver( private val channel: Channel, private val scope: CoroutineScope ) : StreamObserver { override fun onNext(value: ByteArray) { // Use trySend for non-blocking operation, falling back to coroutine launch // if the channel buffer is full (which shouldn't happen with UNLIMITED capacity) val result = channel.trySend(value) if (result.isFailure && !result.isClosed) { scope.launch { channel.send(value) } } } override fun onError(t: Throwable) { channel.close(t) } override fun onCompleted() { channel.close() } } companion object { fun GrpcMockSystem.server(): Server = server } } /** * Interceptor that captures request metadata and makes it available via Context. */ private object MetadataCapturingInterceptor : ServerInterceptor { private val METADATA_KEY: Context.Key = Context.key("captured-metadata") fun currentMetadata(): Metadata = METADATA_KEY.get() ?: Metadata() override fun interceptCall( call: ServerCall, headers: Metadata, next: ServerCallHandler ): ServerCall.Listener { val context = Context.current().withValue(METADATA_KEY, headers) return Contexts.interceptCall(context, call, headers, next) } } private object ByteArrayMarshaller : MethodDescriptor.Marshaller { override fun stream(value: ByteArray): InputStream = ByteArrayInputStream(value) override fun parse(stream: InputStream): ByteArray = stream.readBytes() } ================================================ FILE: lib/stove-grpc-mock/src/main/kotlin/com/trendyol/stove/testing/grpcmock/GrpcMockSystemOptions.kt ================================================ package com.trendyol.stove.testing.grpcmock import arrow.core.getOrElse import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.grpc.ServerBuilder /** * Callback invoked after a stub is matched and used. */ typealias AfterStubMatched = (StubKey, StubDefinition) -> Unit /** * Callback invoked for each request received. */ typealias OnRequestReceived = (StubKey, ByteArray) -> Unit /** * Configuration exposed by gRPC Mock after it starts. * * This allows the application under test to receive the actual gRPC mock URL, * which is especially useful when using dynamic ports (port = 0). * * @property host The host where gRPC mock is running. * @property port The actual port gRPC mock is listening on. */ data class GrpcMockExposedConfiguration( val host: String, val port: Int ) : ExposedConfiguration /** * Configuration options for the native gRPC mock server. * * @property port The port to run the mock server on. * Defaults to 0, which lets the system pick an available port automatically. * This avoids port conflicts, especially in CI environments. * @property removeStubAfterRequestMatched If true, stubs are removed after being matched once. * @property afterStubMatched Callback invoked after a stub is matched. * @property onRequestReceived Callback invoked for each request received. * @property serverBuilder Optional custom server builder configuration. * @property configureExposedConfiguration Callback to expose the gRPC mock configuration to the application. */ data class GrpcMockSystemOptions( val port: Int = 0, val removeStubAfterRequestMatched: Boolean = false, val afterStubMatched: AfterStubMatched = { _, _ -> }, val onRequestReceived: OnRequestReceived = { _, _ -> }, val serverBuilder: (ServerBuilder<*>) -> ServerBuilder<*> = { it }, override val configureExposedConfiguration: (GrpcMockExposedConfiguration) -> List = { _ -> listOf() } ) : SystemOptions, ConfiguresExposedConfiguration /** * Internal context for the gRPC mock system. */ internal data class GrpcMockContext( val port: Int, val removeStubAfterRequestMatched: Boolean, val afterStubMatched: AfterStubMatched, val onRequestReceived: OnRequestReceived, val serverBuilder: (ServerBuilder<*>) -> ServerBuilder<*>, val configureExposedConfiguration: (GrpcMockExposedConfiguration) -> List, val keyName: String? = null ) internal fun Stove.withGrpcMock(options: GrpcMockSystemOptions): Stove = GrpcMockSystem( stove = this, GrpcMockContext( options.port, options.removeStubAfterRequestMatched, options.afterStubMatched, options.onRequestReceived, options.serverBuilder, options.configureExposedConfiguration ) ).also { getOrRegister(it) } .let { this } internal fun Stove.grpcMock(): GrpcMockSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(GrpcMockSystem::class) } internal fun Stove.withGrpcMock(key: SystemKey, options: GrpcMockSystemOptions): Stove = GrpcMockSystem( stove = this, GrpcMockContext( options.port, options.removeStubAfterRequestMatched, options.afterStubMatched, options.onRequestReceived, options.serverBuilder, options.configureExposedConfiguration, keyName = keyDisplayName(key) ) ).also { getOrRegister(key, it) } .let { this } internal fun Stove.grpcMock(key: SystemKey): GrpcMockSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(GrpcMockSystem::class, "No GrpcMockSystem registered with key '${keyDisplayName(key)}'") } /** * Registers the native gRPC mock system with Stove. * * ```kotlin * Stove() * .with { * grpcMock { * GrpcMockSystemOptions(port = 9090) * } * } * ``` */ fun WithDsl.grpcMock(configure: @StoveDsl () -> GrpcMockSystemOptions): Stove = this.stove.withGrpcMock(configure()) /** * Registers a keyed gRPC mock system with Stove. */ fun WithDsl.grpcMock(key: SystemKey, configure: @StoveDsl () -> GrpcMockSystemOptions): Stove = this.stove.withGrpcMock(key, configure()) /** * Access the gRPC mock system for stub configuration in tests. * * ```kotlin * stove { * grpcMock { * mockUnary( * serviceName = "greeting.GreeterService", * methodName = "SayHello", * response = HelloResponse.newBuilder().setMessage("Hello!").build() * ) * } * } * ``` */ suspend fun ValidationDsl.grpcMock( validation: @GrpcMockDsl suspend GrpcMockSystem.() -> Unit ): Unit = validation(stove.grpcMock()) /** * Access a keyed gRPC mock system for stub configuration in tests. */ suspend fun ValidationDsl.grpcMock( key: SystemKey, validation: @GrpcMockDsl suspend GrpcMockSystem.() -> Unit ): Unit = validation(stove.grpcMock(key)) ================================================ FILE: lib/stove-grpc-mock/src/main/kotlin/com/trendyol/stove/testing/grpcmock/StubDefinition.kt ================================================ package com.trendyol.stove.testing.grpcmock import com.google.protobuf.Message import io.grpc.Metadata import io.grpc.Status import kotlinx.coroutines.flow.Flow /** * Key for identifying a stub in the registry. */ data class StubKey( val serviceName: String, val methodName: String, val id: String = java.util.UUID .randomUUID() .toString() ) { val fullMethodName: String = "$serviceName/$methodName" } /** * Matcher for incoming requests. */ sealed class RequestMatcher { /** Matches any request */ data object Any : RequestMatcher() /** Matches requests with exact message content */ data class ExactMessage( val message: Message ) : RequestMatcher() /** Matches requests with exact byte content */ data class ExactBytes( val bytes: ByteArray ) : RequestMatcher() { override fun equals(other: kotlin.Any?): Boolean { if (this === other) return true if (other !is ExactBytes) return false return bytes.contentEquals(other.bytes) } override fun hashCode(): Int = bytes.contentHashCode() } /** Custom matcher function */ data class Custom( val matcher: (ByteArray) -> Boolean ) : RequestMatcher() } /** * Matcher for request metadata (headers). */ sealed class MetadataMatcher { /** Matches any metadata (no restrictions) */ data object Any : MetadataMatcher() /** Requires specific header to be present with exact value */ data class HasHeader( val key: String, val value: String ) : MetadataMatcher() /** Requires Authorization header with specific Bearer token */ data class BearerToken( val token: String ) : MetadataMatcher() /** Requires any valid Authorization header (non-empty) */ data object RequiresAuth : MetadataMatcher() /** Custom metadata matcher */ data class Custom( val matcher: (Metadata) -> Boolean ) : MetadataMatcher() /** Combines multiple matchers (all must match) */ data class All( val matchers: List ) : MetadataMatcher() { constructor(vararg matchers: MetadataMatcher) : this(matchers.toList()) } } /** * Definition of a stub response. */ sealed class StubDefinition { abstract val requestMatcher: RequestMatcher abstract val metadataMatcher: MetadataMatcher /** * Unary RPC: single request -> single response */ data class Unary( override val requestMatcher: RequestMatcher = RequestMatcher.Any, override val metadataMatcher: MetadataMatcher = MetadataMatcher.Any, val response: Message ) : StubDefinition() /** * Server streaming RPC: single request -> stream of responses */ data class ServerStream( override val requestMatcher: RequestMatcher = RequestMatcher.Any, override val metadataMatcher: MetadataMatcher = MetadataMatcher.Any, val responses: List ) : StubDefinition() /** * Client streaming RPC: stream of requests -> single response */ data class ClientStream( override val requestMatcher: RequestMatcher = RequestMatcher.Any, override val metadataMatcher: MetadataMatcher = MetadataMatcher.Any, val response: Message ) : StubDefinition() /** * Bidirectional streaming RPC: stream of requests <-> stream of responses * The handler receives a flow of request bytes and returns a flow of response messages. */ data class BidiStream( override val requestMatcher: RequestMatcher = RequestMatcher.Any, override val metadataMatcher: MetadataMatcher = MetadataMatcher.Any, val handler: suspend (Flow) -> Flow ) : StubDefinition() /** * Error response for any RPC type */ data class Error( override val requestMatcher: RequestMatcher = RequestMatcher.Any, override val metadataMatcher: MetadataMatcher = MetadataMatcher.Any, val status: Status, val message: String? = null ) : StubDefinition() } /** * Record of a request that was received by the mock server. */ data class ReceivedRequest( val stubKey: StubKey, val requestBytes: ByteArray, val metadata: Metadata = Metadata(), val timestamp: Long = System.currentTimeMillis(), val matched: Boolean, val stubId: String? = null ) { /** Get authorization header value if present */ val authorizationHeader: String? get() = metadata.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)) /** Get bearer token if present (strips "Bearer " prefix) */ val bearerToken: String? get() = authorizationHeader?.takeIf { it.startsWith("Bearer ") }?.removePrefix("Bearer ")?.trim() override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is ReceivedRequest) return false return stubKey == other.stubKey && requestBytes.contentEquals(other.requestBytes) && timestamp == other.timestamp && matched == other.matched } override fun hashCode(): Int { var result = stubKey.hashCode() result = 31 * result + requestBytes.contentHashCode() result = 31 * result + timestamp.hashCode() result = 31 * result + matched.hashCode() return result } } /** * Common metadata keys for gRPC. */ object GrpcMetadataKeys { val AUTHORIZATION: Metadata.Key = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) val CONTENT_TYPE: Metadata.Key = Metadata.Key.of("content-type", Metadata.ASCII_STRING_MARSHALLER) /** Create a custom ASCII metadata key */ fun ascii(name: String): Metadata.Key = Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER) /** Create a custom binary metadata key */ fun binary(name: String): Metadata.Key = Metadata.Key.of("$name-bin", Metadata.BINARY_BYTE_MARSHALLER) } ================================================ FILE: lib/stove-grpc-mock/src/test/kotlin/com/trendyol/stove/testing/grpcmock/GrpcMockSystemTest.kt ================================================ package com.trendyol.stove.testing.grpcmock import com.trendyol.stove.grpc.grpc import com.trendyol.stove.system.stove import com.trendyol.stove.testing.grpcmock.test.* import io.grpc.* import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.flow.* /** * Tests for the native gRPC mock system. */ class GrpcMockSystemTest : FunSpec({ context("Unary RPC") { test("should mock unary call and receive response") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", response = TestResponse .newBuilder() .setMessage("Hello from mock!") .setCount(42) .build() ) } grpc { channel { val request = testRequest { message = "Hello" count = 1 } val response = unary(request) response.message shouldBe "Hello from mock!" response.count shouldBe 42 } } } } test("should mock unary call with request matching") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", requestMatcher = RequestMatcher.ExactMessage( TestRequest .newBuilder() .setMessage("specific") .setCount(100) .build() ), response = TestResponse .newBuilder() .setMessage("Matched specific request!") .build() ) } grpc { channel { val request = testRequest { message = "specific" count = 100 } val response = unary(request) response.message shouldBe "Matched specific request!" } } } } test("should handle multiple sequential unary calls") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", requestMatcher = RequestMatcher.ExactMessage( TestRequest.newBuilder().setMessage("first").build() ), response = TestResponse.newBuilder().setMessage("First response").build() ) mockUnary( serviceName = "test.TestService", methodName = "Unary", requestMatcher = RequestMatcher.ExactMessage( TestRequest.newBuilder().setMessage("second").build() ), response = TestResponse.newBuilder().setMessage("Second response").build() ) } grpc { channel { val response1 = unary(testRequest { message = "first" }) response1.message shouldBe "First response" val response2 = unary(testRequest { message = "second" }) response2.message shouldBe "Second response" } } } } } context("Error responses") { test("should mock NOT_FOUND error") { stove { grpcMock { mockError( serviceName = "test.TestService", methodName = "Unary", status = Status.Code.NOT_FOUND, message = "Resource not found" ) } grpc { channel { val exception = shouldThrow { unary(testRequest { message = "test" }) } exception.status.code shouldBe Status.Code.NOT_FOUND exception.status.description shouldContain "Resource not found" } } } } test("should mock UNAUTHENTICATED error") { stove { grpcMock { mockError( serviceName = "test.TestService", methodName = "Unary", status = Status.Code.UNAUTHENTICATED, message = "Invalid credentials" ) } grpc { channel { val exception = shouldThrow { unary(testRequest { message = "test" }) } exception.status.code shouldBe Status.Code.UNAUTHENTICATED } } } } test("should mock INVALID_ARGUMENT error") { stove { grpcMock { mockError( serviceName = "test.TestService", methodName = "Unary", status = Status.Code.INVALID_ARGUMENT, message = "Invalid input" ) } grpc { channel { val exception = shouldThrow { unary(testRequest { message = "" }) } exception.status.code shouldBe Status.Code.INVALID_ARGUMENT } } } } } context("Server streaming RPC") { test("should mock server streaming call with multiple responses") { stove { grpcMock { mockServerStream( serviceName = "test.TestService", methodName = "ServerStream", responses = listOf( Item .newBuilder() .setId("1") .setName("Item 1") .setValue(100) .build(), Item .newBuilder() .setId("2") .setName("Item 2") .setValue(200) .build(), Item .newBuilder() .setId("3") .setName("Item 3") .setValue(300) .build() ) ) } grpc { channel { val request = testRequest { message = "stream" count = 3 } val responses = serverStream(request).toList() responses shouldHaveSize 3 responses[0].id shouldBe "1" responses[0].name shouldBe "Item 1" responses[1].id shouldBe "2" responses[2].id shouldBe "3" } } } } } context("Client streaming RPC") { test("should mock client streaming call") { stove { grpcMock { mockClientStream( serviceName = "test.TestService", methodName = "ClientStream", response = TestResponse .newBuilder() .setMessage("Received all items") .setCount(5) .build() ) } grpc { channel { val requests = kotlinx.coroutines.flow.flowOf( testRequest { message = "item1" }, testRequest { message = "item2" }, testRequest { message = "item3" } ) val response = clientStream(requests) response.message shouldBe "Received all items" response.count shouldBe 5 } } } } } context("Bidirectional streaming RPC") { test("should mock bidi streaming call") { stove { grpcMock { mockBidiStream( serviceName = "test.TestService", methodName = "BidiStream" ) { requestFlow -> requestFlow.map { _ -> // Echo back with modified message TestResponse .newBuilder() .setMessage("Echo response") .setCount(1) .build() } } } grpc { channel { val requests = kotlinx.coroutines.flow.flowOf( testRequest { message = "hello1" }, testRequest { message = "hello2" } ) val responses = bidiStream(requests).toList() responses shouldHaveSize 2 responses.forEach { it.message shouldBe "Echo response" } } } } } } context("Authenticated calls") { test("should mock authenticated unary call with bearer token") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", metadataMatcher = MetadataMatcher.BearerToken("valid-token-123"), response = TestResponse .newBuilder() .setMessage("Authenticated response!") .build() ) } grpc { channel( metadata = mapOf("authorization" to "Bearer valid-token-123") ) { val response = unary(testRequest { message = "secure request" }) response.message shouldBe "Authenticated response!" } } } } test("should reject unauthenticated request when token required") { stove { grpcMock { // Only match if token is provided mockUnary( serviceName = "test.TestService", methodName = "Unary", metadataMatcher = MetadataMatcher.BearerToken("required-token"), response = TestResponse.newBuilder().setMessage("success").build() ) } grpc { // Call without token should fail (no matching stub) channel { val exception = shouldThrow { unary(testRequest { message = "no auth" }) } exception.status.code shouldBe Status.Code.UNIMPLEMENTED } } } } test("should reject request with wrong token") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", metadataMatcher = MetadataMatcher.BearerToken("correct-token"), response = TestResponse.newBuilder().setMessage("success").build() ) } grpc { // Call with wrong token should fail channel( metadata = mapOf("authorization" to "Bearer wrong-token") ) { val exception = shouldThrow { unary(testRequest { message = "wrong auth" }) } exception.status.code shouldBe Status.Code.UNIMPLEMENTED } } } } test("should match custom header") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", metadataMatcher = MetadataMatcher.HasHeader("x-api-key", "secret-key-abc"), response = TestResponse .newBuilder() .setMessage("API key verified!") .build() ) } grpc { channel( metadata = mapOf("x-api-key" to "secret-key-abc") ) { val response = unary(testRequest { message = "api request" }) response.message shouldBe "API key verified!" } } } } test("should support RequiresAuth matcher for any auth header") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", metadataMatcher = MetadataMatcher.RequiresAuth, response = TestResponse .newBuilder() .setMessage("Some auth provided") .build() ) } grpc { // Any authorization header should work channel( metadata = mapOf("authorization" to "Basic dXNlcjpwYXNz") ) { val response = unary(testRequest { message = "basic auth" }) response.message shouldBe "Some auth provided" } } } } test("should support combined matchers") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", metadataMatcher = MetadataMatcher.All( MetadataMatcher.BearerToken("valid-token"), MetadataMatcher.HasHeader("x-tenant-id", "tenant-123") ), response = TestResponse .newBuilder() .setMessage("Multi-header match!") .build() ) } grpc { channel( metadata = mapOf( "authorization" to "Bearer valid-token", "x-tenant-id" to "tenant-123" ) ) { val response = unary(testRequest { message = "multi auth" }) response.message shouldBe "Multi-header match!" } } } } test("should mock authenticated server streaming") { stove { grpcMock { mockServerStream( serviceName = "test.TestService", methodName = "ServerStream", metadataMatcher = MetadataMatcher.BearerToken("stream-token"), responses = listOf( Item .newBuilder() .setId("1") .setName("Secure Item") .build() ) ) } grpc { channel( metadata = mapOf("authorization" to "Bearer stream-token") ) { val responses = serverStream(testRequest { message = "stream" }).toList() responses shouldHaveSize 1 responses[0].name shouldBe "Secure Item" } } } } } context("System state") { test("snapshot should return system state") { stove { grpcMock { mockUnary( serviceName = "test.TestService", methodName = "Unary", response = TestResponse.newBuilder().setMessage("test").build() ) val snapshot = snapshot() snapshot.system shouldBe "gRPC Mock" snapshot.summary shouldContain "Registered stubs:" } } } } }) ================================================ FILE: lib/stove-grpc-mock/src/test/kotlin/com/trendyol/stove/testing/grpcmock/StoveConfig.kt ================================================ package com.trendyol.stove.testing.grpcmock import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.grpc.GrpcSystemOptions import com.trendyol.stove.grpc.grpc import com.trendyol.stove.system.PortFinder import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension private val GRPC_PORT = PortFinder.findAvailablePort() class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { grpcMock { GrpcMockSystemOptions( port = GRPC_PORT, removeStubAfterRequestMatched = true ) } grpc { GrpcSystemOptions( host = "localhost", port = GRPC_PORT ) } applicationUnderTest( object : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } ) }.run() } override suspend fun afterProject(): Unit = Stove.stop() } ================================================ FILE: lib/stove-grpc-mock/src/test/proto/test_service.proto ================================================ syntax = "proto3"; package test; option java_package = "com.trendyol.stove.testing.grpcmock.test"; option java_multiple_files = true; // Request message message TestRequest { string message = 1; int32 count = 2; } // Response message message TestResponse { string message = 1; int32 count = 2; } // Item for streaming tests message Item { string id = 1; string name = 2; int32 value = 3; } // Test service with all RPC types service TestService { // Unary RPC - single request, single response rpc Unary(TestRequest) returns (TestResponse); // Server streaming RPC - single request, stream of responses rpc ServerStream(TestRequest) returns (stream Item); // Client streaming RPC - stream of requests, single response rpc ClientStream(stream TestRequest) returns (TestResponse); // Bidirectional streaming RPC - stream of requests, stream of responses rpc BidiStream(stream TestRequest) returns (stream TestResponse); } ================================================ FILE: lib/stove-grpc-mock/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.testing.grpcmock.StoveConfig ================================================ FILE: lib/stove-http/api/stove-http.api ================================================ public final class com/trendyol/stove/http/HttpClientSystemOptions : com/trendyol/stove/system/abstractions/SystemOptions { public synthetic fun (Ljava/lang/String;Lio/ktor/serialization/ContentConverter;Lio/ktor/serialization/WebsocketContentConverter;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;Lio/ktor/serialization/ContentConverter;Lio/ktor/serialization/WebsocketContentConverter;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lio/ktor/serialization/ContentConverter; public final fun component3 ()Lio/ktor/serialization/WebsocketContentConverter; public final fun component4-UwyO8pc ()J public final fun component5-UwyO8pc ()J public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun component8 ()Lkotlin/jvm/functions/Function1; public final fun copy-NxwtSZ4 (Ljava/lang/String;Lio/ktor/serialization/ContentConverter;Lio/ktor/serialization/WebsocketContentConverter;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/http/HttpClientSystemOptions; public static synthetic fun copy-NxwtSZ4$default (Lcom/trendyol/stove/http/HttpClientSystemOptions;Ljava/lang/String;Lio/ktor/serialization/ContentConverter;Lio/ktor/serialization/WebsocketContentConverter;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/http/HttpClientSystemOptions; public fun equals (Ljava/lang/Object;)Z public final fun getBaseUrl ()Ljava/lang/String; public final fun getConfigureClient ()Lkotlin/jvm/functions/Function1; public final fun getConfigureWebSocket ()Lkotlin/jvm/functions/Function1; public final fun getContentConverter ()Lio/ktor/serialization/ContentConverter; public final fun getCreateClient ()Lkotlin/jvm/functions/Function1; public final fun getTimeout-UwyO8pc ()J public final fun getWebSocketContentConverter ()Lio/ktor/serialization/WebsocketContentConverter; public final fun getWsPingInterval-UwyO8pc ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface annotation class com/trendyol/stove/http/HttpDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/http/HttpSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/PluggedSystem { public static final field Companion Lcom/trendyol/stove/http/HttpSystem$Companion; public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/http/HttpClientSystemOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/http/HttpClientSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun buildWebSocketUrl (Ljava/lang/String;)Ljava/lang/String; public fun close ()V public final fun configureRequest (Lio/ktor/client/request/HttpRequestBuilder;Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;)V public final fun deleteAndExpectBodilessResponse (Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun deleteAndExpectBodilessResponse$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun executeWithBody (Lio/ktor/http/HttpMethod;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun get (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Larrow/core/Option;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getBodilessResponse (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun getBodilessResponse$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun getKtorHttpClient ()Lio/ktor/client/HttpClient; public final fun getOptions ()Lcom/trendyol/stove/http/HttpClientSystemOptions; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun headAndExpectBodilessResponse (Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun headAndExpectBodilessResponse$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun patchAndExpectBodilessResponse (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun patchAndExpectBodilessResponse$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun postAndExpectBodilessResponse (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun postAndExpectBodilessResponse$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun putAndExpectBodilessResponse (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun putAndExpectBodilessResponse$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun toBodilessResponse (Lio/ktor/client/statement/HttpResponse;)Lcom/trendyol/stove/http/StoveHttpResponse$Bodiless; public final fun toFormData (Ljava/util/List;)Ljava/util/List; public final fun webSocket (Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun webSocket$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun webSocketExpect (Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun webSocketExpect$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun webSocketRaw (Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun webSocketRaw$default (Lcom/trendyol/stove/http/HttpSystem;Ljava/lang/String;Ljava/util/Map;Larrow/core/Option;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class com/trendyol/stove/http/HttpSystem$Companion { public final fun client (Lcom/trendyol/stove/http/HttpSystem;)Lio/ktor/client/HttpClient; public final fun client (Lcom/trendyol/stove/http/HttpSystem;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/http/HttpSystem$Companion$HeaderConstants { public static final field AUTHORIZATION Ljava/lang/String; public static final field INSTANCE Lcom/trendyol/stove/http/HttpSystem$Companion$HeaderConstants; public final fun bearer (Ljava/lang/String;)Ljava/lang/String; } public final class com/trendyol/stove/http/HttpSystemKt { public static final fun http-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun http-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun httpClient-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun httpClient-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public abstract class com/trendyol/stove/http/StoveMultiPartContent { } public final class com/trendyol/stove/http/StoveMultiPartContent$Binary : com/trendyol/stove/http/StoveMultiPartContent { public fun (Ljava/lang/String;[B)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()[B public final fun copy (Ljava/lang/String;[B)Lcom/trendyol/stove/http/StoveMultiPartContent$Binary; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveMultiPartContent$Binary;Ljava/lang/String;[BILjava/lang/Object;)Lcom/trendyol/stove/http/StoveMultiPartContent$Binary; public fun equals (Ljava/lang/Object;)Z public final fun getContent ()[B public final fun getParam ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/http/StoveMultiPartContent$File : com/trendyol/stove/http/StoveMultiPartContent { public fun (Ljava/lang/String;Ljava/lang/String;[BLjava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()[B public final fun component4 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;[BLjava/lang/String;)Lcom/trendyol/stove/http/StoveMultiPartContent$File; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveMultiPartContent$File;Ljava/lang/String;Ljava/lang/String;[BLjava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/http/StoveMultiPartContent$File; public fun equals (Ljava/lang/Object;)Z public final fun getContent ()[B public final fun getContentType ()Ljava/lang/String; public final fun getFileName ()Ljava/lang/String; public final fun getParam ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/http/StoveMultiPartContent$Text : com/trendyol/stove/http/StoveMultiPartContent { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/http/StoveMultiPartContent$Text; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveMultiPartContent$Text;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/http/StoveMultiPartContent$Text; public fun equals (Ljava/lang/Object;)Z public final fun getParam ()Ljava/lang/String; public final fun getValue ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract class com/trendyol/stove/http/StoveWebSocketMessage { } public final class com/trendyol/stove/http/StoveWebSocketMessage$Binary : com/trendyol/stove/http/StoveWebSocketMessage { public fun ([B)V public final fun component1 ()[B public final fun copy ([B)Lcom/trendyol/stove/http/StoveWebSocketMessage$Binary; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveWebSocketMessage$Binary;[BILjava/lang/Object;)Lcom/trendyol/stove/http/StoveWebSocketMessage$Binary; public fun equals (Ljava/lang/Object;)Z public final fun getContent ()[B public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/http/StoveWebSocketMessage$Text : com/trendyol/stove/http/StoveWebSocketMessage { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;)Lcom/trendyol/stove/http/StoveWebSocketMessage$Text; public static synthetic fun copy$default (Lcom/trendyol/stove/http/StoveWebSocketMessage$Text;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/http/StoveWebSocketMessage$Text; public fun equals (Ljava/lang/Object;)Z public final fun getContent ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/http/StoveWebSocketSession { public fun (Lio/ktor/client/plugins/websocket/DefaultClientWebSocketSession;)V public final fun close (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun close$default (Lcom/trendyol/stove/http/StoveWebSocketSession;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun collectBinaries-8Mi8wO0 (IJLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun collectBinaries-8Mi8wO0$default (Lcom/trendyol/stove/http/StoveWebSocketSession;IJLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun collectTexts-8Mi8wO0 (IJLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun collectTexts-8Mi8wO0$default (Lcom/trendyol/stove/http/StoveWebSocketSession;IJLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun getSession ()Lio/ktor/client/plugins/websocket/DefaultClientWebSocketSession; public final fun incoming ()Lkotlinx/coroutines/flow/Flow; public final fun incomingBinaries ()Lkotlinx/coroutines/flow/Flow; public final fun incomingTexts ()Lkotlinx/coroutines/flow/Flow; public final fun receive (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun receiveBinary (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun receiveBinaryWithTimeout-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun receiveBinaryWithTimeout-VtjQ1oo$default (Lcom/trendyol/stove/http/StoveWebSocketSession;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun receiveText (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun receiveTextWithTimeout-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun receiveTextWithTimeout-VtjQ1oo$default (Lcom/trendyol/stove/http/StoveWebSocketSession;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun send (Lcom/trendyol/stove/http/StoveWebSocketMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun send (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun send ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun underlyingSession (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/http/StreamingKt { public static final fun readJsonContentStream (Lio/ktor/client/statement/HttpStatement;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; public static final fun readJsonTextStream (Lio/ktor/client/statement/HttpStatement;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; public static final fun serializeToStreamJson (Lcom/trendyol/stove/serialization/StoveSerde;Ljava/util/List;)[B } ================================================ FILE: lib/stove-http/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.ktor.client.core) api(libs.ktor.client.okhttp) api(libs.ktor.client.plugins.logging) api(libs.ktor.client.content.negotiation) api(libs.ktor.serialization.jackson.json) api(libs.ktor.client.websockets) implementation(libs.kotlinx.core) implementation(libs.kotlinx.io.reactor) implementation(libs.kotlinx.reactive) implementation(libs.kotlinx.jdk8) } dependencies { testImplementation(projects.lib.stoveWiremock) testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.jackson.jsr310) testImplementation(testFixtures(projects.lib.stove)) testImplementation(libs.logback.classic) testImplementation(libs.ktor.server.netty) testImplementation(libs.ktor.server.websockets) } ================================================ FILE: lib/stove-http/src/main/kotlin/com/trendyol/stove/http/HttpClientFactory.kt ================================================ package com.trendyol.stove.http import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.* import org.slf4j.LoggerFactory import kotlin.time.* private val httpClientLogger = LoggerFactory.getLogger("com.trendyol.stove.http.HttpClient") @Suppress("MagicNumber") internal fun jsonHttpClient( baseUrl: String, timeout: Duration, converter: ContentConverter, webSocketContentConverter: WebsocketContentConverter, pingInterval: Duration, configureWebSocket: WebSockets.Config.() -> Unit = {}, configureClient: HttpClientConfig<*>.() -> Unit = {} ): HttpClient = HttpClient(OkHttp) { engine { config { followRedirects(true) followSslRedirects(true) connectTimeout(timeout.toJavaDuration()) readTimeout(timeout.toJavaDuration()) callTimeout(timeout.toJavaDuration()) writeTimeout(timeout.toJavaDuration()) } } install(Logging) { logger = object : Logger { override fun log(message: String) { httpClientLogger.info(message) } } } install(ContentNegotiation) { register(ContentType.Application.Json, converter) register(ContentType.Application.ProblemJson, converter) register(ContentType.parse("application/x-ndjson"), converter) } install(WebSockets) { contentConverter = webSocketContentConverter this.pingInterval = pingInterval configureWebSocket(this) } defaultRequest { url(baseUrl) header(HttpHeaders.ContentType, ContentType.Application.Json) header(HttpHeaders.Accept, ContentType.Application.Json) } configureClient(this) } ================================================ FILE: lib/stove-http/src/main/kotlin/com/trendyol/stove/http/HttpDsl.kt ================================================ package com.trendyol.stove.http @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class HttpDsl ================================================ FILE: lib/stove-http/src/main/kotlin/com/trendyol/stove/http/HttpSystem.kt ================================================ @file:Suppress("MemberVisibilityCanBePrivate", "unused") package com.trendyol.stove.http import arrow.core.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import com.trendyol.stove.tracing.TraceContext import io.ktor.client.call.* import io.ktor.client.plugins.websocket.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.* import io.ktor.serialization.jackson.* import io.ktor.util.* import io.ktor.util.reflect.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import java.nio.charset.Charset import kotlin.time.Duration.Companion.seconds /** * Configuration options for the HTTP client system. * * ## Basic Configuration * * ```kotlin * Stove() * .with { * httpClient { * HttpClientSystemOptions( * baseUrl = "http://localhost:8080" * ) * } * } * ``` * * ## Custom Serialization * * Match your application's JSON serialization: * * ```kotlin * httpClient { * HttpClientSystemOptions( * baseUrl = "http://localhost:8080", * contentConverter = JacksonConverter(myObjectMapper), * timeout = 60.seconds * ) * } * ``` * * ## Custom HTTP Client * * For advanced scenarios (custom SSL, interceptors, etc.): * * ```kotlin * httpClient { * HttpClientSystemOptions( * baseUrl = "http://localhost:8080", * createClient = { baseUrl -> * HttpClient(CIO) { * install(ContentNegotiation) { jackson() } * install(Logging) { level = LogLevel.ALL } * defaultRequest { url(baseUrl) } * } * } * ) * } * ``` * * @property baseUrl The base URL for all HTTP requests (e.g., "http://localhost:8080"). * @property contentConverter The content converter for JSON serialization (default: Jackson). * @property timeout Request timeout duration (default: 30 seconds). * @property createClient Factory function for creating the underlying Ktor HTTP client. */ @HttpDsl data class HttpClientSystemOptions( val baseUrl: String, val contentConverter: ContentConverter = JacksonConverter(StoveSerde.jackson.default), val webSocketContentConverter: WebsocketContentConverter = JacksonWebsocketContentConverter( StoveSerde.jackson.default ), val timeout: kotlin.time.Duration = 30.seconds, val wsPingInterval: kotlin.time.Duration = 20.seconds, val configureClient: io.ktor.client.HttpClientConfig<*>.() -> Unit = {}, val configureWebSocket: WebSockets.Config.() -> Unit = {}, val createClient: ( baseUrl: String ) -> io.ktor.client.HttpClient = { url -> jsonHttpClient( url, timeout, contentConverter, webSocketContentConverter, wsPingInterval, configureWebSocket, configureClient ) } ) : SystemOptions internal fun Stove.withHttpClient(options: HttpClientSystemOptions): Stove { this.getOrRegister(HttpSystem(this, options)) return this } internal fun Stove.withHttpClient(key: SystemKey, options: HttpClientSystemOptions): Stove { this.getOrRegister(key, HttpSystem(this, options, keyName = keyDisplayName(key))) return this } internal fun Stove.http(): HttpSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(HttpSystem::class) } internal fun Stove.http(key: SystemKey): HttpSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(HttpSystem::class, "No HttpSystem registered with key '${keyDisplayName(key)}'") } /** * Registers the HTTP client system with the test system. * * ```kotlin * Stove() * .with { * httpClient { * HttpClientSystemOptions(baseUrl = "http://localhost:8080") * } * } * ``` * * @param configure Configuration block returning [HttpClientSystemOptions]. * @return The test system for fluent chaining. */ fun WithDsl.httpClient(configure: @StoveDsl () -> HttpClientSystemOptions): Stove = this.stove.withHttpClient(configure()) /** * Registers a keyed HTTP client system for testing multiple HTTP services. * * ```kotlin * Stove().with { * httpClient(PaymentService) { * HttpClientSystemOptions(baseUrl = "https://payment.internal.com") * } * } * ``` * * @param key The [SystemKey] identifying this HTTP client instance. * @param configure Configuration block returning [HttpClientSystemOptions]. * @return The test system for fluent chaining. */ fun WithDsl.httpClient(key: SystemKey, configure: @StoveDsl () -> HttpClientSystemOptions): Stove = this.stove.withHttpClient(key, configure()) /** * Executes HTTP assertions within the validation DSL. * * ```kotlin * stove { * http { * get("/users/123") { user -> * user.name shouldBe "John" * } * } * } * ``` * * @param validation The HTTP assertion block. */ suspend fun ValidationDsl.http( validation: @HttpDsl suspend HttpSystem.() -> Unit ): Unit = validation(this.stove.http()) /** * Executes HTTP assertions against a keyed HTTP client within the validation DSL. * * ```kotlin * stove { * http(PaymentService) { * get("/payments/123") { payment -> * payment.amount shouldBe 99.99 * } * } * } * ``` * * @param key The [SystemKey] identifying the HTTP client instance. * @param validation The HTTP assertion block. */ suspend fun ValidationDsl.http( key: SystemKey, validation: @HttpDsl suspend HttpSystem.() -> Unit ): Unit = validation(this.stove.http(key)) /** * HTTP client system for testing REST APIs. * * Provides a fluent DSL for making HTTP requests and asserting responses. * All methods return the system instance for chaining. * * ## GET Requests * * ```kotlin * http { * // Get with typed response body * get("/users/123") { user -> * user.name shouldBe "John" * user.email shouldBe "john@example.com" * } * * // Get with query parameters * get>("/users", queryParams = mapOf("role" to "admin")) { users -> * users.size shouldBeGreaterThan 0 * } * * // Get with full response access * getResponse("/users/123") { response -> * response.status shouldBe 200 * response.headers["Content-Type"] shouldContain "application/json" * response.body().name shouldBe "John" * } * * // Get with headers and auth token * get( * uri = "/secrets", * headers = mapOf("X-Request-Id" to "123"), * token = "jwt-token".some() * ) { data -> * data shouldNotBe null * } * } * ``` * * ## POST Requests * * ```kotlin * http { * // Post with JSON body and typed response * postAndExpectJson( * uri = "/users", * body = CreateUserRequest(name = "John", email = "john@example.com").some() * ) { user -> * user.id shouldNotBe null * } * * // Post expecting only status code * postAndExpectBodilessResponse( * uri = "/users", * body = CreateUserRequest(name = "John").some() * ) { response -> * response.status shouldBe 201 * } * * // Post with full response access * postAndExpectBody( * uri = "/users", * body = request.some() * ) { response -> * response.status shouldBe 201 * response.body().id shouldNotBe null * } * } * ``` * * ## PUT, PATCH, DELETE Requests * * ```kotlin * http { * // PUT * putAndExpectJson( * uri = "/users/123", * body = UpdateUserRequest(name = "Jane").some() * ) { user -> * user.name shouldBe "Jane" * } * * // PATCH * patchAndExpectBodilessResponse( * uri = "/users/123", * body = mapOf("status" to "active").some() * ) { response -> * response.status shouldBe 200 * } * * // DELETE * deleteAndExpectBodilessResponse("/users/123") { response -> * response.status shouldBe 204 * } * } * ``` * * ## Multipart/Form Requests * * ```kotlin * http { * postMultipartAndExpectResponse( * uri = "/upload", * body = listOf( * StoveMultiPartContent.Text("name", "document.pdf"), * StoveMultiPartContent.File( * param = "file", * fileName = "document.pdf", * content = fileBytes, * contentType = "application/pdf" * ) * ) * ) { response -> * response.body().fileId shouldNotBe null * } * } * ``` * * ## Streaming Responses * * ```kotlin * http { * readJsonStream( * uri = "/logs/stream", * headers = mapOf("Accept" to "application/x-ndjson") * ) { flow -> * flow.collect { entry -> * println(entry.message) * } * } * } * ``` * * @property stove The parent test system. * @property options HTTP client configuration options. * @see HttpClientSystemOptions * @see StoveHttpResponse */ @Suppress("TooManyFunctions") @HttpDsl class HttpSystem( override val stove: Stove, @PublishedApi internal val options: HttpClientSystemOptions, private val keyName: String? = null ) : PluggedSystem, Reports { @PublishedApi internal val ktorHttpClient: io.ktor.client.HttpClient = options.createClient(options.baseUrl) override val reportSystemName: String = "HTTP" + (keyName?.let { " [$it]" } ?: "") /** * Performs a GET request and asserts on the bodiless response. */ suspend fun getBodilessResponse( uri: String, queryParams: Map = mapOf(), headers: Map = mapOf(), token: Option = None, expect: suspend (StoveHttpResponse.Bodiless) -> Unit ): HttpSystem { val response = get(uri, headers, queryParams, token) report( action = "GET $uri", input = queryParams.takeIf { it.isNotEmpty() }.toOption(), output = "Status: ${response.status.value}".some(), metadata = mapOf( "status" to response.status.value, "headers" to headers, "response" to response.bodyAsText() ), expected = "Response matching expectation".some() ) { expect(response.toBodilessResponse()) } return this } /** * Performs a GET request and asserts on the typed response body. */ suspend inline fun getResponse( uri: String, queryParams: Map = mapOf(), headers: Map = mapOf(), token: Option = None, crossinline expect: suspend (StoveHttpResponse.WithBody) -> Unit ): HttpSystem { val response = get(uri, headers, queryParams, token) report( action = "GET $uri", input = queryParams.takeIf { it.isNotEmpty() }.toOption(), output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "Response<${T::class.simpleName}> matching expectation".some() ) { expect(response.toResponseWithBody()) } return this } /** * Performs a GET request and asserts on the deserialized response body. */ suspend inline fun get( uri: String, queryParams: Map = mapOf(), headers: Map = mapOf(), token: Option = None, crossinline expect: (TExpected) -> Unit ): HttpSystem { val response = get(uri, headers, queryParams, token) report( action = "GET $uri", input = queryParams.takeIf { it.isNotEmpty() }.toOption(), output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "${TExpected::class.simpleName} matching expectation".some() ) { response.expectSuccessBody(expect) } return this } /** * Performs a GET request and asserts on a list response. */ suspend inline fun getMany( uri: String, queryParams: Map = mapOf(), headers: Map = mapOf(), token: Option = None, crossinline expect: (List) -> Unit ): HttpSystem { val response = get(uri, headers, queryParams, token) report( action = "GET $uri", input = queryParams.takeIf { it.isNotEmpty() }.toOption(), output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "List<${TExpected::class.simpleName}> matching expectation".some() ) { response.expectSuccessBody(expect) } return this } /** * Performs a GET request for a JSON stream (NDJSON) and asserts on the flow. */ suspend inline fun readJsonStream( uri: String, queryParams: Map = mapOf(), headers: Map = mapOf(), token: Option = None, crossinline expect: suspend (Flow) -> Unit ): HttpSystem { report( action = "GET $uri (stream)", input = queryParams.takeIf { it.isNotEmpty() }.toOption(), metadata = mapOf("headers" to headers), expected = "Flow<${TExpected::class.simpleName}> stream".some() ) { val flow = ktorHttpClient .prepareGet { url { appendEncodedPathSegments(uri) } headers.forEach { (key, value) -> header(key, value) } header(HttpHeaders.Accept, "application/x-ndjson") queryParams.forEach { (key, value) -> parameter(key, value) } token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } }.readJsonContentStream { options.contentConverter.deserialize(Charset.defaultCharset(), typeInfo(), it) as TExpected } expect(flow.flowOn(Dispatchers.IO)) } return this } /** * Performs a POST request and asserts on the bodiless response. */ suspend fun postAndExpectBodilessResponse( uri: String, body: Option, token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Post, uri, body, headers, token) report( action = "POST $uri", input = body, metadata = mapOf( "status" to response.status.value, "headers" to headers, "response" to response.bodyAsText() ), expected = "Response matching expectation".some() ) { expect(response.toBodilessResponse()) } return this } /** * Performs a POST request and asserts on the deserialized response body. */ suspend inline fun postAndExpectJson( uri: String, body: Option = None, headers: Map = mapOf(), token: Option = None, crossinline expect: (actual: TExpected) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Post, uri, body, headers, token) report( action = "POST $uri", input = body, output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "${TExpected::class.simpleName} matching expectation".some() ) { response.expectSuccessBody(expect) } return this } /** * Performs a POST request and asserts on the typed response with body access. */ suspend inline fun postAndExpectBody( uri: String, body: Option = None, headers: Map = mapOf(), token: Option = None, crossinline expect: suspend (actual: StoveHttpResponse.WithBody) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Post, uri, body, headers, token) report( action = "POST $uri", input = body, output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "Response<${TExpected::class.simpleName}> matching expectation".some() ) { expect(response.toResponseWithBody()) } return this } /** * Performs a PUT request and asserts on the bodiless response. */ suspend fun putAndExpectBodilessResponse( uri: String, body: Option, token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Put, uri, body, headers, token) report( action = "PUT $uri", input = body, metadata = mapOf( "status" to response.status.value, "headers" to headers, "response" to response.bodyAsText() ), expected = "Response matching expectation".some() ) { expect(response.toBodilessResponse()) } return this } /** * Performs a PUT request and asserts on the deserialized response body. */ suspend inline fun putAndExpectJson( uri: String, body: Option = None, headers: Map = mapOf(), token: Option = None, crossinline expect: (actual: TExpected) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Put, uri, body, headers, token) report( action = "PUT $uri", input = body, output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "${TExpected::class.simpleName} matching expectation".some() ) { response.expectSuccessBody(expect) } return this } /** * Performs a PUT request and asserts on the typed response with body access. */ suspend inline fun putAndExpectBody( uri: String, body: Option = None, headers: Map = mapOf(), token: Option = None, crossinline expect: suspend (actual: StoveHttpResponse.WithBody) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Put, uri, body, headers, token) report( action = "PUT $uri", input = body, output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "Response<${TExpected::class.simpleName}> matching expectation".some() ) { expect(response.toResponseWithBody()) } return this } /** * Performs a PATCH request and asserts on the bodiless response. */ suspend fun patchAndExpectBodilessResponse( uri: String, body: Option, token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Patch, uri, body, headers, token) report( action = "PATCH $uri", input = body, metadata = mapOf( "status" to response.status.value, "headers" to headers, "response" to response.bodyAsText() ), expected = "Response matching expectation".some() ) { expect(response.toBodilessResponse()) } return this } /** * Performs a PATCH request and asserts on the deserialized response body. */ suspend inline fun patchAndExpectJson( uri: String, body: Option = None, headers: Map = mapOf(), token: Option = None, crossinline expect: (actual: TExpected) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Patch, uri, body, headers, token) report( action = "PATCH $uri", input = body, output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "${TExpected::class.simpleName} matching expectation".some() ) { response.expectSuccessBody(expect) } return this } /** * Performs a PATCH request and asserts on the typed response with body access. */ suspend inline fun patchAndExpectBody( uri: String, body: Option = None, headers: Map = mapOf(), token: Option = None, crossinline expect: suspend (actual: StoveHttpResponse.WithBody) -> Unit ): HttpSystem { val response = executeWithBody(HttpMethod.Patch, uri, body, headers, token) report( action = "PATCH $uri", input = body, output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "Response<${TExpected::class.simpleName}> matching expectation".some() ) { expect(response.toResponseWithBody()) } return this } /** * Performs a DELETE request and asserts on the bodiless response. */ suspend fun deleteAndExpectBodilessResponse( uri: String, token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit ): HttpSystem { val response = ktorHttpClient.delete { configureRequest(uri, headers, token) } report( action = "DELETE $uri", metadata = mapOf( "status" to response.status.value, "headers" to headers, "response" to response.bodyAsText() ), expected = "Response matching expectation".some() ) { expect(response.toBodilessResponse()) } return this } /** * Performs a DELETE request and asserts on the deserialized response body. */ suspend inline fun deleteAndExpectJson( uri: String, headers: Map = mapOf(), token: Option = None, crossinline expect: (actual: TExpected) -> Unit ): HttpSystem { val response = ktorHttpClient.delete { configureRequest(uri, headers, token) } report( action = "DELETE $uri", output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "${TExpected::class.simpleName} matching expectation".some() ) { response.expectSuccessBody(expect) } return this } /** * Performs a HEAD request and asserts on the bodiless response. */ suspend fun headAndExpectBodilessResponse( uri: String, token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit ): HttpSystem { val response = ktorHttpClient.head { configureRequest(uri, headers, token) } report( action = "HEAD $uri", metadata = mapOf( "status" to response.status.value, "headers" to headers, "response" to response.bodyAsText() ), expected = "Response matching expectation".some() ) { expect(response.toBodilessResponse()) } return this } /** * Performs a multipart POST request and asserts on the typed response. */ suspend inline fun postMultipartAndExpectResponse( uri: String, body: List, headers: Map = mapOf(), token: Option = None, crossinline expect: suspend (StoveHttpResponse.WithBody) -> Unit ): HttpSystem { val response = ktorHttpClient.submitForm { configureRequest(uri, headers, token) setBody(MultiPartFormDataContent(toFormData(body))) } report( action = "POST $uri (multipart)", input = body.map { it::class.simpleName }.some(), output = response.bodyAsText().some(), metadata = mapOf("status" to response.status.value, "headers" to headers), expected = "Response<${TExpected::class.simpleName}> matching expectation".some() ) { expect(response.toResponseWithBody()) } return this } override fun then(): Stove = stove @PublishedApi internal suspend fun get( uri: String, headers: Map, queryParams: Map, token: Option ) = ktorHttpClient.get { configureRequest(uri, headers, token) queryParams.forEach { (key, value) -> parameter(key, value) } } @PublishedApi internal suspend fun executeWithBody( method: HttpMethod, uri: String, body: Option, headers: Map, token: Option ): HttpResponse = ktorHttpClient.request { this.method = method configureRequest(uri, headers, token) body.map { setBody(it) } } @PublishedApi internal fun HttpRequestBuilder.configureRequest( uri: String, headers: Map, token: Option ) { url { appendEncodedPathSegments(uri) } headers.forEach { (key, value) -> header(key, value) } token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } injectTraceHeaders() } private fun HttpRequestBuilder.injectTraceHeaders() { TraceContext.current()?.let { ctx -> header(TraceContext.TRACEPARENT_HEADER, ctx.toTraceparent()) header(TraceContext.STOVE_TEST_ID_HEADER, ctx.testId) } } @PublishedApi internal fun HttpResponse.toBodilessResponse(): StoveHttpResponse.Bodiless = StoveHttpResponse.Bodiless(status.value, headers.toMap()) @PublishedApi internal inline fun HttpResponse.toResponseWithBody(): StoveHttpResponse.WithBody = StoveHttpResponse.WithBody(status.value, headers.toMap()) { body() } @PublishedApi internal suspend inline fun HttpResponse.expectSuccessBody(expect: (T) -> Unit) { check(status.isSuccess()) { "Expected a successful response, but got $status" } expect(body()) } @PublishedApi internal fun toFormData( body: List ) = formData { body.forEach { when (it) { is StoveMultiPartContent.Text -> append(it.param, it.value) is StoveMultiPartContent.Binary -> append( it.param, it.content, Headers.build { append(HttpHeaders.ContentType, ContentType.Application.OctetStream) } ) is StoveMultiPartContent.File -> append( it.param, it.content, Headers.build { append(HttpHeaders.ContentType, ContentType.parse(it.contentType)) append(HttpHeaders.ContentDisposition, "filename=${it.fileName}") } ) } } } // region WebSocket Methods /** * Establishes a WebSocket connection and executes the provided block. * * ## Basic Usage * * ```kotlin * http { * webSocket("/chat") { session -> * session.send("Hello!") * val response = session.receiveText() * response shouldBe "Echo: Hello!" * } * } * ``` * * ## With Headers and Token * * ```kotlin * http { * webSocket( * uri = "/secure-chat", * headers = mapOf("X-Custom-Header" to "value"), * token = "jwt-token".some() * ) { session -> * session.send("Authenticated message") * } * } * ``` * * @param uri The WebSocket endpoint URI (e.g., "/chat"). * @param headers Optional HTTP headers to send with the upgrade request. * @param token Optional bearer token for authentication. * @param block The test block to execute with the WebSocket session. * @return The [HttpSystem] for fluent chaining. */ suspend fun webSocket( uri: String, headers: Map = mapOf(), token: Option = None, block: suspend StoveWebSocketSession.() -> Unit ): HttpSystem { ktorHttpClient.webSocket( urlString = buildWebSocketUrl(uri), request = { headers.forEach { (key, value) -> this.headers.append(key, value) } token.map { this.headers.append(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } injectWebSocketTraceHeaders() } ) { val stoveSession = StoveWebSocketSession(this) block(stoveSession) } return this } /** * Establishes a WebSocket connection and executes assertions on the session. * * This is an alias for [webSocket] with a clearer intent for assertion-focused tests. * * @param uri The WebSocket endpoint URI. * @param headers Optional HTTP headers. * @param token Optional bearer token. * @param expect The assertion block to execute. * @return The [HttpSystem] for fluent chaining. */ suspend fun webSocketExpect( uri: String, headers: Map = mapOf(), token: Option = None, expect: suspend StoveWebSocketSession.() -> Unit ): HttpSystem = webSocket(uri, headers, token, expect) /** * Establishes a raw WebSocket connection for advanced use cases. * * This method provides direct access to the Ktor WebSocket session * for scenarios where the simplified [StoveWebSocketSession] is not sufficient. * * @param uri The WebSocket endpoint URI. * @param headers Optional HTTP headers. * @param token Optional bearer token. * @param block The block to execute with the raw Ktor WebSocket session. * @return The [HttpSystem] for fluent chaining. */ suspend fun webSocketRaw( uri: String, headers: Map = mapOf(), token: Option = None, block: suspend DefaultClientWebSocketSession.() -> Unit ): HttpSystem { ktorHttpClient.webSocket( urlString = buildWebSocketUrl(uri), request = { headers.forEach { (key, value) -> this.headers.append(key, value) } token.map { this.headers.append(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } injectWebSocketTraceHeaders() } ) { block() } return this } @PublishedApi internal fun buildWebSocketUrl(uri: String): String { val baseUrl = options.baseUrl val wsUrl = when { baseUrl.startsWith("https://") -> baseUrl.replace("https://", "wss://") baseUrl.startsWith("http://") -> baseUrl.replace("http://", "ws://") else -> "ws://$baseUrl" } return "$wsUrl${uri.ensureLeadingSlash()}" } private fun String.ensureLeadingSlash(): String = if (startsWith("/")) this else "/$this" private fun HttpRequestBuilder.injectWebSocketTraceHeaders() { TraceContext.current()?.let { ctx -> headers.append(TraceContext.TRACEPARENT_HEADER, ctx.toTraceparent()) headers.append(TraceContext.STOVE_TEST_ID_HEADER, ctx.testId) } } // endregion override fun close() { ktorHttpClient.close() } companion object { object HeaderConstants { const val AUTHORIZATION = "Authorization" fun bearer(token: String) = "Bearer $token" } /** * Exposes the [io.ktor.client.HttpClient] used by the [HttpSystem]. * Use this for advanced HTTP operations not covered by the DSL. */ @Suppress("unused") fun HttpSystem.client(): io.ktor.client.HttpClient = this.ktorHttpClient /** * Exposes the [io.ktor.client.HttpClient] used by the [HttpSystem]. * Use this for advanced HTTP operations not covered by the DSL. */ @Suppress("unused") suspend fun HttpSystem.client( block: suspend io.ktor.client.HttpClient.(baseUrl: URLBuilder) -> Unit ) { report( action = "Custom HTTP Client Operation", metadata = mapOf("baseUrl" to this.options.baseUrl), expected = "Custom operation completed".some() ) { block(this.ktorHttpClient, URLBuilder(this.options.baseUrl)) } } } } ================================================ FILE: lib/stove-http/src/main/kotlin/com/trendyol/stove/http/StoveMultiPartContent.kt ================================================ @file:Suppress("ArrayInDataClass") package com.trendyol.stove.http /** * Represents a multi-part content for a HTTP request. */ sealed class StoveMultiPartContent { /** * Represents a text content for a multi-part request. */ data class Text( val param: String, val value: String ) : StoveMultiPartContent() /** * Represents a file content for a multi-part request. */ data class File( val param: String, val fileName: String, val content: ByteArray, val contentType: String ) : StoveMultiPartContent() /** * Represents a binary content for a multi-part request. */ data class Binary( val param: String, val content: ByteArray ) : StoveMultiPartContent() } ================================================ FILE: lib/stove-http/src/main/kotlin/com/trendyol/stove/http/streaming.kt ================================================ package com.trendyol.stove.http import arrow.core.toOption import com.trendyol.stove.serialization.StoveSerde import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.utils.io.* import kotlinx.coroutines.flow.* @OptIn(InternalAPI::class) @Suppress("unused") fun HttpStatement.readJsonTextStream(transform: suspend (line: String) -> T): Flow = flow { execute { check(it.status.isSuccess()) { "Request failed with status: ${it.status}" } while (!it.rawContent.isClosedForRead) { it.rawContent.readUTF8LineNonEmpty { line -> emit(transform(line)) } } } } @OptIn(InternalAPI::class) @Suppress("unused") fun HttpStatement.readJsonContentStream(transform: suspend (line: ByteReadChannel) -> T): Flow = flow { execute { check(it.status.isSuccess()) { "Request failed with status: ${it.status}" } while (!it.rawContent.isClosedForRead) { it.rawContent.readUTF8LineNonEmpty { line -> emit(transform(ByteReadChannel(line.toByteArray()))) } } } } private suspend fun ByteReadChannel.readUTF8LineNonEmpty(onRead: suspend (String) -> Unit) { readLine().toOption().filter { it.isNotBlank() }.map { onRead(it) } } /** * Serializes the items to a stream of JSON strings. */ fun StoveSerde.serializeToStreamJson(items: List): ByteArray = items .joinToString("\n") { String(serialize(it)) } .toByteArray() ================================================ FILE: lib/stove-http/src/main/kotlin/com/trendyol/stove/http/websocket.kt ================================================ @file:Suppress("TooManyFunctions") package com.trendyol.stove.http import arrow.core.* import io.ktor.client.plugins.websocket.* import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** * Represents a WebSocket message that can be sent or received. * * @see Text for text messages * @see Binary for binary messages */ @HttpDsl sealed class StoveWebSocketMessage { /** * A text-based WebSocket message. * * @property content The text content of the message. */ data class Text( val content: String ) : StoveWebSocketMessage() /** * A binary WebSocket message. * * @property content The binary content of the message. */ data class Binary( val content: ByteArray ) : StoveWebSocketMessage() { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as Binary return content.contentEquals(other.content) } override fun hashCode(): Int = content.contentHashCode() } } /** * A test-friendly wrapper around a Ktor WebSocket session. * * Provides a simplified API for sending and receiving WebSocket messages * in e2e tests, including support for collecting messages with timeouts. * * ## Basic Usage * * ```kotlin * http { * webSocket("/chat") { session -> * // Send a message * session.send("Hello, World!") * * // Receive a single message * val response = session.receiveText() * response shouldBe "Echo: Hello, World!" * } * } * ``` * * ## Collecting Multiple Messages * * ```kotlin * http { * webSocket("/events") { session -> * // Collect messages with a timeout * val messages = session.collectTexts( * count = 5, * timeout = 10.seconds * ) * messages.size shouldBe 5 * } * } * ``` * * @property session The underlying Ktor WebSocket session. */ @HttpDsl class StoveWebSocketSession( @PublishedApi internal val session: DefaultClientWebSocketSession ) { /** * Sends a text message through the WebSocket connection. * * @param message The text message to send. */ suspend fun send(message: String) { session.send(Frame.Text(message)) } /** * Sends a binary message through the WebSocket connection. * * @param data The binary data to send. */ suspend fun send(data: ByteArray) { session.send(Frame.Binary(true, data)) } /** * Sends a [StoveWebSocketMessage] through the WebSocket connection. * * @param message The message to send (either Text or Binary). */ suspend fun send(message: StoveWebSocketMessage) { when (message) { is StoveWebSocketMessage.Text -> send(message.content) is StoveWebSocketMessage.Binary -> send(message.content) } } /** * Receives the next text message from the WebSocket connection. * * @return The received text message, or null if the connection is closed * or a non-text frame is received. */ suspend fun receiveText(): String? = session.incoming.receive().let { frame -> when (frame) { is Frame.Text -> frame.readText() else -> null } } /** * Receives the next binary message from the WebSocket connection. * * @return The received binary data, or null if the connection is closed * or a non-binary frame is received. */ suspend fun receiveBinary(): ByteArray? = session.incoming.receive().let { frame -> when (frame) { is Frame.Binary -> frame.readBytes() else -> null } } /** * Receives the next message from the WebSocket connection as a [StoveWebSocketMessage]. * * @return The received message (Text or Binary), or null if the connection is closed * or an unsupported frame type is received. */ suspend fun receive(): StoveWebSocketMessage? = session.incoming.receive().let { frame -> when (frame) { is Frame.Text -> StoveWebSocketMessage.Text(frame.readText()) is Frame.Binary -> StoveWebSocketMessage.Binary(frame.readBytes()) else -> null } } /** * Attempts to receive a text message with a timeout. * * @param timeout The maximum duration to wait for a message. * @return An [Option] containing the received text message, or [None] if the timeout * is reached or the connection is closed. */ suspend fun receiveTextWithTimeout(timeout: Duration = 5.seconds): Option = try { withTimeout(timeout) { receiveText().toOption() } } catch (_: TimeoutCancellationException) { None } /** * Attempts to receive a binary message with a timeout. * * @param timeout The maximum duration to wait for a message. * @return An [Option] containing the received binary data, or [None] if the timeout * is reached or the connection is closed. */ suspend fun receiveBinaryWithTimeout(timeout: Duration = 5.seconds): Option = try { withTimeout(timeout) { receiveBinary().toOption() } } catch (_: TimeoutCancellationException) { None } /** * Collects text messages from the WebSocket connection. * * @param count The number of messages to collect. * @param timeout The maximum duration to wait for all messages. * @return A list of received text messages. */ suspend fun collectTexts( count: Int, timeout: Duration = 30.seconds ): List = withTimeout(timeout) { val messages = mutableListOf() repeat(count) { receiveText()?.let { messages.add(it) } } messages } /** * Collects binary messages from the WebSocket connection. * * @param count The number of messages to collect. * @param timeout The maximum duration to wait for all messages. * @return A list of received binary data. */ suspend fun collectBinaries( count: Int, timeout: Duration = 30.seconds ): List = withTimeout(timeout) { val messages = mutableListOf() repeat(count) { receiveBinary()?.let { messages.add(it) } } messages } /** * Creates a Flow of incoming text messages. * * The flow will emit messages until the WebSocket connection is closed. * * @return A [Flow] of text messages. */ fun incomingTexts(): Flow = session.incoming .receiveAsFlow() .filterIsInstance() .map { it.readText() } /** * Creates a Flow of incoming binary messages. * * The flow will emit messages until the WebSocket connection is closed. * * @return A [Flow] of binary data. */ fun incomingBinaries(): Flow = session.incoming .receiveAsFlow() .filterIsInstance() .map { it.readBytes() } /** * Creates a Flow of all incoming messages as [StoveWebSocketMessage]. * * The flow will emit messages until the WebSocket connection is closed. * * @return A [Flow] of [StoveWebSocketMessage]. */ fun incoming(): Flow = session.incoming .receiveAsFlow() .mapNotNull { frame -> when (frame) { is Frame.Text -> StoveWebSocketMessage.Text(frame.readText()) is Frame.Binary -> StoveWebSocketMessage.Binary(frame.readBytes()) else -> null } } /** * Closes the WebSocket connection gracefully. * * @param reason Optional close reason message. */ suspend fun close(reason: String = "Test completed") { session.close(CloseReason(CloseReason.Codes.NORMAL, reason)) } /** * Provides access to the underlying Ktor WebSocket session for advanced use cases. * * @param block The block to execute with the underlying session. * @return The result of the block. */ suspend fun underlyingSession( block: suspend DefaultClientWebSocketSession.() -> T ): T = block(session) } ================================================ FILE: lib/stove-http/src/test/kotlin/com/trendyol/stove/http/HttpSystemTests.kt ================================================ package com.trendyol.stove.http import arrow.core.* import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.matching.MultipartValuePattern import com.trendyol.stove.ConsoleSpec import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.HttpSystem.Companion.client import com.trendyol.stove.system.PortFinder import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import com.trendyol.stove.wiremock.* import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.string.shouldContain import io.ktor.client.request.* import io.ktor.http.* import kotlinx.coroutines.flow.toList import java.time.Instant import java.util.* class NoApplication : ApplicationUnderTest { override suspend fun start(configurations: List) { // do nothing } override suspend fun stop() { // do nothing } } private val WIREMOCK_PORT = PortFinder.findAvailablePort() class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$WIREMOCK_PORT" ) } wiremock { WireMockSystemOptions(WIREMOCK_PORT) } applicationUnderTest(NoApplication()) }.run() override suspend fun afterProject(): Unit = com.trendyol.stove.system.Stove .stop() } class HttpSystemTests : FunSpec({ test("DELETE and expect bodiless response") { stove { wiremock { mockDelete("/delete-success", statusCode = 200) mockDelete("/delete-fail", statusCode = 400) } http { deleteAndExpectBodilessResponse("/delete-success", None) { actual -> actual.status shouldBe 200 } deleteAndExpectBodilessResponse("/delete-fail", None) { actual -> actual.status shouldBe 400 } } } } test("PUT and expect bodiless/JSON response") { val expectedPutDtoName = UUID.randomUUID().toString() stove { wiremock { mockPut("/put-with-response-body", 200, None, responseBody = TestDto(expectedPutDtoName).some()) mockPut("/put-without-response-body", 200, None, responseBody = None) } http { putAndExpectBodilessResponse("/put-without-response-body", None, None) { actual -> actual.status shouldBe 200 } putAndExpectJson("/put-with-response-body") { actual -> actual.name shouldBe expectedPutDtoName } } } } test("POST and expect bodiless/JSON response") { val expectedPOSTDtoName = UUID.randomUUID().toString() stove { wiremock { mockPost("/post-with-response-body", 200, None, responseBody = TestDto(expectedPOSTDtoName).some()) mockPost("/post-without-response-body", 200, None, responseBody = None) } http { postAndExpectBodilessResponse("/post-without-response-body", None, None) { actual -> actual.status shouldBe 200 } postAndExpectJson("/post-with-response-body") { actual -> actual.name shouldBe expectedPOSTDtoName } } } } test("PATCH and expect bodiless/JSON response") { val expectedPatchDtoName = UUID.randomUUID().toString() stove { wiremock { mockPatch("/patch-with-response-body", 200, None, responseBody = TestDto(expectedPatchDtoName).some()) mockPatch("/patch-without-response-body", 200, None, responseBody = None) } http { patchAndExpectBodilessResponse("/patch-without-response-body", None, None) { actual -> actual.status shouldBe 200 } patchAndExpectJson("/patch-with-response-body") { actual -> actual.name shouldBe expectedPatchDtoName } } } } test("GET and expect JSON response") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockGet("/get", 200, responseBody = TestDto(expectedGetDtoName).some()) mockGet("/get-many", 200, responseBody = listOf(TestDto(expectedGetDtoName)).some()) } http { get("/get") { actual -> actual.name shouldBe expectedGetDtoName } getMany("/get-many") { actual -> actual[0] shouldBe TestDto(expectedGetDtoName) } get>("/get-many") { actual -> actual[0] shouldBe TestDto(expectedGetDtoName) } } } } test("getResponse and expect body") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockGet("/get", 200, responseBody = TestDto(expectedGetDtoName).some()) } http { getResponse("/get") { actual -> actual.body().name shouldBe expectedGetDtoName } } } } test("getResponse and expect bodiless") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockGet("/get", 200, responseBody = TestDto(expectedGetDtoName).some()) } http { getBodilessResponse("/get") { actual -> actual.status shouldBe 200 actual::class shouldBe StoveHttpResponse.Bodiless::class } } } } test("put and expect body") { val expectedPutDtoName = UUID.randomUUID().toString() stove { wiremock { mockPut("/put-with-response-body", 200, None, responseBody = TestDto(expectedPutDtoName).some()) } http { putAndExpectBody("/put-with-response-body") { actual -> actual.body().name shouldBe expectedPutDtoName } } } } test("post and expect body") { val expectedPostDtoName = UUID.randomUUID().toString() stove { wiremock { mockPost("/post-with-response-body", 200, None, responseBody = TestDto(expectedPostDtoName).some()) } http { postAndExpectBody("/post-with-response-body") { actual -> actual.body().name shouldBe expectedPostDtoName } } } } test("patch and expect body") { val expectedPatchDtoName = UUID.randomUUID().toString() stove { wiremock { mockPatch("/patch-with-response-body", 200, None, responseBody = TestDto(expectedPatchDtoName).some()) } http { patchAndExpectBody("/patch-with-response-body") { actual -> actual.body().name shouldBe expectedPatchDtoName } } } } test("get with query params should work") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockGet("/get?param=1", 200, responseBody = TestDto(expectedGetDtoName).some()) } http { get("/get", queryParams = mapOf("param" to "1")) { actual -> actual.name shouldBe expectedGetDtoName } } } } test("multipart post should work") { val expectedPostDtoName = UUID.randomUUID().toString() stove { wiremock { mockPostConfigure("/post-with-multipart") { req, _ -> req.withMultipartRequestBody( aMultipart() .matchingType(MultipartValuePattern.MatchingType.ANY) .withHeader("Content-Disposition", equalTo("form-data; name=name")) .withBody(equalTo(expectedPostDtoName)) ) req.withMultipartRequestBody( aMultipart() .matchingType(MultipartValuePattern.MatchingType.ANY) .withHeader("Content-Disposition", equalTo("form-data; name=file; filename=file.png")) .withBody(equalTo("file")) ) req.willReturn(aResponse().withStatus(200).withBody("hoi!")) } } http { postMultipartAndExpectResponse( "/post-with-multipart", body = listOf( StoveMultiPartContent.Text("name", expectedPostDtoName), StoveMultiPartContent.File( param = "file", fileName = "file.png", content = "file".toByteArray(), contentType = "application/octet-stream" ) ) ) { actual -> actual.body() shouldBe "hoi!" actual.status shouldBe 200 } } } } test("java time instant should work") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockGet("/get", 200, responseBody = TestDtoWithInstant(expectedGetDtoName, Instant.now()).some()) } http { get("/get") { actual -> actual.name shouldBe expectedGetDtoName } } } } test("keep path segments as is") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockGet("/get?path=1", 200, responseBody = TestDto(expectedGetDtoName).some()) } http { get("/get?path=1") { actual -> actual.name shouldBe expectedGetDtoName } client() shouldNotBe null client { baseUrl -> val resp = get( baseUrl .apply { path("/get") parameters.append("path", "1") }.build() ) resp.status shouldBe HttpStatusCode.OK } } } } test("behavioural tests") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { behaviourFor("/get-behaviour", WireMock::get) { initially { aResponse() .withStatus(503) .withBody("Service unavailable") } then { aResponse() .withHeader("Content-Type", "application/json") .withStatus(200) .withBody(it.serialize(TestDto(expectedGetDtoName))) } } } http { this.getResponse("/get-behaviour") { actual -> actual.status shouldBe 503 } get("/get-behaviour") { actual -> actual.name shouldBe expectedGetDtoName } } } } test("if there is no initial step, can not place `then`") { stove { wiremock { behaviourFor("/get-behaviour", WireMock::get) { shouldThrow { then { aResponse() .withHeader("Content-Type", "application/json") .withStatus(200) .withBody(it.serialize(TestDto(UUID.randomUUID().toString()))) } } } } } } test("should only call initially once") { stove { wiremock { behaviourFor("/get-behaviour", WireMock::get) { initially { aResponse() .withStatus(503) .withBody("Service unavailable") } shouldThrow { initially { aResponse() .withStatus(503) .withBody("Service unavailable") } } } } } } test("serialize to application/x-ndjson") { val expectedGetDtoName = UUID.randomUUID().toString() val items = (1..10).map { TestDto(expectedGetDtoName) } stove { wiremock { mockGetConfigure("/get-ndjson") { builder, serde -> builder.willReturn( aResponse() .withHeader("Content-Type", "application/x-ndjson") .withBody(serde.serializeToStreamJson(items)) ) } } http { readJsonStream("/get-ndjson") { actual -> val collected = actual.toList() collected.size shouldBe 10 collected.forEach { it.name shouldBe expectedGetDtoName } } } } } test("get with headers") { val expectedGetDtoName = UUID.randomUUID().toString() val headers = mapOf("Custom-Header" to "CustomValue") stove { wiremock { mockGet("/get?param=1", 200, responseBody = TestDto(expectedGetDtoName).some(), responseHeaders = headers) } http { getResponse("/get", queryParams = mapOf("param" to "1")) { actual -> actual.body().name shouldBe expectedGetDtoName actual.headers["custom-header"] shouldBe listOf("CustomValue") } } } } }) class HttpConsoleTesting : ConsoleSpec({ capturedOutput -> test("should return error when request bodies do not match") { val expectedGetDtoName = UUID.randomUUID().toString() stove { wiremock { mockPost("/post-with-response-body", 200, requestBody = TestDto("lol").some(), responseBody = TestDto(expectedGetDtoName).some()) } shouldThrow { http { postAndExpectJson("/post-with-response-body2", body = TestDto("no-match").some()) { actual -> actual.name shouldBe expectedGetDtoName } } } capturedOutput.out shouldContain "[equalToJson] | <<<<< Body does not match\n" } } }) data class TestDto( val name: String ) data class TestDtoWithInstant( val name: String, val instant: Instant ) ================================================ FILE: lib/stove-http/src/test/kotlin/com/trendyol/stove/http/WebSocketTests.kt ================================================ package com.trendyol.stove.http import arrow.core.some import com.trendyol.stove.system.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import io.kotest.matchers.collections.shouldHaveSize import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlin.time.Duration.Companion.seconds private const val WS_PORT = 9877 /** * Application under test that runs a simple WebSocket echo server. */ class WebSocketEchoServer : ApplicationUnderTest { private lateinit var server: EmbeddedServer override suspend fun start(configurations: List) { server = embeddedServer(Netty, port = WS_PORT) { install(WebSockets) { pingPeriodMillis = 15_000 timeoutMillis = 15_000 } routing { // Echo endpoint - echoes back whatever is sent webSocket("/echo") { for (frame in incoming) { when (frame) { is Frame.Text -> { val text = frame.readText() send(Frame.Text("Echo: $text")) } is Frame.Binary -> { val bytes = frame.readBytes() send(Frame.Binary(true, bytes)) } else -> {} } } } // Broadcast endpoint - sends multiple messages webSocket("/broadcast") { for (i in 1..5) { send(Frame.Text("Message $i")) delay(10) } close(CloseReason(CloseReason.Codes.NORMAL, "Broadcast complete")) } // Auth endpoint - checks for authorization header webSocket("/secure") { val token = call.request.headers["Authorization"] if (token != null && token.startsWith("Bearer ")) { send(Frame.Text("Authenticated: ${token.substringAfter("Bearer ")}")) } else { send(Frame.Text("Unauthorized")) } close(CloseReason(CloseReason.Codes.NORMAL, "Auth check complete")) } // Binary endpoint - echoes binary data webSocket("/binary") { for (frame in incoming) { when (frame) { is Frame.Binary -> { val bytes = frame.readBytes() send(Frame.Binary(true, bytes.reversedArray())) } else -> {} } } } } } server.start(wait = false) delay(500) } override suspend fun stop() { server.stop(1000, 2000) } } class WebSocketTests : FunSpec({ lateinit var wsStove: Stove beforeSpec { wsStove = Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$WS_PORT" ) } applicationUnderTest(WebSocketEchoServer()) } wsStove.run() } afterSpec { wsStove.close() } test("should send and receive text messages via WebSocket") { stove { http { webSocket("/echo") { send("Hello, WebSocket!") val response = receiveText() response shouldBe "Echo: Hello, WebSocket!" } } } } test("should send and receive multiple messages") { stove { http { webSocket("/echo") { send("First") receiveText() shouldBe "Echo: First" send("Second") receiveText() shouldBe "Echo: Second" send("Third") receiveText() shouldBe "Echo: Third" } } } } test("should collect multiple messages from broadcast endpoint") { stove { http { webSocket("/broadcast") { val messages = collectTexts(count = 5, timeout = 10.seconds) messages shouldHaveSize 5 messages[0] shouldBe "Message 1" messages[4] shouldBe "Message 5" } } } } test("should handle authentication via headers") { stove { http { webSocket( uri = "/secure", token = "my-secret-token".some() ) { val response = receiveText() response shouldBe "Authenticated: my-secret-token" } } } } test("should handle custom headers") { stove { http { webSocket( uri = "/secure", headers = mapOf("Authorization" to "Bearer custom-token") ) { val response = receiveText() response shouldBe "Authenticated: custom-token" } } } } test("should send and receive binary data") { stove { http { webSocket("/binary") { val data = byteArrayOf(1, 2, 3, 4, 5) send(data) val response = receiveBinary() response shouldBe byteArrayOf(5, 4, 3, 2, 1) } } } } test("should use StoveWebSocketMessage sealed class") { stove { http { webSocket("/echo") { send(StoveWebSocketMessage.Text("Using sealed class")) val response = receive() response shouldBe StoveWebSocketMessage.Text("Echo: Using sealed class") } } } } test("should use incoming flow for streaming messages") { stove { http { webSocket("/broadcast") { val messages = incomingTexts() .take(3) .toList() messages shouldHaveSize 3 messages[0] shouldBe "Message 1" messages[1] shouldBe "Message 2" messages[2] shouldBe "Message 3" } } } } test("should receive text with timeout") { stove { http { webSocket("/echo") { send("Quick message") val response = receiveTextWithTimeout(5.seconds) response.isSome() shouldBe true response.getOrNull() shouldBe "Echo: Quick message" } } } } test("webSocketExpect should work as alias") { stove { http { webSocketExpect("/echo") { send("Test") receiveText() shouldBe "Echo: Test" } } } } test("webSocketRaw should provide access to underlying session") { stove { http { webSocketRaw("/echo") { send(Frame.Text("Raw frame")) val frame = incoming.receive() (frame as Frame.Text).readText() shouldBe "Echo: Raw frame" } } } } test("should properly close connection") { stove { http { webSocket("/echo") { send("Before close") receiveText() shouldBe "Echo: Before close" close("Test completed") } } } } test("should access underlying session for advanced operations") { stove { http { webSocket("/echo") { underlyingSession { send(Frame.Text("Advanced")) val frame = incoming.receive() (frame as Frame.Text).readText() shouldBe "Echo: Advanced" } } } } } test("should send StoveWebSocketMessage.Binary and receive binary response") { stove { http { webSocket("/binary") { val data = byteArrayOf(10, 20, 30) send(StoveWebSocketMessage.Binary(data)) val response = receiveBinary() response shouldBe byteArrayOf(30, 20, 10) } } } } test("should receive binary with timeout") { stove { http { webSocket("/binary") { send(byteArrayOf(1, 2, 3)) val response = receiveBinaryWithTimeout(5.seconds) response.isSome() shouldBe true response.getOrNull() shouldBe byteArrayOf(3, 2, 1) } } } } test("should collect multiple binary messages") { stove { http { webSocket("/binary") { repeat(3) { i -> send(byteArrayOf((i + 1).toByte())) } val responses = collectBinaries(count = 3, timeout = 10.seconds) responses shouldHaveSize 3 responses[0] shouldBe byteArrayOf(1) responses[1] shouldBe byteArrayOf(2) responses[2] shouldBe byteArrayOf(3) } } } } test("should use incoming flow for binary streaming") { stove { http { webSocket("/binary") { repeat(3) { i -> send(byteArrayOf((i + 10).toByte())) } val messages = incomingBinaries() .take(3) .toList() messages shouldHaveSize 3 } } } } test("should use generic incoming flow for mixed messages") { stove { http { webSocket("/echo") { send("Test message") val messages = incoming() .take(1) .toList() messages shouldHaveSize 1 (messages[0] is StoveWebSocketMessage.Text) shouldBe true (messages[0] as StoveWebSocketMessage.Text).content shouldBe "Echo: Test message" } } } } }) class StoveWebSocketMessageTests : FunSpec({ test("Binary equals should return true for same content") { val a = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) val b = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) (a == b) shouldBe true } test("Binary equals should return false for different content") { val a = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) val b = StoveWebSocketMessage.Binary(byteArrayOf(4, 5, 6)) (a == b) shouldBe false } test("Binary equals should return true for same instance") { val a = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) (a == a) shouldBe true } @Suppress("EqualsNullCall") test("Binary equals should return false for different type") { val a = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) a.equals("not binary") shouldBe false } test("Binary hashCode should be consistent for same content") { val a = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) val b = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) a.hashCode() shouldBe b.hashCode() } test("Binary hashCode should differ for different content") { val a = StoveWebSocketMessage.Binary(byteArrayOf(1, 2, 3)) val b = StoveWebSocketMessage.Binary(byteArrayOf(4, 5, 6)) (a.hashCode() != b.hashCode()) shouldBe true } test("Text should have correct content") { val msg = StoveWebSocketMessage.Text("hello") msg.content shouldBe "hello" } }) class WebSocketUrlBuildingTests : FunSpec({ test("should convert http to ws") { val testSystem = Stove() testSystem.with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } } val httpSystem = testSystem.getOrNone().getOrNull()!! httpSystem.buildWebSocketUrl("/chat") shouldBe "ws://localhost:8080/chat" } test("should convert https to wss") { val testSystem = Stove() testSystem.with { httpClient { HttpClientSystemOptions(baseUrl = "https://localhost:8080") } } val httpSystem = testSystem.getOrNone().getOrNull()!! httpSystem.buildWebSocketUrl("/chat") shouldBe "wss://localhost:8080/chat" } test("should handle uri without leading slash") { val testSystem = Stove() testSystem.with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") } } val httpSystem = testSystem.getOrNone().getOrNull()!! httpSystem.buildWebSocketUrl("chat") shouldBe "ws://localhost:8080/chat" } test("should handle baseUrl without protocol") { val testSystem = Stove() testSystem.with { httpClient { HttpClientSystemOptions(baseUrl = "localhost:8080") } } val httpSystem = testSystem.getOrNone().getOrNull()!! httpSystem.buildWebSocketUrl("/chat") shouldBe "ws://localhost:8080/chat" } }) ================================================ FILE: lib/stove-http/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.http.StoveConfig ================================================ FILE: lib/stove-http/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: lib/stove-kafka/api/stove-kafka.api ================================================ public final class com/trendyol/stove/kafka/AcknowledgedMessage : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/AcknowledgedMessage$Companion; public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;)Lcom/trendyol/stove/kafka/AcknowledgedMessage; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/AcknowledgedMessage;Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/AcknowledgedMessage; public fun equals (Ljava/lang/Object;)Z public final fun getException ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getOffset ()J public final fun getPartition ()I public final fun getTopic ()Ljava/lang/String; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/AcknowledgedMessage$Companion { } public final class com/trendyol/stove/kafka/Caching { public static final field INSTANCE Lcom/trendyol/stove/kafka/Caching; public final fun of ()Lcom/github/benmanes/caffeine/cache/Cache; } public final class com/trendyol/stove/kafka/CommittedMessage : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/CommittedMessage$Companion; public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;)Lcom/trendyol/stove/kafka/CommittedMessage; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/CommittedMessage;Ljava/lang/String;Ljava/lang/String;IJLjava/lang/String;Lokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/CommittedMessage; public fun equals (Ljava/lang/Object;)Z public final fun getId ()Ljava/lang/String; public final fun getMetadata ()Ljava/lang/String; public final fun getOffset ()J public final fun getPartition ()I public final fun getTopic ()Ljava/lang/String; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/CommittedMessage$Companion { } public final class com/trendyol/stove/kafka/CommittedRecord { public fun (Ljava/lang/String;Ljava/lang/String;JI)V public final fun getMetadata ()Ljava/lang/String; public final fun getOffset ()J public final fun getPartition ()I public final fun getTopic ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/ConsumedMessage : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/ConsumedMessage$Companion; public fun ()V public fun (Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;IJLjava/lang/String;Ljava/util/Map;Lokio/ByteString;)V public synthetic fun (Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;IJLjava/lang/String;Ljava/util/Map;Lokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;IJLjava/lang/String;Ljava/util/Map;Lokio/ByteString;)Lcom/trendyol/stove/kafka/ConsumedMessage; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/ConsumedMessage;Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;IJLjava/lang/String;Ljava/util/Map;Lokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/ConsumedMessage; public fun equals (Ljava/lang/Object;)Z public final fun getHeaders ()Ljava/util/Map; public final fun getId ()Ljava/lang/String; public final fun getKey ()Ljava/lang/String; public final fun getMessage ()Lokio/ByteString; public final fun getOffset ()J public final fun getPartition ()I public final fun getTopic ()Ljava/lang/String; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/ConsumedMessage$Companion { } public final class com/trendyol/stove/kafka/ConsumedRecord { public fun (Ljava/lang/String;Ljava/lang/String;[BLjava/util/Map;JI)V public final fun getHeaders ()Ljava/util/Map; public final fun getKey ()Ljava/lang/String; public final fun getOffset ()J public final fun getPartition ()I public final fun getTopic ()Ljava/lang/String; public final fun getValue ()[B } public final class com/trendyol/stove/kafka/CoroutinesKt { public static final fun getAsExecutor (Lkotlinx/coroutines/CoroutineScope;)Ljava/util/concurrent/Executor; public static final fun getAsExecutorService (Lkotlinx/coroutines/CoroutineScope;)Ljava/util/concurrent/ExecutorService; } public final class com/trendyol/stove/kafka/EmbeddedKafkaRuntime : com/trendyol/stove/system/abstractions/SystemRuntime { public static final field INSTANCE Lcom/trendyol/stove/kafka/EmbeddedKafkaRuntime; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/ExtensionsKt { public static final fun metadata (Lcom/trendyol/stove/kafka/ConsumedMessage;)Lcom/trendyol/stove/messaging/MessageMetadata; public static final fun metadata (Lcom/trendyol/stove/kafka/PublishedMessage;)Lcom/trendyol/stove/messaging/MessageMetadata; public static final fun toProperties (Ljava/util/Map;)Ljava/util/Properties; } public final class com/trendyol/stove/kafka/GrpcStoveKafkaObserverServiceClient : com/trendyol/stove/kafka/StoveKafkaObserverServiceClient { public fun (Lcom/squareup/wire/GrpcClient;)V public fun healthCheck ()Lcom/squareup/wire/GrpcCall; public fun onAcknowledgedMessage ()Lcom/squareup/wire/GrpcCall; public fun onCommittedMessage ()Lcom/squareup/wire/GrpcCall; public fun onConsumedMessage ()Lcom/squareup/wire/GrpcCall; public fun onPublishedMessage ()Lcom/squareup/wire/GrpcCall; } public final class com/trendyol/stove/kafka/HealthCheckRequest : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/HealthCheckRequest$Companion; public fun ()V public fun (Ljava/lang/String;Lokio/ByteString;)V public synthetic fun (Ljava/lang/String;Lokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;Lokio/ByteString;)Lcom/trendyol/stove/kafka/HealthCheckRequest; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/HealthCheckRequest;Ljava/lang/String;Lokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/HealthCheckRequest; public fun equals (Ljava/lang/Object;)Z public final fun getService ()Ljava/lang/String; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/HealthCheckRequest$Companion { } public final class com/trendyol/stove/kafka/HealthCheckResponse : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/HealthCheckResponse$Companion; public fun ()V public fun (Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus;Lokio/ByteString;)V public synthetic fun (Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus;Lokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus;Lokio/ByteString;)Lcom/trendyol/stove/kafka/HealthCheckResponse; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/HealthCheckResponse;Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus;Lokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/HealthCheckResponse; public fun equals (Ljava/lang/Object;)Z public final fun getStatus ()Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/HealthCheckResponse$Companion { } public final class com/trendyol/stove/kafka/HealthCheckResponse$ServingStatus : java/lang/Enum, com/squareup/wire/WireEnum { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus$Companion; public static final field NOT_SERVING Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public static final field SERVICE_UNKNOWN Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public static final field SERVING Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public static final field UNKNOWN Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public static final fun fromValue (I)Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public static fun getEntries ()Lkotlin/enums/EnumEntries; public fun getValue ()I public static fun valueOf (Ljava/lang/String;)Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; public static fun values ()[Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; } public final class com/trendyol/stove/kafka/HealthCheckResponse$ServingStatus$Companion { public final fun fromValue (I)Lcom/trendyol/stove/kafka/HealthCheckResponse$ServingStatus; } public final class com/trendyol/stove/kafka/KafkaContainerOptions : com/trendyol/stove/containers/ContainerOptions { public static final field Companion Lcom/trendyol/stove/kafka/KafkaContainerOptions$Companion; public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/util/List; public final fun component5 ()Ljava/lang/String; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/kafka/KafkaContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public final fun getPorts ()Ljava/util/List; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaContainerOptions$Companion { public final fun getDEFAULT_KAFKA_PORTS ()Ljava/util/List; } public final class com/trendyol/stove/kafka/KafkaContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public final fun component3 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;Ljava/lang/String;)Lcom/trendyol/stove/kafka/KafkaContext; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaContext; public fun equals (Ljava/lang/Object;)Z public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaContextKt { public static final fun kafka-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun kafka-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun kafka-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun kafka-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/kafka/KafkaExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getBootstrapServers ()Ljava/lang/String; public final fun getInterceptorClass ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaMigrationContext { public fun (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/kafka/KafkaSystemOptions;)V public final fun component1 ()Lorg/apache/kafka/clients/admin/Admin; public final fun component2 ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public final fun copy (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/kafka/KafkaMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaMigrationContext;Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getAdmin ()Lorg/apache/kafka/clients/admin/Admin; public final fun getOptions ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/AfterRunAware, com/trendyol/stove/system/abstractions/BeforeRunAware, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/kafka/KafkaSystem$Companion; public field sink Lcom/trendyol/stove/kafka/intercepting/StoveMessageSink; public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/kafka/KafkaContext;)V public final fun adminOperations (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun afterRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun assertKafkaMessage-WPi__2c (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun beforeRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun close ()V public fun configuration ()Ljava/util/List; public final fun consumer-IY8X1Ik (Ljava/lang/String;ZLjava/lang/String;ZLkotlin/jvm/functions/Function1;Lorg/apache/kafka/common/serialization/Deserializer;Lorg/apache/kafka/common/serialization/Deserializer;JJLjava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun consumer-IY8X1Ik$default (Lcom/trendyol/stove/kafka/KafkaSystem;Ljava/lang/String;ZLjava/lang/String;ZLkotlin/jvm/functions/Function1;Lorg/apache/kafka/common/serialization/Deserializer;Lorg/apache/kafka/common/serialization/Deserializer;JJLjava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public final fun getSink ()Lcom/trendyol/stove/kafka/intercepting/StoveMessageSink; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun messageStore ()Lcom/trendyol/stove/kafka/intercepting/MessageStore; public final fun pause ()Lcom/trendyol/stove/kafka/KafkaSystem; public final fun peekCommittedMessages-rnQQ1Ag (JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun peekCommittedMessages-rnQQ1Ag$default (Lcom/trendyol/stove/kafka/KafkaSystem;JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun peekConsumedMessages-rnQQ1Ag (JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun peekConsumedMessages-rnQQ1Ag$default (Lcom/trendyol/stove/kafka/KafkaSystem;JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun peekPublishedMessages-rnQQ1Ag (JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun peekPublishedMessages-rnQQ1Ag$default (Lcom/trendyol/stove/kafka/KafkaSystem;JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun publish (Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Ljava/util/Map;ILarrow/core/Option;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun publish$default (Lcom/trendyol/stove/kafka/KafkaSystem;Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Ljava/util/Map;ILarrow/core/Option;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setSink (Lcom/trendyol/stove/kafka/intercepting/StoveMessageSink;)V public final fun shouldBeConsumedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBeFailedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBePublishedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBeRetriedInternal-WPwdCS8 (Lkotlin/reflect/KClass;JILkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause ()Lcom/trendyol/stove/kafka/KafkaSystem; } public final class com/trendyol/stove/kafka/KafkaSystem$Companion { } public final class com/trendyol/stove/kafka/KafkaSystemKt { public static final field STOVE_KAFKA_BRIDGE_PORT Ljava/lang/String; public static final fun getStoveKafkaBridgePortDefault ()Ljava/lang/String; public static final fun getStoveSerdeRef ()Lcom/trendyol/stove/serialization/StoveSerde; public static final fun setStoveKafkaBridgePortDefault (Ljava/lang/String;)V public static final fun setStoveSerdeRef (Lcom/trendyol/stove/serialization/StoveSerde;)V } public class com/trendyol/stove/kafka/KafkaSystemOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/kafka/KafkaSystemOptions$Companion; public fun (ZLcom/trendyol/stove/kafka/TopicSuffixes;ZILcom/trendyol/stove/serialization/StoveSerde;Lorg/apache/kafka/common/serialization/Serializer;Lcom/trendyol/stove/kafka/KafkaContainerOptions;Lkotlin/jvm/functions/Function2;Ljava/util/Map;Lkotlin/jvm/functions/Function1;)V public synthetic fun (ZLcom/trendyol/stove/kafka/TopicSuffixes;ZILcom/trendyol/stove/serialization/StoveSerde;Lorg/apache/kafka/common/serialization/Serializer;Lcom/trendyol/stove/kafka/KafkaContainerOptions;Lkotlin/jvm/functions/Function2;Ljava/util/Map;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getBridgeGrpcServerPort ()I public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainerOptions ()Lcom/trendyol/stove/kafka/KafkaContainerOptions; public fun getListenPublishedMessagesFromStove ()Z public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getProperties ()Ljava/util/Map; public fun getSerde ()Lcom/trendyol/stove/serialization/StoveSerde; public fun getTopicSuffixes ()Lcom/trendyol/stove/kafka/TopicSuffixes; public fun getUseEmbeddedKafka ()Z public fun getValueSerializer ()Lorg/apache/kafka/common/serialization/Serializer; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/kafka/KafkaSystemOptions; } public final class com/trendyol/stove/kafka/KafkaSystemOptions$Companion { public final fun provided (Ljava/lang/String;Lcom/trendyol/stove/kafka/TopicSuffixes;ZILcom/trendyol/stove/serialization/StoveSerde;Lorg/apache/kafka/common/serialization/Serializer;Ljava/util/Map;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/kafka/ProvidedKafkaSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/kafka/KafkaSystemOptions$Companion;Ljava/lang/String;Lcom/trendyol/stove/kafka/TopicSuffixes;ZILcom/trendyol/stove/serialization/StoveSerde;Lorg/apache/kafka/common/serialization/Serializer;Ljava/util/Map;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/ProvidedKafkaSystemOptions; } public final class com/trendyol/stove/kafka/ProvidedKafkaSystemOptions : com/trendyol/stove/kafka/KafkaSystemOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/kafka/KafkaExposedConfiguration;Lcom/trendyol/stove/kafka/TopicSuffixes;ZILcom/trendyol/stove/serialization/StoveSerde;Lorg/apache/kafka/common/serialization/Serializer;Ljava/util/Map;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/kafka/KafkaExposedConfiguration;Lcom/trendyol/stove/kafka/TopicSuffixes;ZILcom/trendyol/stove/serialization/StoveSerde;Lorg/apache/kafka/common/serialization/Serializer;Ljava/util/Map;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public final class com/trendyol/stove/kafka/PublishedMessage : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/PublishedMessage$Companion; public fun ()V public fun (Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lokio/ByteString;)V public synthetic fun (Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lokio/ByteString;)Lcom/trendyol/stove/kafka/PublishedMessage; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/PublishedMessage;Ljava/lang/String;Lokio/ByteString;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/PublishedMessage; public fun equals (Ljava/lang/Object;)Z public final fun getHeaders ()Ljava/util/Map; public final fun getId ()Ljava/lang/String; public final fun getKey ()Ljava/lang/String; public final fun getMessage ()Lokio/ByteString; public final fun getTopic ()Ljava/lang/String; public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/PublishedMessage$Companion { } public final class com/trendyol/stove/kafka/PublishedRecord { public fun (Ljava/lang/String;Ljava/lang/String;[BLjava/util/Map;)V public final fun getHeaders ()Ljava/util/Map; public final fun getKey ()Ljava/lang/String; public final fun getTopic ()Ljava/lang/String; public final fun getValue ()[B } public final class com/trendyol/stove/kafka/Reply : com/squareup/wire/Message { public static final field ADAPTER Lcom/squareup/wire/ProtoAdapter; public static final field Companion Lcom/trendyol/stove/kafka/Reply$Companion; public fun ()V public fun (ILokio/ByteString;)V public synthetic fun (ILokio/ByteString;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun copy (ILokio/ByteString;)Lcom/trendyol/stove/kafka/Reply; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/Reply;ILokio/ByteString;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/Reply; public fun equals (Ljava/lang/Object;)Z public final fun getStatus ()I public fun hashCode ()I public synthetic fun newBuilder ()Lcom/squareup/wire/Message$Builder; public synthetic fun newBuilder ()Ljava/lang/Void; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/Reply$Companion { } public class com/trendyol/stove/kafka/StoveKafkaContainer : org/testcontainers/kafka/ConfluentKafkaContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public abstract interface class com/trendyol/stove/kafka/StoveKafkaObserverServiceClient : com/squareup/wire/Service { public abstract fun healthCheck ()Lcom/squareup/wire/GrpcCall; public abstract fun onAcknowledgedMessage ()Lcom/squareup/wire/GrpcCall; public abstract fun onCommittedMessage ()Lcom/squareup/wire/GrpcCall; public abstract fun onConsumedMessage ()Lcom/squareup/wire/GrpcCall; public abstract fun onPublishedMessage ()Lcom/squareup/wire/GrpcCall; } public abstract interface class com/trendyol/stove/kafka/StoveKafkaObserverServiceServer : com/squareup/wire/Service { public abstract fun healthCheck (Lcom/trendyol/stove/kafka/HealthCheckRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onAcknowledgedMessage (Lcom/trendyol/stove/kafka/AcknowledgedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onCommittedMessage (Lcom/trendyol/stove/kafka/CommittedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onConsumedMessage (Lcom/trendyol/stove/kafka/ConsumedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onPublishedMessage (Lcom/trendyol/stove/kafka/PublishedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc { public static final field INSTANCE Lcom/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc; public static final field SERVICE_NAME Ljava/lang/String; public final fun getServiceDescriptor ()Lio/grpc/ServiceDescriptor; public final fun gethealthCheckMethod ()Lio/grpc/MethodDescriptor; public final fun getonAcknowledgedMessageMethod ()Lio/grpc/MethodDescriptor; public final fun getonCommittedMessageMethod ()Lio/grpc/MethodDescriptor; public final fun getonConsumedMessageMethod ()Lio/grpc/MethodDescriptor; public final fun getonPublishedMessageMethod ()Lio/grpc/MethodDescriptor; public final fun newStub (Lio/grpc/Channel;)Lcom/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceStub; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$BindableAdapter : com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase { public fun (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;)V public synthetic fun (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lkotlin/jvm/functions/Function0;)V public fun healthCheck (Lcom/trendyol/stove/kafka/HealthCheckRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onAcknowledgedMessage (Lcom/trendyol/stove/kafka/AcknowledgedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onCommittedMessage (Lcom/trendyol/stove/kafka/CommittedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onConsumedMessage (Lcom/trendyol/stove/kafka/ConsumedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onPublishedMessage (Lcom/trendyol/stove/kafka/PublishedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase : com/squareup/wire/kotlin/grpcserver/WireBindableService { public fun ()V public fun (Lkotlin/coroutines/CoroutineContext;)V public synthetic fun (Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun bindService ()Lio/grpc/ServerServiceDefinition; protected final fun getContext ()Lkotlin/coroutines/CoroutineContext; public fun healthCheck (Lcom/trendyol/stove/kafka/HealthCheckRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onAcknowledgedMessage (Lcom/trendyol/stove/kafka/AcknowledgedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onCommittedMessage (Lcom/trendyol/stove/kafka/CommittedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onConsumedMessage (Lcom/trendyol/stove/kafka/ConsumedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onPublishedMessage (Lcom/trendyol/stove/kafka/PublishedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$AcknowledgedMessageMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/AcknowledgedMessage; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/AcknowledgedMessage;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$CommittedMessageMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/CommittedMessage; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/CommittedMessage;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$ConsumedMessageMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/ConsumedMessage; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/ConsumedMessage;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$HealthCheckRequestMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/HealthCheckRequest; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/HealthCheckRequest;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$HealthCheckResponseMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/HealthCheckResponse; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/HealthCheckResponse;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$PublishedMessageMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/PublishedMessage; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/PublishedMessage;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase$ReplyMarshaller : com/squareup/wire/kotlin/grpcserver/WireMethodMarshaller { public fun ()V public fun marshalledClass ()Ljava/lang/Class; public fun parse (Ljava/io/InputStream;)Lcom/trendyol/stove/kafka/Reply; public synthetic fun parse (Ljava/io/InputStream;)Ljava/lang/Object; public fun stream (Lcom/trendyol/stove/kafka/Reply;)Ljava/io/InputStream; public synthetic fun stream (Ljava/lang/Object;)Ljava/io/InputStream; } public final class com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceStub : io/grpc/kotlin/AbstractCoroutineStub { public synthetic fun build (Lio/grpc/Channel;Lio/grpc/CallOptions;)Lio/grpc/stub/AbstractStub; public final fun healthCheck (Lcom/trendyol/stove/kafka/HealthCheckRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun onAcknowledgedMessage (Lcom/trendyol/stove/kafka/AcknowledgedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun onCommittedMessage (Lcom/trendyol/stove/kafka/CommittedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun onConsumedMessage (Lcom/trendyol/stove/kafka/ConsumedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun onPublishedMessage (Lcom/trendyol/stove/kafka/PublishedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/kafka/StoveKafkaValueDeserializer : org/apache/kafka/common/serialization/Deserializer { public fun ()V public fun deserialize (Ljava/lang/String;[B)Ljava/lang/Object; } public final class com/trendyol/stove/kafka/StoveKafkaValueSerializer : org/apache/kafka/common/serialization/Serializer { public fun ()V public fun serialize (Ljava/lang/String;Ljava/lang/Object;)[B } public final class com/trendyol/stove/kafka/TopicSuffixes { public fun ()V public fun (Ljava/util/List;Ljava/util/List;)V public synthetic fun (Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; public final fun component2 ()Ljava/util/List; public final fun copy (Ljava/util/List;Ljava/util/List;)Lcom/trendyol/stove/kafka/TopicSuffixes; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/TopicSuffixes;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/TopicSuffixes; public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ljava/util/List; public final fun getRetry ()Ljava/util/List; public fun hashCode ()I public final fun isErrorTopic (Ljava/lang/String;)Z public final fun isRetryTopic (Ljava/lang/String;)Z public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/intercepting/GrpcUtils { public static final field INSTANCE Lcom/trendyol/stove/kafka/intercepting/GrpcUtils; public final fun createClient (Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;)Lcom/trendyol/stove/kafka/StoveKafkaObserverServiceClient; } public final class com/trendyol/stove/kafka/intercepting/MessageStore { public fun ()V public final fun committedMessages ()Ljava/util/Collection; public final fun consumedMessages ()Ljava/util/Collection; public final fun failedMessages ()Ljava/util/Collection; public final fun publishedMessages ()Ljava/util/Collection; public final fun retriedMessages ()Ljava/util/Collection; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/intercepting/StoveKafkaBridge : org/apache/kafka/clients/consumer/ConsumerInterceptor, org/apache/kafka/clients/producer/ProducerInterceptor { public fun ()V public fun close ()V public fun configure (Ljava/util/Map;)V public fun onAcknowledgement (Lorg/apache/kafka/clients/producer/RecordMetadata;Ljava/lang/Exception;)V public fun onCommit (Ljava/util/Map;)V public fun onConsume (Lorg/apache/kafka/clients/consumer/ConsumerRecords;)Lorg/apache/kafka/clients/consumer/ConsumerRecords; public fun onSend (Lorg/apache/kafka/clients/producer/ProducerRecord;)Lorg/apache/kafka/clients/producer/ProducerRecord; } public final class com/trendyol/stove/kafka/intercepting/StoveKafkaObserverGrpcServer : com/trendyol/stove/kafka/StoveKafkaObserverServiceWireGrpc$StoveKafkaObserverServiceImplBase { public fun (Lcom/trendyol/stove/kafka/intercepting/StoveMessageSink;)V public fun healthCheck (Lcom/trendyol/stove/kafka/HealthCheckRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onAcknowledgedMessage (Lcom/trendyol/stove/kafka/AcknowledgedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onCommittedMessage (Lcom/trendyol/stove/kafka/CommittedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onConsumedMessage (Lcom/trendyol/stove/kafka/ConsumedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onPublishedMessage (Lcom/trendyol/stove/kafka/PublishedMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/kafka/intercepting/StoveMessageSink : com/trendyol/stove/kafka/intercepting/CommonOps, com/trendyol/stove/kafka/intercepting/MessageSinkOps { public fun (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/serialization/StoveSerde;Lcom/trendyol/stove/kafka/TopicSuffixes;)V public fun deserializeCatching-gIAlu-s ([BLkotlin/reflect/KClass;)Ljava/lang/Object; public fun dumpMessages ()Ljava/lang/String; public fun getAdminClient ()Lorg/apache/kafka/clients/admin/Admin; public fun getLogger ()Lorg/slf4j/Logger; public fun getSerde ()Lcom/trendyol/stove/serialization/StoveSerde; public fun getStore ()Lcom/trendyol/stove/kafka/intercepting/MessageStore; public fun getTopicSuffixes ()Lcom/trendyol/stove/kafka/TopicSuffixes; public final fun onMessageAcknowledged (Lcom/trendyol/stove/kafka/AcknowledgedMessage;)V public final fun onMessageCommitted (Lcom/trendyol/stove/kafka/CommittedMessage;)V public final fun onMessageConsumed (Lcom/trendyol/stove/kafka/ConsumedMessage;)V public final fun onMessagePublished (Lcom/trendyol/stove/kafka/PublishedMessage;)V public fun recordAcknowledgedMessage (Lcom/trendyol/stove/kafka/AcknowledgedMessage;)V public fun recordCommittedMessage (Lcom/trendyol/stove/kafka/CommittedMessage;)V public fun recordConsumed (Lcom/trendyol/stove/kafka/ConsumedMessage;)V public fun recordError (Lcom/trendyol/stove/kafka/ConsumedMessage;)V public fun recordPublishedMessage (Lcom/trendyol/stove/kafka/PublishedMessage;)V public fun recordRetry (Lcom/trendyol/stove/kafka/ConsumedMessage;)V public fun throwIfFailed (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;)V public fun throwIfRetried (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;)V public fun waitUntilConditionMet-WPwdCS8 (Lkotlin/jvm/functions/Function0;JLjava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun waitUntilConsumed-rnQQ1Ag (JLkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun waitUntilCount-dWUq8MI (Lkotlin/jvm/functions/Function1;JILkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun waitUntilFailed-rnQQ1Ag (JLkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun waitUntilPublished-rnQQ1Ag (JLkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun waitUntilRetried-gRj5Bb8 (JILkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } ================================================ FILE: lib/stove-kafka/build.gradle.kts ================================================ plugins { alias(libs.plugins.wire) } dependencies { api(projects.lib.stove) api(libs.testcontainers.kafka) api(libs.kafka) api(libs.kafka.embedded) implementation(libs.kotlinx.io.reactor.extensions) implementation(libs.kotlinx.jdk8) implementation(libs.kotlinx.core) implementation(libs.wire.grpc.server) implementation(libs.wire.grpc.client) implementation(libs.wire.grpc.runtime) implementation(libs.io.grpc) implementation(libs.io.grpc.protobuf) implementation(libs.io.grpc.stub) implementation(libs.io.grpc.kotlin) implementation(libs.io.grpc.netty) implementation(libs.google.protobuf.kotlin) implementation(libs.caffeine) implementation(libs.pprint) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) testImplementation(libs.kafkaKotlin) } buildscript { dependencies { classpath(libs.wire.grpc.server.generator) } } wire { sourcePath("src/main/proto") kotlin { rpcRole = "client" rpcCallStyle = "suspending" exclusive = false javaInterop = false } kotlin { custom { schemaHandlerFactory = com.squareup.wire.kotlin.grpcserver.GrpcServerSchemaHandler.Factory() options = mapOf( "singleMethodServices" to "false", "rpcCallStyle" to "suspending" ) } rpcRole = "server" rpcCallStyle = "suspending" exclusive = false singleMethodServices = false javaInterop = true includes = listOf("com.trendyol.stove.kafka.StoveKafkaObserverService") } } val testWithEmbedded = tasks.register("testWithEmbedded") { group = "verification" description = "Runs tests with embedded Kafka" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useEmbeddedKafka", "true") doFirst { println("Starting embedded Kafka tests...") } } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided Kafka instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting Kafka tests with provided instance...") } } tasks.test.configure { dependsOn(testWithEmbedded, testWithProvided) } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/Caching.kt ================================================ package com.trendyol.stove.kafka import com.github.benmanes.caffeine.cache.* object Caching { fun of(): Cache = Caffeine.newBuilder().build() } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/Extensions.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.messaging.MessageMetadata import org.apache.kafka.clients.producer.* import java.util.* import kotlin.coroutines.* fun Map.toProperties(): Properties = Properties().apply { this@toProperties.forEach { (k, v) -> this[k] = v } } fun ConsumedMessage.metadata(): MessageMetadata = MessageMetadata( topic = topic, key = key, headers = headers ) fun PublishedMessage.metadata(): MessageMetadata = MessageMetadata( topic = topic, key = key, headers = headers ) suspend inline fun KafkaProducer.dispatch( record: ProducerRecord ): RecordMetadata = suspendCoroutine { continuation -> val callback = Callback { metadata, exception -> if (exception != null) { continuation.resumeWithException(exception) } else { continuation.resume(metadata) } } send(record, callback) } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaContainerOptions.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.containers.* import org.testcontainers.kafka.ConfluentKafkaContainer import org.testcontainers.utility.DockerImageName open class StoveKafkaContainer( override val imageNameAccess: DockerImageName ) : ConfluentKafkaContainer(imageNameAccess), StoveContainer data class KafkaContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = "confluentinc/cp-kafka", override val tag: String = "latest", val ports: List = DEFAULT_KAFKA_PORTS, override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveKafkaContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions { companion object { val DEFAULT_KAFKA_PORTS = listOf(9092, 9093) } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaContext.kt ================================================ package com.trendyol.stove.kafka import arrow.core.getOrElse import com.trendyol.stove.containers.withProvidedRegistry import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl data class KafkaContext( val runtime: SystemRuntime, val options: KafkaSystemOptions, val keyName: String? = null ) internal fun Stove.kafka(): KafkaSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(KafkaSystem::class) } internal fun Stove.kafka(key: SystemKey): KafkaSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(KafkaSystem::class, "No KafkaSystem registered with key '${keyDisplayName(key)}'") } internal fun Stove.withKafka( options: KafkaSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(KafkaSystem(this, KafkaContext(runtime, options))) return this } internal fun Stove.withKafka( key: SystemKey, options: KafkaSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, KafkaSystem(this, KafkaContext(runtime, options, keyName = keyDisplayName(key)))) return this } suspend fun ValidationDsl.kafka( validation: @StoveDsl suspend KafkaSystem.() -> Unit ): Unit = validation(this.stove.kafka()) suspend fun ValidationDsl.kafka( key: SystemKey, validation: @StoveDsl suspend KafkaSystem.() -> Unit ): Unit = validation(this.stove.kafka(key)) /** * Configures Kafka system. * * For container-based setup: * ```kotlin * kafka { * KafkaSystemOptions( * cleanup = { admin -> admin.deleteTopics(...) }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For embedded Kafka: * ```kotlin * kafka { * KafkaSystemOptions( * useEmbeddedKafka = true, * cleanup = { admin -> admin.deleteTopics(...) }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * kafka { * KafkaSystemOptions.provided( * bootstrapServers = "localhost:9092", * cleanup = { admin -> admin.deleteTopics(...) }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.kafka( configure: () -> KafkaSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = when { options is ProvidedKafkaSystemOptions -> ProvidedRuntime options.useEmbeddedKafka -> EmbeddedKafkaRuntime else -> withProvidedRegistry( options.containerOptions.imageWithTag, options.containerOptions.registry, options.containerOptions.compatibleSubstitute ) { dockerImageName -> options.containerOptions .useContainerFn(dockerImageName) .withExposedPorts(*options.containerOptions.ports.toTypedArray()) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveKafkaContainer } .apply(options.containerOptions.containerFn) } } return stove.withKafka(options, runtime) } /** * Configures a keyed Kafka system for testing multiple Kafka instances. * * ```kotlin * Stove().with { * kafka(PaymentKafka) { * KafkaSystemOptions( * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * } * ``` * * @param key The [SystemKey] identifying this Kafka instance. * @param configure Configuration block returning [KafkaSystemOptions]. * @return The test system for fluent chaining. */ fun WithDsl.kafka( key: SystemKey, configure: () -> KafkaSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = when { options is ProvidedKafkaSystemOptions -> ProvidedRuntime options.useEmbeddedKafka -> EmbeddedKafkaRuntime else -> withProvidedRegistry( options.containerOptions.imageWithTag, options.containerOptions.registry, options.containerOptions.compatibleSubstitute ) { dockerImageName -> options.containerOptions .useContainerFn(dockerImageName) .withExposedPorts(*options.containerOptions.ports.toTypedArray()) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveKafkaContainer } .apply(options.containerOptions.containerFn) } } return stove.withKafka(key, options, runtime) } /** * Special runtime for embedded Kafka that doesn't use a container. */ data object EmbeddedKafkaRuntime : SystemRuntime ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaExposedConfiguration.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.system.abstractions.ExposedConfiguration data class KafkaExposedConfiguration( val bootstrapServers: String, val interceptorClass: String ) : ExposedConfiguration ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaSystem.kt ================================================ @file:Suppress("TooGenericExceptionCaught") package com.trendyol.stove.kafka import arrow.core.* import com.trendyol.stove.functional.* import com.trendyol.stove.kafka.intercepting.* import com.trendyol.stove.messaging.* import com.trendyol.stove.reporting.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import com.trendyol.stove.tracing.TraceContext import io.github.embeddedkafka.* import io.grpc.Server import io.grpc.netty.NettyServerBuilder import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.selects.* import org.apache.kafka.clients.admin.* import org.apache.kafka.clients.consumer.* import org.apache.kafka.clients.producer.* import org.apache.kafka.common.serialization.* import org.slf4j.* import scala.collection.immutable.`Map$` import java.net.* import java.util.* import kotlin.reflect.KClass import kotlin.time.* import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds var stoveSerdeRef: StoveSerde = StoveSerde.jackson.anyByteArraySerde() /** * Default port for the Stove Kafka Bridge gRPC server. * This can be overridden by setting the [STOVE_KAFKA_BRIDGE_PORT] environment variable * or by using [PortFinder.findAvailablePortAsString] to get a dynamically available port. */ var stoveKafkaBridgePortDefault: String = PortFinder.findAvailablePortAsString() const val STOVE_KAFKA_BRIDGE_PORT = "STOVE_KAFKA_BRIDGE_PORT" internal val StoveKafkaCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) /** * Kafka messaging system for testing message publishing and consumption. * * Provides a comprehensive DSL for testing Kafka-based messaging patterns: * - Publishing messages to topics * - Asserting messages are consumed by the application * - Asserting messages are published by the application * - Asserting message processing failures * * ## Publishing Messages * * ```kotlin * kafka { * // Publish a message to a topic * publish("orders.created", OrderCreatedEvent(orderId = "123", amount = 99.99)) * * // Publish with custom headers * publish( * topic = "orders.created", * message = event, * headers = mapOf("correlationId" to "abc-123") * ) * * // Publish with specific key * publish( * topic = "orders.created", * key = "customer-456", * message = event * ) * } * ``` * * ## Asserting Consumed Messages * * Verify your application consumed messages correctly: * * ```kotlin * kafka { * publish("orders.created", OrderCreatedEvent(orderId = "123")) * * // Assert the message was consumed * shouldBeConsumed { * actual.orderId == "123" * } * * // With custom timeout * shouldBeConsumed(atLeastIn = 30.seconds) { * actual.orderId == "123" * } * } * ``` * * ## Asserting Published Messages * * Verify your application published messages: * * ```kotlin * // Trigger action that publishes a message * http { * postAndExpectBodilessResponse("/orders", body = request.some()) { * it.status shouldBe 201 * } * } * * kafka { * // Assert message was published * shouldBePublished { * actual.orderId == request.id * } * * // Assert with header validation * shouldBePublished { * actual.orderId == request.id && * metadata.headers["X-Correlation-Id"] == correlationId * } * } * ``` * * ## Asserting Failed Messages * * Verify messages failed processing (for error handling tests): * * ```kotlin * kafka { * publish("orders.created", InvalidOrderEvent(orderId = "invalid")) * * shouldBeFailed { * actual.orderId == "invalid" * } * } * ``` * * ## Topic Management * * ```kotlin * kafka { * // Create topics * createTopics("orders.created", "orders.confirmed") * * // Delete topics * deleteTopics("orders.created") * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * kafka { * stoveKafkaObjectMapperRef = myObjectMapper * KafkaSystemOptions { * listOf( * "spring.kafka.bootstrap-servers=${it.bootstrapServers}", * "spring.kafka.consumer.group-id=test-group" * ) * } * } * } * ``` * * @property stove The parent test system. * @see KafkaSystemOptions * @see KafkaExposedConfiguration */ @Suppress("TooManyFunctions", "unused", "MagicNumber") @StoveDsl class KafkaSystem( override val stove: Stove, private val context: KafkaContext ) : PluggedSystem, ExposesConfiguration, RunAware, AfterRunAware, BeforeRunAware, Reports { override val reportSystemName: String = "Kafka" + (context.keyName?.let { " [$it]" } ?: "") override fun snapshot(): SystemSnapshot { val currentTestId = reporter.currentTestId() val store = sink.store val belongsToTest: (Map) -> Boolean = { headers -> val testId = headers[TraceContext.STOVE_TEST_ID_HEADER].toOption() testId.isNone() || testId.isSome { it == currentTestId } } val consumed = store.consumedMessages().filter { belongsToTest(it.headers) } val published = store.publishedMessages().filter { belongsToTest(it.headers) } val failed = store.failedMessages().filter { belongsToTest(it.headers) } val retried = store.retriedMessages().filter { belongsToTest(it.headers) } val topicPartitions = consumed.map { it.topic to it.partition }.toSet() val committed = store.committedMessages().filter { (it.topic to it.partition) in topicPartitions } return SystemSnapshot( system = reportSystemName, state = mapOf( "consumed" to consumed.map { it.toReportMap() }, "published" to published.map { it.toReportMap() }, "committed" to committed.map { it.toReportMap() }, "failed" to failed.map { it.toReportMap() }, "retried" to retried.map { it.toReportMap() } ), summary = listOf( "Consumed (this test)" to consumed.size, "Published (this test)" to published.size, "Committed (this test)" to committed.size, "Failed (this test)" to failed.size, "Retried (this test)" to retried.size ).joinToString("\n") { (label, count) -> "$label: $count" } ) } private fun ConsumedMessage.toReportMap(): Map = mapOf( "id" to id, "topic" to topic, "key" to key, "partition" to partition, "offset" to offset, "headers" to headers, "message" to String(message.toByteArray()) ) private fun PublishedMessage.toReportMap(): Map = mapOf( "id" to id, "topic" to topic, "key" to key, "headers" to headers, "message" to String(message.toByteArray()) ) private fun CommittedMessage.toReportMap(): Map = mapOf( "topic" to topic, "partition" to partition, "offset" to offset ) private lateinit var exposedConfiguration: KafkaExposedConfiguration private lateinit var adminClient: Admin private lateinit var kafkaPublisher: KafkaProducer private lateinit var grpcServer: Server @PublishedApi internal lateinit var sink: StoveMessageSink private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(context.keyName) override suspend fun beforeRun() { stoveSerdeRef = context.options.serde } override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() adminClient = createAdminClient(exposedConfiguration) kafkaPublisher = createPublisher(exposedConfiguration) sink = StoveMessageSink(adminClient, context.options.serde, context.options.topicSuffixes) grpcServer = startGrpcServer() runMigrationsIfNeeded() } override suspend fun afterRun() = Unit private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { context.options.migrationCollection.run(KafkaMigrationContext(adminClient, context.options)) } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedKafkaSystemOptions -> context.options.runMigrations context.runtime is StoveKafkaContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways context.runtime is EmbeddedKafkaRuntime -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } override suspend fun stop() { when (val runtime = context.runtime) { is ProvidedRuntime -> Unit is EmbeddedKafkaRuntime -> stopEmbeddedKafka() is StoveKafkaContainer -> runtime.stop() else -> throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } override fun close(): Unit = runBlocking { Try { context.options.cleanup(adminClient) grpcServer.shutdownNow() StoveKafkaCoroutineScope.cancel() kafkaPublisher.close() executeWithReuseCheck { stop() } } }.recover { logger.warn("got an error while stopping: ${it.message}") }.let { } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) suspend fun publish( topic: String, message: Any, key: Option = None, headers: Map = mapOf(), partition: Int = 0, testCase: Option = None ): KafkaSystem { report( action = "Publish to '$topic'", input = arrow.core.Some(message), metadata = buildMap { key.onSome { put("key", it) } put("headers", headers) put("partition", partition) } ) { val record = ProducerRecord(topic, partition, key.getOrNull(), message) headers.forEach { (k, v) -> record.headers().add(k, v.toByteArray()) } testCase.map { record.headers().add("testCase", it.toByteArray()) } injectTraceHeaders(record) kafkaPublisher.dispatch(record) } return this } private fun injectTraceHeaders(record: ProducerRecord) { TraceContext.current()?.let { ctx -> record.headers().add(TraceContext.TRACEPARENT_HEADER, ctx.toTraceparent().toByteArray()) record.headers().add(TraceContext.STOVE_TEST_ID_HEADER, ctx.testId.toByteArray()) } } suspend inline fun shouldBeConsumed( atLeastIn: Duration = 5.seconds, crossinline condition: ObservedMessage.() -> Boolean ): KafkaSystem = assertKafkaMessage( assertionName = "shouldBeConsumed", typeName = T::class.simpleName ?: "Unknown", timeout = atLeastIn, expected = "Message matching condition within $atLeastIn" ) { onMatch -> shouldBeConsumedInternal(T::class, atLeastIn) { parsed -> parsed.message.isSome { o -> val result = condition(ObservedMessage(o, parsed.metadata)) if (result) onMatch(o) result } } } suspend inline fun shouldBePublished( atLeastIn: Duration = 5.seconds, crossinline condition: ObservedMessage.() -> Boolean ): KafkaSystem = assertKafkaMessage( assertionName = "shouldBePublished", typeName = T::class.simpleName ?: "Unknown", timeout = atLeastIn, expected = "Message matching condition within $atLeastIn" ) { onMatch -> shouldBePublishedInternal(T::class, atLeastIn) { parsed -> parsed.message.isSome { o -> val result = condition(ObservedMessage(o, parsed.metadata)) if (result) onMatch(o) result } } } suspend inline fun shouldBeFailed( atLeastIn: Duration = 5.seconds, crossinline condition: ObservedMessage.() -> Boolean ): KafkaSystem = assertKafkaMessage( assertionName = "shouldBeFailed", typeName = T::class.simpleName ?: "Unknown", timeout = atLeastIn, expected = "Failed message within $atLeastIn" ) { onMatch -> shouldBeFailedInternal(T::class, atLeastIn) { parsed -> parsed.message.isSome { o -> val result = condition(ObservedMessage(o, parsed.metadata)) if (result) onMatch(o) result } } } /** * Helper to reduce boilerplate in Kafka assertion methods. * Handles try-catch, recording, and re-throwing. */ @PublishedApi internal suspend inline fun assertKafkaMessage( assertionName: String, typeName: String, timeout: Duration, expected: String, crossinline block: suspend ((T) -> Unit) -> Unit ): KafkaSystem { var matchedMessage: T? = null val result = runCatching { coroutineScope { block { matchedMessage = it } } } val failure = result.exceptionOrNull()?.let { e -> e as? AssertionError ?: AssertionError( "Expected $assertionName<$typeName> matching condition within $timeout, but none was found", e ) } if (result.isSuccess) { reporter.record( ReportEntry.success( system = reportSystemName, testId = reporter.currentTestId(), action = "$assertionName<$typeName>", output = matchedMessage.toOption(), metadata = mapOf("timeout" to timeout.toString()) ) ) } else { reporter.record( ReportEntry.failure( system = reportSystemName, testId = reporter.currentTestId(), action = "$assertionName<$typeName>", error = failure?.message ?: "No matching message found", expected = expected.some(), actual = (matchedMessage ?: "No matching message found").some() ) ) } failure?.let { throw it } return this } suspend inline fun shouldBeRetried( atLeastIn: Duration = 5.seconds, times: Int = 1, crossinline condition: ObservedMessage.() -> Boolean ): KafkaSystem = coroutineScope { shouldBeRetriedInternal(T::class, atLeastIn, times) { parsed -> parsed.message.isSome { o -> condition(ObservedMessage(o, parsed.metadata)) } } }.let { this } /** * Waits until the consumed message is seen. This does not mean committed. */ @Suppress("MagicNumber") suspend inline fun peekConsumedMessages( atLeastIn: Duration = 5.seconds, topic: String, crossinline condition: (ConsumedRecord) -> Boolean ) = withTimeout(atLeastIn) { var offset = -1L var loop = true while (loop) { sink.store .consumedMessages() .filter { it.topic == topic && it.offset > offset } .onEach { offset = it.offset } .map { ConsumedRecord(it.topic, it.key, it.message.toByteArray(), it.headers, it.offset, it.partition) } .forEach { loop = !condition(it) } delay(100) } } /** * Waits until the committed message is seen with the given condition. */ @Suppress("MagicNumber") suspend inline fun peekCommittedMessages( atLeastIn: Duration = 5.seconds, topic: String, crossinline condition: (CommittedRecord) -> Boolean ) = withTimeout(atLeastIn) { var offset = -1L var loop = true while (loop) { sink.store .committedMessages() .filter { it.topic == topic && it.offset > offset } .onEach { offset = it.offset } .map { CommittedRecord(it.topic, it.metadata, it.offset, it.partition) } .forEach { loop = !condition(it) } delay(100) } } /** * Waits until the published message is seen with the given condition. */ @Suppress("MagicNumber") suspend inline fun peekPublishedMessages( atLeastIn: Duration = 5.seconds, topic: String, crossinline condition: (PublishedRecord) -> Boolean ) = withTimeout(atLeastIn) { val seenIds = mutableMapOf() var loop = true while (loop) { sink.store .publishedMessages() .filter { it.topic == topic && !seenIds.containsKey(it.id) } .onEach { seenIds[it.id] = it } .map { PublishedRecord(it.topic, it.key, it.message.toByteArray(), it.headers) } .forEach { loop = !condition(it) } delay(100) } } /** * Creates an inflight consumer that consumes messages from the given topic. */ suspend fun consumer( topic: String, readOnly: Boolean = true, autoOffsetReset: String = "earliest", autoCreateTopics: Boolean = false, config: (Properties) -> Unit = {}, keyDeserializer: Deserializer = StoveKafkaValueDeserializer(), valueDeserializer: Deserializer = StoveKafkaValueDeserializer(), keepConsumingAtLeastFor: Duration = 5.seconds, pollTimeout: Duration = (keepConsumingAtLeastFor.inWholeMilliseconds / 2).milliseconds, groupId: String = UUID.randomUUID().toString(), onConsume: suspend (ConsumerRecord) -> Unit ) = consume( autoOffsetReset, readOnly, autoCreateTopics, config, keyDeserializer, valueDeserializer, topic, pollTimeout, keepConsumingAtLeastFor, groupId, onConsume ) /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance or embedded Kafka. */ fun pause(): KafkaSystem = withContainerOrWarn("pause") { it.pause() } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance or embedded Kafka. */ fun unpause(): KafkaSystem = withContainerOrWarn("unpause") { it.unpause() } /** * Provides access to the message store of the KafkaSystem. */ fun messageStore(): MessageStore = this.sink.store suspend fun adminOperations(block: suspend Admin.() -> Unit) = block(adminClient) @PublishedApi internal suspend fun shouldBeConsumedInternal( clazz: KClass, atLeastIn: Duration, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { sink.waitUntilConsumed(atLeastIn, clazz, condition) } @PublishedApi internal suspend fun shouldBeFailedInternal( clazz: KClass, atLeastIn: Duration, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { sink.waitUntilFailed(atLeastIn, clazz, condition) } @PublishedApi internal suspend fun shouldBePublishedInternal( clazz: KClass, atLeastIn: Duration, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { sink.waitUntilPublished(atLeastIn, clazz, condition) } @PublishedApi internal suspend fun shouldBeRetriedInternal( clazz: KClass, atLeastIn: Duration, times: Int, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { sink.waitUntilRetried(atLeastIn, times, clazz, condition) } private suspend fun obtainExposedConfiguration(): KafkaExposedConfiguration = when { context.options is ProvidedKafkaSystemOptions -> context.options.config context.runtime is EmbeddedKafkaRuntime -> startEmbeddedKafka() context.runtime is StoveKafkaContainer -> startKafkaContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startEmbeddedKafka(): KafkaExposedConfiguration = state.capture { val config = EmbeddedKafkaConfig.apply(0, 0, `Map$`.`MODULE$`.empty(), `Map$`.`MODULE$`.empty(), `Map$`.`MODULE$`.empty()) val server = EmbeddedKafka.start(config) while (!EmbeddedKafka.isRunning()) { delay(100) } KafkaExposedConfiguration("0.0.0.0:${server.config().kafkaPort()}", StoveKafkaBridge::class.java.name) } private suspend fun startKafkaContainer(container: StoveKafkaContainer): KafkaExposedConfiguration = state.capture { container.start() KafkaExposedConfiguration(container.bootstrapServers, StoveKafkaBridge::class.java.name) } private suspend fun stopEmbeddedKafka() { EmbeddedKafka.stop() while (EmbeddedKafka.isRunning()) { delay(100) } } @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) @Suppress("MagicNumber") private suspend fun consume( autoOffsetReset: String, readOnly: Boolean, autoCreateTopics: Boolean, config: (Properties) -> Unit, keyDeserializer: Deserializer, valueDeserializer: Deserializer, topic: String, pollTimeout: Duration, keepConsumingAtLeastFor: Duration, groupId: String, onConsume: suspend (ConsumerRecord) -> Unit ) = coroutineScope { val props = createConsumerProperties(autoOffsetReset, autoCreateTopics, groupId).apply(config) val c = KafkaConsumer(props, keyDeserializer, valueDeserializer) c.subscribe(listOf(topic)) val channel = Channel>() val job = launch { while (isActive) { c.poll(pollTimeout.toJavaDuration()).forEach { channel.send(it) } delay(100) } } whileSelect { onTimeout(keepConsumingAtLeastFor) { c.close() job.cancelAndJoin() false } channel.onReceive { onConsume(it) if (!readOnly) c.commitSync() !channel.isClosedForReceive } } } private fun createConsumerProperties( autoOffsetReset: String, autoCreateTopics: Boolean, groupId: String ): Properties = Properties().apply { putAll(context.options.properties) this[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = exposedConfiguration.bootstrapServers this[ConsumerConfig.GROUP_ID_CONFIG] = groupId this[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = autoOffsetReset this[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false this[ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG] = autoCreateTopics this[ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG] = exposedConfiguration.interceptorClass } private fun createPublisher(config: KafkaExposedConfiguration): KafkaProducer = KafkaProducer( buildMap { putAll(context.options.properties) put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.bootstrapServers) put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, context.options.valueSerializer::class.java.name) put(ProducerConfig.CLIENT_ID_CONFIG, "stove-kafka-producer") put(ProducerConfig.ACKS_CONFIG, "1") if (context.options.listenPublishedMessagesFromStove) { put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, config.interceptorClass) } } ) private fun createAdminClient(config: KafkaExposedConfiguration): Admin = Admin.create( buildMap { putAll(context.options.properties) put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, config.bootstrapServers) put(AdminClientConfig.CLIENT_ID_CONFIG, "stove-kafka-admin-client") }.toProperties() ) private suspend fun startGrpcServer(): Server { System.setProperty(STOVE_KAFKA_BRIDGE_PORT, context.options.bridgeGrpcServerPort.toString()) return Try { NettyServerBuilder .forAddress(InetSocketAddress(InetAddress.getLoopbackAddress(), context.options.bridgeGrpcServerPort)) .executor(StoveKafkaCoroutineScope.also { it.ensureActive() }.asExecutor) .addService(StoveKafkaObserverGrpcServer(sink)) .handshakeTimeout(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .permitKeepAliveTime(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .keepAliveTime(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .keepAliveTimeout(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .maxConnectionAge(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .maxConnectionAgeGrace(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .maxConnectionIdle(GRPC_TIMEOUT_IN_SECONDS, java.util.concurrent.TimeUnit.SECONDS) .maxInboundMessageSize(MAX_MESSAGE_SIZE) .maxInboundMetadataSize(MAX_MESSAGE_SIZE) .permitKeepAliveWithoutCalls(true) .build() .start() .also { waitUntilHealthy(it, 30.seconds) } }.recover { logger.error("Failed to start Stove Message Sink Grpc Server", it) throw it }.map { logger.info("Stove Sink Grpc Server started on port ${context.options.bridgeGrpcServerPort}") it }.get() } private suspend fun waitUntilHealthy(server: Server, duration: Duration) { val client = GrpcUtils.createClient(server.port.toString(), StoveKafkaCoroutineScope) var healthy = false withTimeout(duration) { while (!healthy) { logger.info("Waiting for Stove Message Sink Grpc Server to be healthy") Try { val response = client.healthCheck().execute(HealthCheckRequest()) healthy = response.status == HealthCheckResponse.ServingStatus.SERVING } delay(GRPC_SERVER_DELAY) } logger.info("Stove Message Sink Grpc Server is healthy!") } } private inline fun withContainerOrWarn( operation: String, action: (StoveKafkaContainer) -> Unit ): KafkaSystem = when (val runtime = context.runtime) { is ProvidedRuntime, is EmbeddedKafkaRuntime -> { logger.warn("$operation() is not supported when using embedded Kafka or a provided instance") this } is StoveKafkaContainer -> { action(runtime) this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } companion object { private const val GRPC_SERVER_DELAY = 500L private const val GRPC_TIMEOUT_IN_SECONDS = 300L private const val MAX_MESSAGE_SIZE = 1024 * 1024 * 1024 } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaSystemOptions.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.database.migrations.* import com.trendyol.stove.kafka.intercepting.StoveKafkaBridge import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.apache.kafka.clients.admin.Admin import org.apache.kafka.common.serialization.Serializer /** * Options for configuring the Kafka system in container or embedded mode. */ @StoveDsl open class KafkaSystemOptions( /** * When set to `true`, an embedded Kafka broker is automatically started and used for the test run. * This is ideal for self-contained integration tests without external dependencies. * When `false`, the system will attempt to connect to a TestContainer Kafka instance. * * The default is `false`. */ open val useEmbeddedKafka: Boolean = false, /** * Suffixes for error and retry topics in the application. */ open val topicSuffixes: TopicSuffixes = TopicSuffixes(), /** * If true, the system will listen to the messages published by the Kafka system. */ open val listenPublishedMessagesFromStove: Boolean = false, /** * The port of the bridge gRPC server that is used to communicate with the Kafka system. */ open val bridgeGrpcServerPort: Int = stoveKafkaBridgePortDefault.toInt(), /** * The Serde that is used while asserting the messages, * serializing while bridging the messages. * * The default value is [StoveSerde.jackson]'s anyByteArraySerde. * * @see [com.trendyol.stove.kafka.intercepting.StoveKafkaBridge] for bridging the messages. * @see StoveKafkaValueSerializer for serializing the messages. * @see StoveKafkaValueDeserializer for deserializing the messages. */ open val serde: StoveSerde = stoveSerdeRef, /** * The Value serializer that is used to serialize messages. */ open val valueSerializer: Serializer = StoveKafkaValueSerializer(), /** * The options for the Kafka container. */ open val containerOptions: KafkaContainerOptions = KafkaContainerOptions(), /** * A suspend function to clean up data after tests complete. */ open val cleanup: suspend (Admin) -> Unit = {}, /** * Additional Kafka client properties applied to all internal clients (admin, producer, consumer). * Use this to pass security configs (SASL_SSL, truststore, etc.) when connecting to a secured cluster. * * Example: * ```kotlin * properties = mapOf( * "security.protocol" to "SASL_SSL", * "sasl.mechanism" to "PLAIN", * "sasl.jaas.config" to "...PlainLoginModule required username=\"user\" password=\"pass\";" * ) * ``` */ open val properties: Map = emptyMap(), /** * The options for the Kafka system that is exposed to the application. */ override val configureExposedConfiguration: (KafkaExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided Kafka instance * instead of a testcontainer or embedded Kafka. * * @param bootstrapServers The Kafka bootstrap servers (e.g., "localhost:9092") * @param topicSuffixes Suffixes for error and retry topics * @param listenPublishedMessagesFromStove If true, the system will listen to published messages * @param bridgeGrpcServerPort The port of the bridge gRPC server * @param serde The Serde used for message serialization * @param valueSerializer The Value serializer for messages * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( bootstrapServers: String, topicSuffixes: TopicSuffixes = TopicSuffixes(), listenPublishedMessagesFromStove: Boolean = false, bridgeGrpcServerPort: Int = stoveKafkaBridgePortDefault.toInt(), serde: StoveSerde = stoveSerdeRef, valueSerializer: Serializer = StoveKafkaValueSerializer(), properties: Map = emptyMap(), runMigrations: Boolean = true, cleanup: suspend (Admin) -> Unit = {}, configureExposedConfiguration: (KafkaExposedConfiguration) -> List ): ProvidedKafkaSystemOptions = ProvidedKafkaSystemOptions( config = KafkaExposedConfiguration( bootstrapServers = bootstrapServers, interceptorClass = StoveKafkaBridge::class.java.name ), topicSuffixes = topicSuffixes, listenPublishedMessagesFromStove = listenPublishedMessagesFromStove, bridgeGrpcServerPort = bridgeGrpcServerPort, serde = serde, valueSerializer = valueSerializer, properties = properties, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided Kafka instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedKafkaSystemOptions( /** * The configuration for the provided Kafka instance. */ val config: KafkaExposedConfiguration, topicSuffixes: TopicSuffixes = TopicSuffixes(), listenPublishedMessagesFromStove: Boolean = false, bridgeGrpcServerPort: Int = stoveKafkaBridgePortDefault.toInt(), serde: StoveSerde = stoveSerdeRef, valueSerializer: Serializer = StoveKafkaValueSerializer(), properties: Map = emptyMap(), cleanup: suspend (Admin) -> Unit = {}, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (KafkaExposedConfiguration) -> List ) : KafkaSystemOptions( useEmbeddedKafka = false, topicSuffixes = topicSuffixes, listenPublishedMessagesFromStove = listenPublishedMessagesFromStove, bridgeGrpcServerPort = bridgeGrpcServerPort, serde = serde, valueSerializer = valueSerializer, containerOptions = KafkaContainerOptions(), properties = properties, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: KafkaExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } /** * Context provided to Kafka migrations. * Contains the Admin client and options for performing setup operations. * * @property admin The Kafka Admin client for managing topics, ACLs, etc. * @property options The Kafka system options */ @StoveDsl data class KafkaMigrationContext( val admin: Admin, val options: KafkaSystemOptions ) /** * Convenience type alias for Kafka migrations. * * Instead of writing `DatabaseMigration`, use `KafkaMigration`: * ```kotlin * class MyMigration : KafkaMigration { * override val order: Int = 1 * override suspend fun execute(connection: KafkaMigrationContext) { ... } * } * ``` */ typealias KafkaMigration = DatabaseMigration /** * Suffixes for error and retry topics in the application. * Stove Kafka uses these suffixes to understand the intent of the topic and the message. */ data class TopicSuffixes( val error: List = listOf(".error", ".DLT"), val retry: List = listOf(".retry") ) { fun isRetryTopic(topic: String): Boolean = retry.any { topic.endsWith(it, ignoreCase = true) } fun isErrorTopic(topic: String): Boolean = error.any { topic.endsWith(it, ignoreCase = true) } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/SerDe.kt ================================================ package com.trendyol.stove.kafka import org.apache.kafka.common.serialization.* @Suppress("UNCHECKED_CAST") class StoveKafkaValueDeserializer : Deserializer { override fun deserialize( topic: String, data: ByteArray ): T = stoveSerdeRef.deserialize(data, Any::class.java) as T } class StoveKafkaValueSerializer : Serializer { override fun serialize( topic: String, data: T ): ByteArray = stoveSerdeRef.serialize(data) } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/coroutines.kt ================================================ package com.trendyol.stove.kafka import kotlinx.coroutines.* import java.util.concurrent.* val CoroutineScope.asExecutor: Executor get() = StoveCoroutineExecutor(this) val CoroutineScope.asExecutorService: ExecutorService get() = CoroutineExecutorService(this) internal class CoroutineExecutorService( private val coroutineScope: CoroutineScope ) : AbstractExecutorService() { override fun execute(command: Runnable) { coroutineScope.launch { command.run() } } override fun shutdown() { coroutineScope.cancel() } override fun shutdownNow(): List { coroutineScope.cancel() return emptyList() } override fun isShutdown(): Boolean = coroutineScope.coroutineContext[Job]?.isCancelled ?: true override fun isTerminated(): Boolean = coroutineScope.coroutineContext[Job]?.isCompleted ?: true override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean { // Coroutine jobs don't support await termination out of the box // This is a simplified implementation return isTerminated } } internal class StoveCoroutineExecutor( private val scope: CoroutineScope ) : Executor { override fun execute(command: Runnable) { scope.launch { command.run() } } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/CommonOps.kt ================================================ package com.trendyol.stove.kafka.intercepting import arrow.core.toOption import com.trendyol.stove.kafka.* import com.trendyol.stove.messaging.* import com.trendyol.stove.serialization.StoveSerde import kotlinx.coroutines.* import org.apache.kafka.clients.admin.Admin import org.slf4j.Logger import kotlin.reflect.KClass import kotlin.time.Duration internal interface CommonOps { val store: MessageStore val serde: StoveSerde val adminClient: Admin val topicSuffixes: TopicSuffixes val logger: Logger companion object { const val DELAY_MS = 50L } suspend fun (() -> Collection).waitUntilConditionMet( duration: Duration, subject: String, condition: (T) -> Boolean ): Collection = runCatching { val collectionFunc = this withTimeout(duration) { while (!collectionFunc().any { condition(it) }) { delay(DELAY_MS) } } collectionFunc().filter { condition(it) } }.fold( onFailure = { throw AssertionError("GOT A TIMEOUT: $subject. ${dumpMessages()}") }, onSuccess = { it } ) suspend fun (suspend () -> Collection).waitUntilCount( duration: Duration, count: Int ): Collection = runCatching { val collectionFunc = this withTimeout(duration) { while (collectionFunc().size < count) { delay(DELAY_MS) } } collectionFunc() }.getOrElse { throw AssertionError( "GOT A TIMEOUT: While expecting $count items to be retried, " + "but was ${this().size}.\n ${dumpMessages()}" ) } fun throwIfFailed( clazz: KClass, selector: (message: ParsedMessage) -> Boolean ): Unit = store .failedMessages() .filter { selector(SuccessfulParsedMessage(deserializeCatching(it.message.toByteArray(), clazz).getOrNull().toOption(), it.metadata())) }.forEach { throw AssertionError("Message was expected to be consumed successfully, but failed: $it \n ${dumpMessages()}") } fun throwIfRetried( clazz: KClass, selector: (message: ParsedMessage) -> Boolean ): Unit = store .retriedMessages() .filter { selector( SuccessfulParsedMessage( deserializeCatching(it.message.toByteArray(), clazz).getOrNull().toOption(), MessageMetadata(it.topic, it.key, it.headers) ) ) }.forEach { throw AssertionError("Message was expected to be consumed successfully, but was retried: $it \n ${dumpMessages()}") } fun deserializeCatching( value: ByteArray, clazz: KClass ): Result = runCatching { serde.deserialize(value, clazz.java) } .onFailure { exception -> logger.debug("Failed to deserialize message: ${String(value)}", exception) } fun dumpMessages(): String } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/GrpcUtils.kt ================================================ @file:Suppress("HttpUrlsUsage") package com.trendyol.stove.kafka.intercepting import com.squareup.wire.* import com.trendyol.stove.kafka.* import kotlinx.coroutines.CoroutineScope import okhttp3.* import java.net.InetAddress import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration object GrpcUtils { private val getClient = { scope: CoroutineScope -> OkHttpClient .Builder() .protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE)) .callTimeout(30.seconds.toJavaDuration()) .readTimeout(30.seconds.toJavaDuration()) .writeTimeout(30.seconds.toJavaDuration()) .connectTimeout(30.seconds.toJavaDuration()) .dispatcher(Dispatcher(scope.asExecutorService)) .build() } fun createClient(onPort: String, scope: CoroutineScope): StoveKafkaObserverServiceClient = GrpcClient .Builder() .client(getClient(scope)) .baseUrl(onLoopback(onPort)) .build() .create() private fun onLoopback(port: String): GrpcHttpUrl = "http://${InetAddress.getLoopbackAddress().hostAddress}:$port".toHttpUrl() } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/MessageSinkOps.kt ================================================ package com.trendyol.stove.kafka.intercepting import arrow.core.toOption import com.trendyol.stove.kafka.* import com.trendyol.stove.messaging.* import kotlinx.coroutines.runBlocking import kotlin.reflect.KClass import kotlin.time.Duration internal interface MessageSinkOps : MessageSinkPublishOps, CommonOps { fun recordConsumed(record: ConsumedMessage): Unit = runBlocking { store.record(record) logger.info( "Recorded Consumed Message: {}, testCase: {}", record, record.headers.firstNotNullOf { it.key == "testCase" } ) } fun recordRetry(record: ConsumedMessage): Unit = runBlocking { store.recordRetry(record) logger.info( "Recorded Retried Message: {}, testCase: {}", record, record.headers.firstNotNullOf { it.key == "testCase" } ) } fun recordCommittedMessage(record: CommittedMessage): Unit = runBlocking { store.record(record) logger.info("Recorded Committed Message:{}", record) } fun recordAcknowledgedMessage(record: AcknowledgedMessage): Unit = runBlocking { store.record(record) logger.info("Recorded Acknowledged Message:{}", record) } fun recordError(record: ConsumedMessage): Unit = runBlocking { store.recordFailure(record) logger.info("Recorded Failed Message: {}", record) } suspend fun waitUntilConsumed( atLeastIn: Duration, clazz: KClass, condition: (metadata: ParsedMessage) -> Boolean ) { val getRecords = { store.consumedMessages() } getRecords.waitUntilConditionMet(atLeastIn, "While expecting consuming of ${clazz.java.simpleName}") { val outcome = deserializeCatching(it.message.toByteArray(), clazz) outcome.isSuccess && condition( SuccessfulParsedMessage( outcome.getOrNull().toOption(), it.metadata() ) ) && store.isCommitted(it.topic, it.offset, it.partition) } throwIfFailed(clazz, condition) throwIfRetried(clazz, condition) } suspend fun waitUntilFailed( atLeastIn: Duration, clazz: KClass, condition: (ParsedMessage) -> Boolean ) { class FailedMessage( val message: ByteArray, val metadata: MessageMetadata ) val getRecords = { store.failedMessages().map { FailedMessage(it.message.toByteArray(), it.metadata()) } + store .publishedMessages() .filter { topicSuffixes.isErrorTopic(it.topic) } .map { FailedMessage(it.message.toByteArray(), it.metadata()) } } getRecords.waitUntilConditionMet(atLeastIn, "While expecting Failure of ${clazz.java.simpleName}") { val outcome = deserializeCatching(it.message, clazz) outcome.isSuccess && condition(SuccessfulParsedMessage(outcome.getOrNull().toOption(), it.metadata)) } } suspend fun waitUntilRetried( atLeastIn: Duration, times: Int = 1, clazz: KClass, condition: (message: ParsedMessage) -> Boolean ) { val getRecords = { store.retriedMessages() } val failedFunc = suspend { getRecords.waitUntilConditionMet(atLeastIn, "While expecting Retrying of ${clazz.java.simpleName}") { val outcome = deserializeCatching(it.message.toByteArray(), clazz) outcome.isSuccess && condition( SuccessfulParsedMessage( outcome.getOrNull().toOption(), MessageMetadata(it.topic, it.key, it.headers) ) ) } } failedFunc.waitUntilCount(atLeastIn, times) } override fun dumpMessages(): String = "Sink so far:\n$store" } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/MessageSinkPublishOps.kt ================================================ package com.trendyol.stove.kafka.intercepting import arrow.core.toOption import com.trendyol.stove.kafka.PublishedMessage import com.trendyol.stove.messaging.* import kotlinx.coroutines.runBlocking import kotlin.reflect.KClass import kotlin.time.Duration internal interface MessageSinkPublishOps : CommonOps { suspend fun waitUntilPublished( atLeastIn: Duration, clazz: KClass, condition: (message: ParsedMessage) -> Boolean ) { val getRecords = { store.publishedMessages().map { it } } getRecords.waitUntilConditionMet(atLeastIn, "While expecting Publishing of ${clazz.java.simpleName}") { val outcome = deserializeCatching(it.message.toByteArray(), clazz) outcome.isSuccess && condition(SuccessfulParsedMessage(outcome.getOrNull().toOption(), MessageMetadata(it.topic, it.key, it.headers))) } } fun recordPublishedMessage(record: PublishedMessage): Unit = runBlocking { store.record(record) logger.info( "Recorded Published Message: {}, testCase: {}", record, record.headers.firstNotNullOf { it.key == "testCase" } ) } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/MessageStore.kt ================================================ package com.trendyol.stove.kafka.intercepting import com.trendyol.stove.kafka.* import io.exoquery.pprint class MessageStore { private val consumed = Caching.of() private val published = Caching.of() private val committed = Caching.of() private val retried = Caching.of() private val failedMessages = Caching.of() private val acknowledged = Caching.of() internal fun record(message: ConsumedMessage) = consumed.put(message.id, message) internal fun record(message: PublishedMessage) = published.put(message.id, message) internal fun record(message: CommittedMessage) = committed.put(message.id, message) internal fun record(message: AcknowledgedMessage) = acknowledged.put(message.id, message) internal fun recordRetry(message: ConsumedMessage) = retried.put(message.id, message) internal fun recordFailure(message: ConsumedMessage) = failedMessages.put(message.id, message) fun failedMessages(): Collection = failedMessages.asMap().values fun consumedMessages(): Collection = consumed.asMap().values fun publishedMessages(): Collection = published.asMap().values fun committedMessages(): Collection = committed.asMap().values fun retriedMessages(): Collection = retried.asMap().values internal fun isCommitted( topic: String, offset: Long, partition: Int ): Boolean = committedMessages() .filter { it.topic == topic && it.partition == partition } .any { committed -> committed.offset >= offset + 1 } override fun toString(): String = """ |Consumed: ${pprint(consumedMessages())} |Published: ${pprint(publishedMessages())} |Committed: ${pprint(committedMessages())} |Retried: ${pprint(retriedMessages())} |Failed: ${pprint(failedMessages())} """.trimIndent().trimMargin() } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/StoveKafkaBridge.kt ================================================ package com.trendyol.stove.kafka.intercepting import com.squareup.wire.GrpcException import com.trendyol.stove.functional.* import com.trendyol.stove.kafka.* import com.trendyol.stove.serialization.StoveSerde import kotlinx.coroutines.runBlocking import okio.ByteString.Companion.toByteString import org.apache.kafka.clients.consumer.* import org.apache.kafka.clients.producer.* import org.apache.kafka.common.TopicPartition import org.slf4j.Logger import java.nio.charset.Charset import java.util.* @Suppress("UNUSED") class StoveKafkaBridge : ConsumerInterceptor, ProducerInterceptor { private val logger: Logger = org.slf4j.LoggerFactory.getLogger(StoveKafkaBridge::class.java) private val client: StoveKafkaObserverServiceClient by lazy { startGrpcClient() } private val serde: StoveSerde by lazy { stoveSerdeRef } override fun onSend(record: ProducerRecord): ProducerRecord = runBlocking { record.also { send(publishedMessage(it)) } } override fun onConsume(records: ConsumerRecords): ConsumerRecords = runBlocking { records.also { consumedMessages(it).forEach { message -> send(message) } } } override fun onCommit(offsets: MutableMap) = runBlocking { committedMessages(offsets).forEach { send(it) } } override fun configure(configs: MutableMap) = Unit override fun close() = Unit override fun onAcknowledgement(metadata: RecordMetadata?, exception: Exception?) = runBlocking { ackedMessages(metadata, exception).forEach { send(it) } } private suspend fun send(consumedMessage: ConsumedMessage) { Try { client.onConsumedMessage().execute(consumedMessage) }.map { logger.info("Consumed message sent to Stove Kafka Bridge: $consumedMessage") }.recover { e -> when { e is GrpcException && e.grpcStatus.code == 2 && e.grpcStatus.name == "UNKNOWN" -> Unit else -> logger.error("Failed to send consumed message to Stove Kafka Bridge: $consumedMessage", e) } } } private suspend fun send(committedMessage: CommittedMessage) { Try { client.onCommittedMessage().execute(committedMessage) }.map { logger.info("Committed message sent to Stove Kafka Bridge: $committedMessage") }.recover { e -> when { e is GrpcException && e.grpcStatus.code == 2 && e.grpcStatus.name == "UNKNOWN" -> Unit else -> logger.error("Failed to send committed message to Stove Kafka Bridge: $committedMessage", e) } } } private suspend fun send(publishedMessage: PublishedMessage) { Try { client.onPublishedMessage().execute(publishedMessage) }.map { logger.info("Published message sent to Stove Kafka Bridge: $publishedMessage") }.recover { e -> when { e is GrpcException && e.grpcStatus.code == 2 && e.grpcStatus.name == "UNKNOWN" -> Unit else -> logger.error("Failed to send published message to Stove Kafka Bridge: $publishedMessage", e) } } } private suspend fun send(ackedMessage: AcknowledgedMessage) { Try { client.onAcknowledgedMessage().execute(ackedMessage) }.map { logger.info("Acknowledged message sent to Stove Kafka Bridge: $ackedMessage") }.recover { e -> when { e is GrpcException && e.grpcStatus.code == 2 && e.grpcStatus.name == "UNKNOWN" -> Unit else -> logger.error("Failed to send acknowledged message to Stove Kafka Bridge: $ackedMessage", e) } } } private fun ackedMessages(metadata: RecordMetadata?, exception: Exception?): List { val ackedMessage = AcknowledgedMessage( id = UUID.randomUUID().toString(), topic = metadata?.topic() ?: "", partition = metadata?.partition() ?: -1, offset = metadata?.offset() ?: -1, exception = exception?.message ?: "" ) return listOf(ackedMessage) } private fun consumedMessages(records: ConsumerRecords) = records.map { record -> ConsumedMessage( id = UUID.randomUUID().toString(), key = record.key().toString(), message = serializeIfNotYet(record.value()).toByteString(), topic = record.topic(), offset = record.offset(), partition = record.partition(), headers = record.headers().associate { it.key() to it.value().toString(Charset.defaultCharset()) } ) } private fun publishedMessage(record: ProducerRecord) = PublishedMessage( id = UUID.randomUUID().toString(), key = record.key().toString(), message = serializeIfNotYet(record.value()).toByteString(), topic = record.topic(), headers = record.headers().associate { it.key() to it.value().toString(Charset.defaultCharset()) } ) private fun committedMessages( offsets: Map ): List = offsets.map { CommittedMessage( id = UUID.randomUUID().toString(), topic = it.key.topic(), partition = it.key.partition(), offset = it.value.offset(), metadata = it.value.metadata() ) } private fun serializeIfNotYet(value: V): ByteArray = when (value) { is ByteArray -> value else -> serde.serialize(value as Any) } private fun startGrpcClient(): StoveKafkaObserverServiceClient { val onPort = System.getenv(STOVE_KAFKA_BRIDGE_PORT) ?: System.getProperty(STOVE_KAFKA_BRIDGE_PORT) ?: stoveKafkaBridgePortDefault logger.info("Connecting to Stove Kafka Bridge on port $onPort") return Try { GrpcUtils.createClient(onPort, StoveKafkaCoroutineScope) } .map { logger.info("Stove Kafka Observer Client created on port $onPort") it }.getOrElse { error("failed to connect Stove Kafka observer client") } } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/StoveKafkaObserverGrpcServer.kt ================================================ package com.trendyol.stove.kafka.intercepting import com.trendyol.stove.kafka.* import org.slf4j.* class StoveKafkaObserverGrpcServer( private val sink: StoveMessageSink ) : StoveKafkaObserverServiceWireGrpc.StoveKafkaObserverServiceImplBase() { private val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun healthCheck(request: HealthCheckRequest): HealthCheckResponse { logger.info("Received health check request: $request") return HealthCheckResponse(status = HealthCheckResponse.ServingStatus.SERVING) } override suspend fun onPublishedMessage(request: PublishedMessage): Reply { logger.info("Received published message: $request") sink.onMessagePublished(request) return Reply(status = 200) } override suspend fun onConsumedMessage(request: ConsumedMessage): Reply { logger.info("Received consumed message: $request") sink.onMessageConsumed(request) return Reply(status = 200) } override suspend fun onCommittedMessage(request: CommittedMessage): Reply { logger.info("Received committed message: $request") sink.onMessageCommitted(request) return Reply(status = 200) } override suspend fun onAcknowledgedMessage(request: AcknowledgedMessage): Reply { logger.info("Received acknowledged message: $request") sink.onMessageAcknowledged(request) return Reply(status = 200) } } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/intercepting/StoveMessageSink.kt ================================================ package com.trendyol.stove.kafka.intercepting import com.trendyol.stove.kafka.* import com.trendyol.stove.serialization.StoveSerde import org.apache.kafka.clients.admin.Admin import org.slf4j.* class StoveMessageSink( override val adminClient: Admin, override val serde: StoveSerde, override val topicSuffixes: TopicSuffixes ) : MessageSinkOps, CommonOps { override val logger: Logger = LoggerFactory.getLogger(javaClass) override val store: MessageStore = MessageStore() fun onMessageConsumed(record: ConsumedMessage): Unit = when { topicSuffixes.isErrorTopic(record.topic) -> recordError(record) topicSuffixes.isRetryTopic(record.topic) -> recordRetry(record) else -> recordConsumed(record) } fun onMessagePublished(record: PublishedMessage): Unit = recordPublishedMessage(record) fun onMessageCommitted(record: CommittedMessage): Unit = recordCommittedMessage(record) fun onMessageAcknowledged(record: AcknowledgedMessage): Unit = recordAcknowledgedMessage(record) } ================================================ FILE: lib/stove-kafka/src/main/kotlin/com/trendyol/stove/kafka/messages.kt ================================================ package com.trendyol.stove.kafka class PublishedRecord( val topic: String, val key: String, val value: ByteArray, val headers: Map ) class CommittedRecord( val topic: String, val metadata: String, val offset: Long, val partition: Int ) class ConsumedRecord( val topic: String, val key: String, val value: ByteArray, val headers: Map, val offset: Long, val partition: Int ) ================================================ FILE: lib/stove-kafka/src/main/proto/messages.proto ================================================ syntax = "proto3"; // buf:lint:ignore FILE_SAME_PACKAGE package com.trendyol.stove.kafka; message ConsumedMessage { string id = 1; bytes message = 2; string topic = 3; int32 partition = 4; int64 offset = 5; string key = 6; map headers = 8; } message PublishedMessage { string id = 1; bytes message = 2; string topic = 3; string key = 4; map headers = 5; } message CommittedMessage { string id = 1; string topic = 2; int32 partition = 3; int64 offset = 4; string metadata = 5; } message AcknowledgedMessage { string id = 1; string topic = 2; int32 partition = 3; int64 offset = 4; string exception = 5; } message Reply { int32 status = 3; } message HealthCheckRequest { string service = 1; } message HealthCheckResponse { enum ServingStatus { UNKNOWN = 0; SERVING = 1; NOT_SERVING = 2; SERVICE_UNKNOWN = 3; // Used only by the Watch method. } ServingStatus status = 1; } service StoveKafkaObserverService { rpc healthCheck(HealthCheckRequest) returns (HealthCheckResponse) {} // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE rpc onConsumedMessage(ConsumedMessage) returns (Reply) {} // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE rpc onPublishedMessage(PublishedMessage) returns (Reply) {} // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE rpc onCommittedMessage(CommittedMessage) returns (Reply) {} // buf:lint:ignore RPC_REQUEST_RESPONSE_UNIQUE rpc onAcknowledgedMessage(AcknowledgedMessage) returns (Reply) {} } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/setup/TestSystemConfig.kt ================================================ package com.trendyol.stove.kafka.setup import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.kafka.* import com.trendyol.stove.kafka.setup.example.KafkaTestShared import com.trendyol.stove.system.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import io.github.nomisRev.kafka.publisher.PublisherSettings import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.apache.kafka.clients.admin.* import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.slf4j.* import org.testcontainers.kafka.ConfluentKafkaContainer import org.testcontainers.utility.DockerImageName import java.util.* // ============================================================================ // Shared components // ============================================================================ class KafkaApplicationUnderTest : ApplicationUnderTest { private val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var client: AdminClient private val consumers: MutableList = mutableListOf() override suspend fun start(configurations: List) { val bootstrapServers = configurations.first { it.contains("kafka", true) }.split('=')[1] logger.info("Starting Kafka application with bootstrap servers: $bootstrapServers") client = mapOf( AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers ).let { AdminClient.create(it) } val newTopics = KafkaTestShared.topics .flatMap { listOf(it.topic, it.retryTopic, it.deadLetterTopic) } .map { NewTopic(it, 1, 1) } client.createTopics(newTopics).all().get() startConsumers(bootstrapServers) } private suspend fun startConsumers(bootStrapServers: String) { val consumerSettings = mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootStrapServers, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to "2000", ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG to "true", ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StoveKafkaValueDeserializer::class.java, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", ConsumerConfig.GROUP_ID_CONFIG to "stove-application-consumers", ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG to listOf("com.trendyol.stove.kafka.intercepting.StoveKafkaBridge") ) val producerSettings = PublisherSettings( bootStrapServers, StringSerializer(), StoveKafkaValueSerializer(), properties = Properties().apply { put( ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, listOf("com.trendyol.stove.kafka.intercepting.StoveKafkaBridge") ) } ) val listeners = KafkaTestShared.consumers(consumerSettings, producerSettings) listeners.forEach { it.start() } consumers.addAll(listeners) } override suspend fun stop() { client.close() consumers.forEach { it.close() } } } /** * Migration that creates additional topics for testing. */ class CreateTestTopicsMigration : KafkaMigration { private val logger: Logger = LoggerFactory.getLogger(javaClass) override val order: Int = 1 override suspend fun execute(connection: KafkaMigrationContext) { logger.info("Executing CreateTestTopicsMigration") val topics = listOf( NewTopic("migration-test-topic", 1, 1), NewTopic("migration-test-topic-2", 2, 1) ) connection.admin .createTopics(topics) .all() .get() logger.info("Created migration test topics") } } // ============================================================================ // Strategy interface // ============================================================================ sealed interface KafkaTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): KafkaTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false val useEmbedded = System.getenv("USE_EMBEDDED")?.toBoolean() ?: System.getProperty("useEmbeddedKafka")?.toBoolean() ?: false return when { useProvided -> ProvidedKafkaStrategy() useEmbedded -> EmbeddedKafkaStrategy() else -> ContainerKafkaStrategy() } } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerKafkaStrategy : KafkaTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) init { setupBridgePort() } override suspend fun start() { logger.info("Starting Kafka tests with container mode") val options = KafkaSystemOptions( useEmbeddedKafka = false, listenPublishedMessagesFromStove = true, containerOptions = KafkaContainerOptions(tag = "8.0.3"), configureExposedConfiguration = { cfg -> listOf("kafka.servers=${cfg.bootstrapServers}") } ).migrations { register() } Stove() .with { kafka { options } applicationUnderTest(KafkaApplicationUnderTest()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("Kafka container tests completed") } } // ============================================================================ // Embedded Kafka strategy // ============================================================================ class EmbeddedKafkaStrategy : KafkaTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) init { setupBridgePort() } override suspend fun start() { logger.info("Starting Kafka tests with embedded mode") val options = KafkaSystemOptions( useEmbeddedKafka = true, listenPublishedMessagesFromStove = true, configureExposedConfiguration = { cfg -> listOf("kafka.servers=${cfg.bootstrapServers}") } ).migrations { register() } Stove() .with { kafka { options } applicationUnderTest(KafkaApplicationUnderTest()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("Kafka embedded tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedKafkaStrategy : KafkaTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: ConfluentKafkaContainer init { setupBridgePort() } override suspend fun start() { logger.info("Starting Kafka tests with provided mode") // Start an external container to simulate a provided instance externalContainer = ConfluentKafkaContainer( DockerImageName.parse("confluentinc/cp-kafka:7.8.1") ).apply { start() } logger.info("External Kafka container started at ${externalContainer.bootstrapServers}") val options = KafkaSystemOptions .provided( bootstrapServers = externalContainer.bootstrapServers, listenPublishedMessagesFromStove = true, runMigrations = true, cleanup = { admin -> logger.info("Running cleanup on provided instance") val topics = admin .listTopics() .names() .get() .filter { it.startsWith("migration-test") } if (topics.isNotEmpty()) { admin.deleteTopics(topics).all().get() } }, configureExposedConfiguration = { cfg -> listOf("kafka.servers=${cfg.bootstrapServers}") } ).migrations { register() } Stove() .with { kafka { options } applicationUnderTest(KafkaApplicationUnderTest()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("Kafka provided tests completed") } } // ============================================================================ // Helper function // ============================================================================ private fun setupBridgePort() { stoveKafkaBridgePortDefault = PortFinder.findAvailablePortAsString() System.setProperty(STOVE_KAFKA_BRIDGE_PORT, stoveKafkaBridgePortDefault) } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = KafkaTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/setup/example/DomainEvents.kt ================================================ package com.trendyol.stove.kafka.setup.example import kotlin.random.Random object DomainEvents { data class ProductCreated( val productId: String ) { companion object { val randomString = { Random.nextInt(0, Int.MAX_VALUE).toString() } fun randoms(count: Int): List = (0 until count).map { ProductCreated(randomString()) } } } data class ProductFailingCreated( val productId: String ) } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/setup/example/KafkaTestShared.kt ================================================ package com.trendyol.stove.kafka.setup.example import com.trendyol.stove.kafka.setup.example.consumers.* import io.github.nomisRev.kafka.publisher.PublisherSettings object KafkaTestShared { data class TopicDefinition( val topic: String, val retryTopic: String, val deadLetterTopic: String ) val topics = listOf( TopicDefinition("product", "product.retry", "product.error"), TopicDefinition("productFailing", "productFailing.retry", "productFailing.error") ) val consumers: ( consumerSettings: Map, producerSettings: PublisherSettings ) -> List = { a, b -> listOf( ProductConsumer(a, b), ProductFailingConsumer(a, b) ) } } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/setup/example/StoveListener.kt ================================================ package com.trendyol.stove.kafka.setup.example import com.trendyol.stove.functional.* import com.trendyol.stove.kafka.setup.example.KafkaTestShared.TopicDefinition import com.trendyol.stove.kafka.setup.example.consumers.* import io.github.nomisRev.kafka.publisher.* import kotlinx.coroutines.* import org.apache.kafka.clients.consumer.* import org.apache.kafka.clients.producer.ProducerRecord import java.time.Duration abstract class StoveListener( consumerSettings: Map, publisherSettings: PublisherSettings ) : AutoCloseable { private val logger = org.slf4j.LoggerFactory.getLogger(javaClass) abstract val topicDefinition: TopicDefinition private val consumer: KafkaConsumer = KafkaConsumer(consumerSettings) private val publisher: KafkaPublisher = KafkaPublisher(publisherSettings) private var scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) suspend fun start() { consumer.subscribe(listOf(topicDefinition.topic, topicDefinition.retryTopic, topicDefinition.deadLetterTopic)) scope.launch { while (isActive) { consumer .poll(Duration.ofMillis(100)) .forEach { message -> logger.info("Message RECEIVED on the application side: ${message.value()}") consume(message, consumer) } } } } private suspend fun consume(message: ConsumerRecord, consumer: KafkaConsumer) { Try { listen(message) } .map { logger.info("Message COMMITTED on the application side: ${message.value()}") consumer.commitAsync() }.recover { logger.warn("CONSUMER GOT an ERROR on the application side, exception: $it") if (message.getRetryCount() < 3) { logger.warn("CONSUMER GOT an ERROR, retrying...") try { message.incrementRetryCount() publisher.publishScope { offer( ProducerRecord( topicDefinition.retryTopic, message.partition(), message.key(), message.value(), message.headers() ) ) } } catch (e: Exception) { logger.error("Failed to publish message to retry topic: $message", e) } } else { logger.error("CONSUMER GOT an ERROR, retry limit exceeded: $message") val record = ProducerRecord( topicDefinition.deadLetterTopic, message.partition(), message.key(), message.value(), message.headers() ).apply { headers().add("doNotFail", "true".toByteArray()) } try { publisher.publishScope { offer(record) } } catch (e: Exception) { logger.error("Failed to publish message to dead letter topic: $message", e) } } } } abstract suspend fun listen(record: ConsumerRecord) override fun close(): Unit = scope.cancel() } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/setup/example/consumers/ProductConsumer.kt ================================================ package com.trendyol.stove.kafka.setup.example.consumers import com.trendyol.stove.kafka.setup.example.KafkaTestShared.TopicDefinition import com.trendyol.stove.kafka.setup.example.StoveListener import io.github.nomisRev.kafka.publisher.PublisherSettings import org.apache.kafka.clients.consumer.ConsumerRecord // TODO: Convert into in-flight consumer class ProductConsumer( consumerSettings: Map, producerSettings: PublisherSettings ) : StoveListener(consumerSettings, producerSettings) { private val logger = org.slf4j.LoggerFactory.getLogger(javaClass) override val topicDefinition: TopicDefinition = TopicDefinition("product", "product.retry", "product.error") override suspend fun listen(record: ConsumerRecord) { logger.info("Product consumed: ${record.value()} from topic: ${record.topic()} with key: ${record.key()}") } } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/setup/example/consumers/ProductFailingConsumer.kt ================================================ package com.trendyol.stove.kafka.setup.example.consumers import arrow.core.* import com.trendyol.stove.kafka.setup.example.KafkaTestShared.TopicDefinition import com.trendyol.stove.kafka.setup.example.StoveListener import io.github.nomisRev.kafka.publisher.PublisherSettings import org.apache.kafka.clients.consumer.ConsumerRecord // TODO: Convert into in-flight consumer @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") class ProductFailingConsumer( consumerSettings: Map, producerSettings: PublisherSettings ) : StoveListener(consumerSettings, producerSettings) { override val topicDefinition: TopicDefinition = TopicDefinition( "productFailing", "productFailing.retry", "productFailing.error" ) override suspend fun listen(record: ConsumerRecord) { record .headers() .firstOrNone { it.key() == "doNotFail" } .onSome { return } .onNone { throw Exception("exception occurred on purpose") } } } fun ConsumerRecord.getRetryCount(): Int = this .headers() .firstOrNone { it.key() == "retry" } .map { it.value().toString(Charsets.UTF_8).toInt() } .getOrElse { 0 } fun ConsumerRecord.incrementRetryCount(): Int { val currentRetry = this.getRetryCount() this.headers().remove("retry") this.headers().add("retry", (currentRetry + 1).toString().toByteArray(Charsets.UTF_8)) return currentRetry + 1 } ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/tests/CoroutineExecutorServiceTests.kt ================================================ package com.trendyol.stove.kafka.tests import com.trendyol.stove.kafka.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.* import java.util.concurrent.* import java.util.concurrent.atomic.* class CoroutineExecutorServiceTests : FunSpec({ test("execute should run the command") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executorService = scope.asExecutorService val executed = CountDownLatch(1) executorService.execute { executed.countDown() } executed.await(2, TimeUnit.SECONDS) shouldBe true executorService.shutdown() } test("shutdown should cancel the coroutine scope") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executorService = scope.asExecutorService executorService.isShutdown shouldBe false executorService.shutdown() executorService.isShutdown shouldBe true } test("shutdownNow should cancel scope and return empty list") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executorService = scope.asExecutorService val result = executorService.shutdownNow() result shouldBe emptyList() executorService.isShutdown shouldBe true } test("isTerminated should return true when job is completed") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executorService = scope.asExecutorService executorService.isTerminated shouldBe false executorService.shutdown() // After shutdown, the job is cancelled which means completed executorService.isTerminated shouldBe true } test("awaitTermination should return isTerminated status") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executorService = scope.asExecutorService executorService.shutdown() executorService.awaitTermination(1, TimeUnit.SECONDS) shouldBe true } test("execute should run multiple commands") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executorService = scope.asExecutorService val counter = AtomicInteger(0) val latch = CountDownLatch(3) repeat(3) { executorService.execute { counter.incrementAndGet() latch.countDown() } } latch.await(2, TimeUnit.SECONDS) shouldBe true counter.get() shouldBe 3 executorService.shutdown() } test("StoveCoroutineExecutor should execute commands") { val scope = CoroutineScope(Dispatchers.Default + Job()) val executor = scope.asExecutor val executed = AtomicBoolean(false) val latch = CountDownLatch(1) executor.execute { executed.set(true) latch.countDown() } latch.await(2, TimeUnit.SECONDS) shouldBe true executed.get() shouldBe true scope.cancel() } }) ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/tests/KafkaOptionsTests.kt ================================================ package com.trendyol.stove.kafka.tests import com.trendyol.stove.kafka.* import com.trendyol.stove.kafka.intercepting.StoveKafkaBridge import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldNotBeBlank class KafkaOptionsTests : FunSpec({ test("KafkaSystemOptions should have sensible defaults") { val options = object : KafkaSystemOptions( configureExposedConfiguration = { _ -> listOf() } ) {} options.useEmbeddedKafka shouldBe false options.topicSuffixes shouldBe TopicSuffixes() options.listenPublishedMessagesFromStove shouldBe false options.serde shouldNotBe null options.valueSerializer shouldNotBe null options.containerOptions shouldNotBe null } test("KafkaSystemOptions.provided should create ProvidedKafkaSystemOptions with correct config") { val options = KafkaSystemOptions.provided( bootstrapServers = "localhost:9092", configureExposedConfiguration = { cfg -> listOf("kafka.bootstrap-servers=${cfg.bootstrapServers}") } ) options.providedConfig.bootstrapServers shouldBe "localhost:9092" options.providedConfig.interceptorClass shouldBe StoveKafkaBridge::class.java.name options.runMigrationsForProvided shouldBe true options.useEmbeddedKafka.shouldBeFalse() } test("ProvidedKafkaSystemOptions should expose correct properties") { val config = KafkaExposedConfiguration( bootstrapServers = "broker1:9092,broker2:9092", interceptorClass = "com.example.Interceptor" ) val options = ProvidedKafkaSystemOptions( config = config, runMigrations = false, configureExposedConfiguration = { cfg -> listOf("servers=${cfg.bootstrapServers}") } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("KafkaExposedConfiguration should hold bootstrap servers and interceptor class") { val cfg = KafkaExposedConfiguration( bootstrapServers = "host1:9092", interceptorClass = "com.test.MyInterceptor" ) cfg.bootstrapServers shouldBe "host1:9092" cfg.interceptorClass shouldBe "com.test.MyInterceptor" } test("KafkaContainerOptions should have defaults") { val opts = KafkaContainerOptions() opts shouldNotBe null } test("stoveKafkaBridgePortDefault should return a valid port string") { stoveKafkaBridgePortDefault.shouldNotBeBlank() stoveKafkaBridgePortDefault.toInt() shouldNotBe 0 } }) ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/tests/KafkaSystemTests.kt ================================================ package com.trendyol.stove.kafka.tests import arrow.core.some import com.trendyol.stove.kafka.kafka import com.trendyol.stove.kafka.setup.example.DomainEvents.ProductCreated import com.trendyol.stove.kafka.setup.example.DomainEvents.ProductFailingCreated import com.trendyol.stove.system.stove import io.github.nomisRev.kafka.createTopic import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldNotContainAll import io.kotest.matchers.shouldBe import kotlinx.coroutines.* import org.apache.kafka.clients.admin.NewTopic import org.apache.kafka.clients.consumer.ConsumerRecord import kotlin.random.Random import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds class KafkaSystemTests : FunSpec({ val randomString = { Random.nextInt(0, Int.MAX_VALUE).toString() } test("migration should create test topics") { stove { kafka { adminOperations { val topics = listTopics().names().get() // Verify migration-created topics exist topics.contains("migration-test-topic") shouldBe true topics.contains("migration-test-topic-2") shouldBe true // Verify partition count val topicDescription = describeTopics(listOf("migration-test-topic-2")).allTopicNames().get() topicDescription["migration-test-topic-2"]?.partitions()?.size shouldBe 2 } } } } test("When publish then it should work") { stove { kafka { val key = randomString() val productId = "$key[productCreated]" publish("product", message = ProductCreated(productId), key = key.some()) shouldBePublished { actual.productId == productId } peekPublishedMessages(topic = "product") { it.key == key } shouldBeConsumed(1.minutes) { actual.productId == productId } peekConsumedMessages(topic = "product") { it.key == key } } } } test("lots of messages") { stove { kafka { val messages = ProductCreated.randoms(100) messages.map { async { publish("product", it, key = randomString().some()) } }.awaitAll() messages.map { async { shouldBePublished { actual.productId == it.productId } } }.awaitAll() messages.map { async { shouldBeConsumed(1.minutes) { actual.productId == it.productId } } }.awaitAll() peekConsumedMessages(topic = "product") { it.offset == 100L } peekCommittedMessages(topic = "product") { record -> record.offset == 101L // next offset } } } } test("When publish to a failing consumer should end-up throwing exception") { stove { kafka { val string = randomString() val productId = "$string[productFailingCreated]" val key = string.some() publish("productFailing", ProductFailingCreated(productId), key = key) shouldBeRetried(atLeastIn = 1.minutes, times = 3) { actual.productId == productId } shouldBePublished(atLeastIn = 1.minutes) { this.metadata.topic == "productFailing.error" } peekPublishedMessages(topic = "productFailing.error") { it.key == string } } } } test("in-flight consumer should commit the message after consuming it successfully") { stove { kafka { val key = randomString() val productId = "$key[productCreated]" val topic = randomString() adminOperations { createTopic(NewTopic(topic, 1, 1)) } publish(topic, message = ProductCreated(productId), key = key.some()) shouldBePublished { actual.productId == productId } consumer(topic, readOnly = false) { println(it) // it should commit } shouldBeConsumed { actual.productId == productId } } } } test("in-flight consumer: same consumer group after consuming successfully should not consume the same message again") { stove { kafka { val key = randomString() val productId = "$key[productCreated]" val topic = randomString() adminOperations { createTopic(NewTopic(topic, 1, 1)) } publish(topic, message = ProductCreated(productId), key = key.some()) shouldBePublished { actual.productId == productId } val consumerGroup1 = randomString() consumer(topic, readOnly = true, autoOffsetReset = "earliest", groupId = consumerGroup1) { println(it) } delay(3.seconds) val consumedMessages = this.messageStore().consumedMessages().filter { it.topic == topic } val committedMessages = this.messageStore().committedMessages().filter { it.topic == topic } committedMessages.map { it.offset } shouldNotContainAll consumedMessages.map { it.offset } // and consumer can re-read val reReadMessages = mutableListOf>() consumer(topic, readOnly = true, autoOffsetReset = "earliest", groupId = consumerGroup1) { reReadMessages.add(it) } reReadMessages.size shouldBe consumedMessages.size // and consumer can commit consumer(topic, readOnly = false, autoOffsetReset = "earliest", groupId = consumerGroup1) { println(it) } val committedMessagesAfterCommit = mutableListOf>() consumer(topic, readOnly = true, autoOffsetReset = "earliest", groupId = consumerGroup1) { committedMessagesAfterCommit.add(it) } committedMessagesAfterCommit.size shouldBe 0 } } } }) ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/tests/MessageStoreTests.kt ================================================ package com.trendyol.stove.kafka.tests import com.trendyol.stove.kafka.* import com.trendyol.stove.kafka.intercepting.MessageStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import okio.ByteString.Companion.EMPTY import okio.ByteString.Companion.toByteString class MessageStoreTests : FunSpec({ test("returns false when checking offset not yet committed") { val messageStore = MessageStore() val message1 = ConsumedMessage( id = "1", message = "message/1".toByteArray().toByteString(), topic = "topic", partition = 0, offset = 0, key = "key/1", headers = emptyMap(), unknownFields = EMPTY ) val message2 = ConsumedMessage( id = "2", message = "message/2".toByteArray().toByteString(), topic = "topic", partition = 0, offset = 1, key = "key/2", headers = emptyMap(), unknownFields = EMPTY ) messageStore.record( message1 ) messageStore.record( message2 ) val committedMessage1 = CommittedMessage( id = "1", topic = "topic", partition = 0, offset = message1.offset + 1, metadata = "", unknownFields = EMPTY ) messageStore.record(committedMessage1) messageStore.isCommitted( "topic", 0, 0 ) shouldBe true messageStore.isCommitted( "topic", 1, 0 ) shouldBe false } }) ================================================ FILE: lib/stove-kafka/src/test/kotlin/com/trendyol/stove/kafka/tests/TopicSuffixesTests.kt ================================================ package com.trendyol.stove.kafka.tests import com.trendyol.stove.kafka.TopicSuffixes import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class TopicSuffixesTests : FunSpec({ test("default error suffixes should match .error and .DLT") { val suffixes = TopicSuffixes() suffixes.isErrorTopic("my-topic.error") shouldBe true suffixes.isErrorTopic("my-topic.DLT") shouldBe true suffixes.isErrorTopic("my-topic") shouldBe false suffixes.isErrorTopic("my-topic.retry") shouldBe false } test("default retry suffixes should match .retry") { val suffixes = TopicSuffixes() suffixes.isRetryTopic("my-topic.retry") shouldBe true suffixes.isRetryTopic("my-topic") shouldBe false suffixes.isRetryTopic("my-topic.error") shouldBe false } test("error topic matching should be case-insensitive") { val suffixes = TopicSuffixes() suffixes.isErrorTopic("my-topic.ERROR") shouldBe true suffixes.isErrorTopic("my-topic.Error") shouldBe true suffixes.isErrorTopic("my-topic.dlt") shouldBe true } test("retry topic matching should be case-insensitive") { val suffixes = TopicSuffixes() suffixes.isRetryTopic("my-topic.RETRY") shouldBe true suffixes.isRetryTopic("my-topic.Retry") shouldBe true } test("custom suffixes should be used for matching") { val suffixes = TopicSuffixes( error = listOf(".dead-letter", ".failed"), retry = listOf(".retry-1", ".retry-2") ) suffixes.isErrorTopic("my-topic.dead-letter") shouldBe true suffixes.isErrorTopic("my-topic.failed") shouldBe true suffixes.isErrorTopic("my-topic.error") shouldBe false suffixes.isRetryTopic("my-topic.retry-1") shouldBe true suffixes.isRetryTopic("my-topic.retry-2") shouldBe true suffixes.isRetryTopic("my-topic.retry") shouldBe false } test("empty suffixes should never match") { val suffixes = TopicSuffixes(error = emptyList(), retry = emptyList()) suffixes.isErrorTopic("my-topic.error") shouldBe false suffixes.isRetryTopic("my-topic.retry") shouldBe false } }) ================================================ FILE: lib/stove-kafka/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.kafka.setup.StoveConfig ================================================ FILE: lib/stove-kafka/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: lib/stove-mongodb/api/stove-mongodb.api ================================================ public final class com/trendyol/stove/mongodb/DatabaseOptions { public fun ()V public fun (Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase;)V public synthetic fun (Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase; public final fun copy (Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase;)Lcom/trendyol/stove/mongodb/DatabaseOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/mongodb/DatabaseOptions;Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/DatabaseOptions; public fun equals (Ljava/lang/Object;)Z public final fun getDefault ()Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase; public static synthetic fun copy$default (Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/DatabaseOptions$DefaultDatabase; public fun equals (Ljava/lang/Object;)Z public final fun getCollection ()Ljava/lang/String; public final fun getName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mongodb/MongoContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mongodb/MongoContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/mongodb/MongoContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/MongoContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface annotation class com/trendyol/stove/mongodb/MongoDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/mongodb/MongodbContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/mongodb/MongodbSystemOptions; public final fun component3 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;Ljava/lang/String;)Lcom/trendyol/stove/mongodb/MongodbContext; public static synthetic fun copy$default (Lcom/trendyol/stove/mongodb/MongodbContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/MongodbContext; public fun equals (Ljava/lang/Object;)Z public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/mongodb/MongodbSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mongodb/MongodbExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()I public final fun component4 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getConnectionString ()Ljava/lang/String; public final fun getHost ()Ljava/lang/String; public final fun getPort ()I public final fun getReplicaSetUrl ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mongodb/MongodbMigrationContext { public fun (Lcom/mongodb/kotlin/client/coroutine/MongoClient;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;)V public final fun component1 ()Lcom/mongodb/kotlin/client/coroutine/MongoClient; public final fun component2 ()Lcom/trendyol/stove/mongodb/MongodbSystemOptions; public final fun copy (Lcom/mongodb/kotlin/client/coroutine/MongoClient;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;)Lcom/trendyol/stove/mongodb/MongodbMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/mongodb/MongodbMigrationContext;Lcom/mongodb/kotlin/client/coroutine/MongoClient;Lcom/trendyol/stove/mongodb/MongodbSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/MongodbMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getClient ()Lcom/mongodb/kotlin/client/coroutine/MongoClient; public final fun getOptions ()Lcom/trendyol/stove/mongodb/MongodbSystemOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mongodb/MongodbSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/mongodb/MongodbSystem$Companion; public static final field RESERVED_ID Ljava/lang/String; public field mongoClient Lcom/mongodb/kotlin/client/coroutine/MongoClient; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getContext ()Lcom/trendyol/stove/mongodb/MongodbContext; public final fun getMongoClient ()Lcom/mongodb/kotlin/client/coroutine/MongoClient; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setMongoClient (Lcom/mongodb/kotlin/client/coroutine/MongoClient;)V public final fun shouldDelete (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldDelete$default (Lcom/trendyol/stove/mongodb/MongodbSystem;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun shouldNotExist (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldNotExist$default (Lcom/trendyol/stove/mongodb/MongodbSystem;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/mongodb/MongodbSystem$Companion { public final fun client (Lcom/trendyol/stove/mongodb/MongodbSystem;)Lcom/mongodb/kotlin/client/coroutine/MongoClient; public final fun filterById (Ljava/lang/String;)Lorg/bson/conversions/Bson; } public class com/trendyol/stove/mongodb/MongodbSystemOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/mongodb/MongodbSystemOptions$Companion; public fun (Lcom/trendyol/stove/mongodb/DatabaseOptions;Lcom/trendyol/stove/mongodb/MongoContainerOptions;Lkotlin/jvm/functions/Function1;Lcom/trendyol/stove/serialization/StoveSerde;Lorg/bson/json/JsonWriterSettings;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/mongodb/DatabaseOptions;Lcom/trendyol/stove/mongodb/MongoContainerOptions;Lkotlin/jvm/functions/Function1;Lcom/trendyol/stove/serialization/StoveSerde;Lorg/bson/json/JsonWriterSettings;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureClient ()Lkotlin/jvm/functions/Function1; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/mongodb/MongoContainerOptions; public fun getDatabaseOptions ()Lcom/trendyol/stove/mongodb/DatabaseOptions; public fun getJsonWriterSettings ()Lorg/bson/json/JsonWriterSettings; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getSerde ()Lcom/trendyol/stove/serialization/StoveSerde; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mongodb/MongodbSystemOptions; } public final class com/trendyol/stove/mongodb/MongodbSystemOptions$Companion { public final fun provided (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/mongodb/DatabaseOptions;Lkotlin/jvm/functions/Function1;Lcom/trendyol/stove/serialization/StoveSerde;Lorg/bson/json/JsonWriterSettings;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mongodb/ProvidedMongodbSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/mongodb/MongodbSystemOptions$Companion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Lcom/trendyol/stove/mongodb/DatabaseOptions;Lkotlin/jvm/functions/Function1;Lcom/trendyol/stove/serialization/StoveSerde;Lorg/bson/json/JsonWriterSettings;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/mongodb/ProvidedMongodbSystemOptions; } public final class com/trendyol/stove/mongodb/ObjectIdDeserializer : com/fasterxml/jackson/databind/deser/std/StdDeserializer { public fun ()V public synthetic fun deserialize (Lcom/fasterxml/jackson/core/JsonParser;Lcom/fasterxml/jackson/databind/DeserializationContext;)Ljava/lang/Object; public fun deserialize (Lcom/fasterxml/jackson/core/JsonParser;Lcom/fasterxml/jackson/databind/DeserializationContext;)Lorg/bson/types/ObjectId; } public final class com/trendyol/stove/mongodb/ObjectIdModule : com/fasterxml/jackson/databind/module/SimpleModule { public fun ()V } public final class com/trendyol/stove/mongodb/ObjectIdSerializer : com/fasterxml/jackson/databind/ser/std/StdSerializer { public fun ()V public synthetic fun serialize (Ljava/lang/Object;Lcom/fasterxml/jackson/core/JsonGenerator;Lcom/fasterxml/jackson/databind/SerializerProvider;)V public fun serialize (Lorg/bson/types/ObjectId;Lcom/fasterxml/jackson/core/JsonGenerator;Lcom/fasterxml/jackson/databind/SerializerProvider;)V } public final class com/trendyol/stove/mongodb/OptionsKt { public static final fun mongodb-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mongodb-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mongodb-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun mongodb-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/mongodb/ProvidedMongodbSystemOptions : com/trendyol/stove/mongodb/MongodbSystemOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration;Lcom/trendyol/stove/mongodb/DatabaseOptions;Lkotlin/jvm/functions/Function1;Lcom/trendyol/stove/serialization/StoveSerde;Lorg/bson/json/JsonWriterSettings;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration;Lcom/trendyol/stove/mongodb/DatabaseOptions;Lkotlin/jvm/functions/Function1;Lcom/trendyol/stove/serialization/StoveSerde;Lorg/bson/json/JsonWriterSettings;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/mongodb/MongodbExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/mongodb/StoveMongoContainer : org/testcontainers/mongodb/MongoDBContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public final class com/trendyol/stove/mongodb/StoveMongoJsonWriterSettings { public static final field INSTANCE Lcom/trendyol/stove/mongodb/StoveMongoJsonWriterSettings; public final fun getObjectIdAsString ()Lorg/bson/json/JsonWriterSettings; } ================================================ FILE: lib/stove-mongodb/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.testcontainers.mongodb) implementation(libs.mongodb.kotlin.coroutine) implementation(libs.kotlinx.io.reactor.extensions) implementation(libs.kotlinx.reactive) implementation(libs.kotlinx.jdk8) implementation(libs.kotlinx.core) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided MongoDB instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting MongoDB tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-mongodb/src/main/kotlin/com/trendyol/stove/mongodb/MongoDsl.kt ================================================ package com.trendyol.stove.mongodb @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class MongoDsl ================================================ FILE: lib/stove-mongodb/src/main/kotlin/com/trendyol/stove/mongodb/MongodbSystem.kt ================================================ package com.trendyol.stove.mongodb import com.mongodb.* import com.mongodb.client.model.Filters.eq import com.mongodb.kotlin.client.coroutine.MongoClient import com.trendyol.stove.containers.StoveContainerInspectInformation import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.runBlocking import org.bson.* import org.bson.conversions.Bson import org.bson.types.ObjectId import org.slf4j.* /** * MongoDB document database system for testing document storage operations. * * Provides a DSL for testing MongoDB operations: * - Document CRUD operations (save, get, delete) * - MongoDB queries with JSON syntax * - Collection management * - Aggregation pipelines * * ## Saving Documents * * ```kotlin * mongodb { * // Save to default collection * save("user-123", User(id = "123", name = "John")) * * // Save to specific collection * save("user-123", User(id = "123", name = "John"), collection = "users") * } * ``` * * ## Retrieving Documents * * ```kotlin * mongodb { * // Get by ObjectId and assert * shouldGet("507f1f77bcf86cd799439011") { user -> * user.name shouldBe "John" * user.email shouldBe "john@example.com" * } * * // Get from specific collection * shouldGet("507f1f77bcf86cd799439011", collection = "users") { user -> * user.name shouldBe "John" * } * } * ``` * * ## Querying Documents * * ```kotlin * mongodb { * // Query with MongoDB JSON syntax * shouldQuery( * query = """{ "status": "active", "age": { "${"$"}gte": 18 } }""", * collection = "users" * ) { users -> * users.size shouldBeGreaterThan 0 * users.all { it.status == "active" } shouldBe true * } * * // Complex queries * shouldQuery( * query = """{ * "userId": "user-123", * "total": { "${"$"}gt": 100 }, * "status": { "${"$"}in": ["pending", "confirmed"] } * }""", * collection = "orders" * ) { orders -> * orders shouldHaveSize 2 * } * } * ``` * * ## Deleting Documents * * ```kotlin * mongodb { * shouldDelete("507f1f77bcf86cd799439011") * shouldDelete("507f1f77bcf86cd799439011", collection = "users") * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should create user via API and store in MongoDB") { * stove { * // Create user via API * val userId: String * http { * postAndExpectBody( * uri = "/users", * body = CreateUserRequest(name = "John").some() * ) { response -> * response.status shouldBe 201 * userId = response.body().id * } * } * * // Verify in MongoDB * mongodb { * shouldGet(userId, collection = "users") { user -> * user.name shouldBe "John" * user.createdAt shouldNotBe null * } * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * mongodb { * MongodbSystemOptions( * databaseOptions = DatabaseOptions( * default = DefaultDatabase( * name = "my_database", * collection = "default_collection" * ) * ), * configureExposedConfiguration = { cfg -> * listOf( * "spring.data.mongodb.uri=${cfg.connectionString}" * ) * } * ).migrations { * register() * } * } * } * ``` * * @property stove The parent test system. * @property context MongoDB context containing database options. * @see MongodbSystemOptions * @see MongodbExposedConfiguration */ @MongoDsl class MongodbSystem internal constructor( override val stove: Stove, val context: MongodbContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var mongoClient: MongoClient override val reportSystemName: String = "MongoDB" + (context.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: MongodbExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(context.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() mongoClient = createClient(exposedConfiguration) runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) override fun close(): Unit = runBlocking { Try { context.options.cleanup(mongoClient) mongoClient.close() executeWithReuseCheck { stop() } }.recover { logger.warn("Closing mongodb got an error: $it") } } suspend inline fun shouldQuery( query: String, collection: String = context.options.databaseOptions.default.collection, crossinline assertion: (List) -> Unit ): MongodbSystem { report( action = "Query '$collection'", input = arrow.core.Some(mapOf("collection" to collection, "filter" to query)) ) { val results = mongoClient .getDatabase(context.options.databaseOptions.default.name) .getCollection(collection) .find(BsonDocument.parse(query)) .map { context.options.serde.deserialize(it.toJson(context.options.jsonWriterSettings), T::class.java) } .toList() assertion(results) results } return this } suspend inline fun shouldGet( objectId: String, collection: String = context.options.databaseOptions.default.collection, crossinline assertion: (T) -> Unit ): MongodbSystem { report( action = "Get document", input = arrow.core.Some(mapOf("collection" to collection, "_id" to objectId)) ) { val document = mongoClient .getDatabase(context.options.databaseOptions.default.name) .getCollection(collection) .find(filterById(objectId)) .map { context.options.serde.deserialize(it.toJson(context.options.jsonWriterSettings), T::class.java) } .first() assertion(document) document } return this } /** * Saves the [instance] with given [objectId] to the [collection] */ suspend inline fun save( instance: T, objectId: String = ObjectId().toHexString(), collection: String = context.options.databaseOptions.default.collection ): MongodbSystem { report( action = "Insert document", input = arrow.core.Some(instance), metadata = mapOf("collection" to collection, "_id" to objectId) ) { mongoClient .getDatabase(context.options.databaseOptions.default.name) .getCollection(collection) .also { coll -> context.options.serde .serialize(instance) .let { BsonDocument.parse(it) } .let { doc -> Document(doc) } .append(RESERVED_ID, ObjectId(objectId)) .let { coll.insertOne(it) } } } return this } suspend fun shouldNotExist( objectId: String, collection: String = context.options.databaseOptions.default.collection ): MongodbSystem { report( action = "Document should not exist", input = arrow.core.Some(mapOf("collection" to collection, "_id" to objectId)), expected = arrow.core.Some("Document not found") ) { val exists = mongoClient .getDatabase(context.options.databaseOptions.default.name) .getCollection(collection) .find(filterById(objectId)) .firstOrNull() != null if (exists) throw AssertionError("The document with the given id($objectId) was not expected, but found!") } return this } suspend fun shouldDelete( objectId: String, collection: String = context.options.databaseOptions.default.collection ): MongodbSystem { report( action = "Delete document", metadata = mapOf("collection" to collection, "_id" to objectId) ) { mongoClient .getDatabase(context.options.databaseOptions.default.name) .getCollection(collection) .deleteOne(filterById(objectId)) } return this } /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return MongodbSystem */ suspend fun pause(): MongodbSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return MongodbSystem */ suspend fun unpause(): MongodbSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } /** * Inspects the container. This operation is not supported when using a provided instance. */ fun inspect(): StoveContainerInspectInformation? = when (val runtime = context.runtime) { is StoveMongoContainer -> { runtime.inspect() } is ProvidedRuntime -> { logger.warn("inspect() is not supported when using a provided instance") null } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private suspend fun obtainExposedConfiguration(): MongodbExposedConfiguration = when { context.options is ProvidedMongodbSystemOptions -> context.options.config context.runtime is StoveMongoContainer -> startMongoContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startMongoContainer(container: StoveMongoContainer): MongodbExposedConfiguration = state.capture { container.start() MongodbExposedConfiguration( connectionString = container.connectionString, host = container.host, port = container.firstMappedPort, replicaSetUrl = container.replicaSetUrl ) } private fun createClient(config: MongodbExposedConfiguration): MongoClient = MongoClientSettings .builder() .applyConnectionString(ConnectionString(config.connectionString)) .retryWrites(true) .readConcern(ReadConcern.MAJORITY) .writeConcern(WriteConcern.MAJORITY) .apply(context.options.configureClient) .build() .let { MongoClient.create(it) } private inline fun withContainerOrWarn( operation: String, action: (StoveMongoContainer) -> Unit ): MongodbSystem = when (val runtime = context.runtime) { is StoveMongoContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveMongoContainer) -> Unit) { if (context.runtime is StoveMongoContainer) { action(context.runtime) } } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { context.options.migrationCollection.run(MongodbMigrationContext(mongoClient, context.options)) } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedMongodbSystemOptions -> context.options.runMigrations context.runtime is StoveMongoContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } companion object { const val RESERVED_ID = "_id" @PublishedApi internal fun filterById(key: String): Bson = eq(RESERVED_ID, ObjectId(key)) /** * Exposes the [MongoClient] to the [MongodbSystem]. * Use this for advanced MongoDB operations not covered by the DSL. */ @Suppress("unused") fun MongodbSystem.client(): MongoClient = mongoClient } } ================================================ FILE: lib/stove-mongodb/src/main/kotlin/com/trendyol/stove/mongodb/MongodbSystemOptions.kt ================================================ package com.trendyol.stove.mongodb import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.module.kotlin.KotlinModule import com.mongodb.MongoClientSettings import com.mongodb.kotlin.client.coroutine.MongoClient import com.trendyol.stove.database.migrations.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.bson.json.JsonWriterSettings /** * Context provided to MongoDB migrations. * Contains the MongoDB client and options for performing setup operations. * * @property client The MongoDB client for executing operations * @property options The MongoDB system options */ @StoveDsl data class MongodbMigrationContext( val client: MongoClient, val options: MongodbSystemOptions ) /** * Convenience type alias for MongoDB migrations. * * Instead of writing `DatabaseMigration`, use `MongodbMigration`: * ```kotlin * class MyMigration : MongodbMigration { * override val order: Int = 1 * override suspend fun execute(connection: MongodbMigrationContext) { ... } * } * ``` */ typealias MongodbMigration = DatabaseMigration /** * Options for configuring the MongoDB system in container mode. */ @StoveDsl open class MongodbSystemOptions( open val databaseOptions: DatabaseOptions = DatabaseOptions(), open val container: MongoContainerOptions = MongoContainerOptions(), open val configureClient: (MongoClientSettings.Builder) -> Unit = { }, open val serde: StoveSerde = StoveSerde.jackson.anyJsonStringSerde( StoveSerde.jackson.byConfiguring { disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(MapperFeature.DEFAULT_VIEW_INCLUSION) addModule(ObjectIdModule()) addModule(KotlinModule.Builder().build()) } ), open val jsonWriterSettings: JsonWriterSettings = StoveMongoJsonWriterSettings.objectIdAsString, open val cleanup: suspend (MongoClient) -> Unit = {}, override val configureExposedConfiguration: (MongodbExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided MongoDB instance * instead of a testcontainer. * * @param connectionString The MongoDB connection string * @param host The MongoDB host * @param port The MongoDB port * @param replicaSetUrl The MongoDB replica set URL (defaults to connectionString) * @param databaseOptions Database options configuration * @param configureClient Client configuration function * @param serde Serialization/deserialization configuration * @param jsonWriterSettings JSON writer settings * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( connectionString: String, host: String, port: Int, replicaSetUrl: String = connectionString, databaseOptions: DatabaseOptions = DatabaseOptions(), configureClient: (MongoClientSettings.Builder) -> Unit = { }, serde: StoveSerde = StoveSerde.jackson.anyJsonStringSerde( StoveSerde.jackson.byConfiguring { disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(MapperFeature.DEFAULT_VIEW_INCLUSION) addModule(ObjectIdModule()) addModule(KotlinModule.Builder().build()) } ), jsonWriterSettings: JsonWriterSettings = StoveMongoJsonWriterSettings.objectIdAsString, runMigrations: Boolean = true, cleanup: suspend (MongoClient) -> Unit = {}, configureExposedConfiguration: (MongodbExposedConfiguration) -> List ): ProvidedMongodbSystemOptions = ProvidedMongodbSystemOptions( config = MongodbExposedConfiguration( connectionString = connectionString, host = host, port = port, replicaSetUrl = replicaSetUrl ), databaseOptions = databaseOptions, configureClient = configureClient, serde = serde, jsonWriterSettings = jsonWriterSettings, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided MongoDB instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedMongodbSystemOptions( /** * The configuration for the provided MongoDB instance. */ val config: MongodbExposedConfiguration, databaseOptions: DatabaseOptions = DatabaseOptions(), configureClient: (MongoClientSettings.Builder) -> Unit = { }, serde: StoveSerde = StoveSerde.jackson.anyJsonStringSerde( StoveSerde.jackson.byConfiguring { disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) enable(MapperFeature.DEFAULT_VIEW_INCLUSION) addModule(ObjectIdModule()) addModule(KotlinModule.Builder().build()) } ), jsonWriterSettings: JsonWriterSettings = StoveMongoJsonWriterSettings.objectIdAsString, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, cleanup: suspend (MongoClient) -> Unit = {}, configureExposedConfiguration: (MongodbExposedConfiguration) -> List ) : MongodbSystemOptions( databaseOptions = databaseOptions, container = MongoContainerOptions(), configureClient = configureClient, serde = serde, jsonWriterSettings = jsonWriterSettings, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: MongodbExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } object StoveMongoJsonWriterSettings { val objectIdAsString: JsonWriterSettings = JsonWriterSettings .builder() .objectIdConverter { value, writer -> writer.writeString(value.toHexString()) } .build() } ================================================ FILE: lib/stove-mongodb/src/main/kotlin/com/trendyol/stove/mongodb/ObjectIdJsonOperations.kt ================================================ package com.trendyol.stove.mongodb import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.node.TextNode import com.fasterxml.jackson.databind.ser.std.StdSerializer import org.bson.types.ObjectId class ObjectIdSerializer : StdSerializer(ObjectId::class.java) { override fun serialize( value: ObjectId, gen: JsonGenerator, provider: SerializerProvider ): Unit = gen.writeString(value.toHexString()) } class ObjectIdDeserializer : StdDeserializer(ObjectId::class.java) { override fun deserialize( parser: JsonParser, context: DeserializationContext ): ObjectId = when (val node = context.parser.codec.readValue(parser, JsonNode::class.java)) { is TextNode -> node.textValue().removeSurrounding("\"") is JsonNode -> node["\$oid"].textValue().removeSurrounding("\"") else -> throw IllegalArgumentException( "ObjectId (\$oid) could not be deserialized, this is because JsonNode is not properly recognized." ) }.let { ObjectId(it) } } class ObjectIdModule : SimpleModule() { init { addSerializer(ObjectId::class.java, ObjectIdSerializer()) addDeserializer(ObjectId::class.java, ObjectIdDeserializer()) } } ================================================ FILE: lib/stove-mongodb/src/main/kotlin/com/trendyol/stove/mongodb/Options.kt ================================================ package com.trendyol.stove.mongodb import arrow.core.getOrElse import com.trendyol.stove.containers.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.testcontainers.mongodb.MongoDBContainer import org.testcontainers.utility.DockerImageName @StoveDsl data class MongodbExposedConfiguration( val connectionString: String, val host: String, val port: Int, val replicaSetUrl: String ) : ExposedConfiguration @StoveDsl data class MongodbContext( val runtime: SystemRuntime, val options: MongodbSystemOptions, val keyName: String? = null ) open class StoveMongoContainer( override val imageNameAccess: DockerImageName ) : MongoDBContainer(imageNameAccess), StoveContainer @StoveDsl data class MongoContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = "mongo", override val tag: String = "latest", override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveMongoContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions @StoveDsl data class DatabaseOptions( val default: DefaultDatabase = DefaultDatabase() ) { data class DefaultDatabase( val name: String = "stove", val collection: String = "stoveCollection" ) } internal fun Stove.withMongodb( options: MongodbSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(MongodbSystem(this, MongodbContext(runtime, options))) return this } internal fun Stove.withMongodb( key: SystemKey, options: MongodbSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, MongodbSystem(this, MongodbContext(runtime, options, keyName = keyDisplayName(key)))) return this } internal fun Stove.mongodb(): MongodbSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(MongodbSystem::class) } internal fun Stove.mongodb(key: SystemKey): MongodbSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(MongodbSystem::class, "No MongodbSystem registered with key '${keyDisplayName(key)}'") } /** * Configures MongoDB system. * * For container-based setup: * ```kotlin * mongodb { * MongodbSystemOptions( * cleanup = { client -> client.getDatabase("mydb").drop() }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * mongodb { * MongodbSystemOptions.provided( * connectionString = "mongodb://localhost:27017", * host = "localhost", * port = 27017, * cleanup = { client -> client.getDatabase("mydb").drop() }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.mongodb( configure: () -> MongodbSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedMongodbSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveMongoContainer } .apply(options.container.containerFn) } } return stove.withMongodb(options, runtime) } fun WithDsl.mongodb( key: SystemKey, configure: () -> MongodbSystemOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedMongodbSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveMongoContainer } .apply(options.container.containerFn) } } return stove.withMongodb(key, options, runtime) } suspend fun ValidationDsl.mongodb( validation: @MongoDsl suspend MongodbSystem.() -> Unit ): Unit = validation(this.stove.mongodb()) suspend fun ValidationDsl.mongodb( key: SystemKey, validation: @MongoDsl suspend MongodbSystem.() -> Unit ): Unit = validation(this.stove.mongodb(key)) ================================================ FILE: lib/stove-mongodb/src/test/kotlin/com/trendyol/stove/mongodb/MongodbOptionsTests.kt ================================================ package com.trendyol.stove.mongodb import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class MongodbOptionsTests : FunSpec({ test("MongodbExposedConfiguration should hold connection details") { val cfg = MongodbExposedConfiguration( connectionString = "mongodb://localhost:27017", host = "localhost", port = 27017, replicaSetUrl = "mongodb://localhost:27017/replicaSet" ) cfg.connectionString shouldBe "mongodb://localhost:27017" cfg.host shouldBe "localhost" cfg.port shouldBe 27017 cfg.replicaSetUrl shouldBe "mongodb://localhost:27017/replicaSet" } test("MongodbSystemOptions.provided should create ProvidedMongodbSystemOptions") { val options = MongodbSystemOptions.provided( connectionString = "mongodb://localhost:27017", host = "localhost", port = 27017, configureExposedConfiguration = { cfg -> listOf("mongo.uri=${cfg.connectionString}") } ) options.providedConfig.connectionString shouldBe "mongodb://localhost:27017" options.providedConfig.host shouldBe "localhost" options.providedConfig.port shouldBe 27017 options.providedConfig.replicaSetUrl shouldBe "mongodb://localhost:27017" options.runMigrationsForProvided shouldBe true } test("ProvidedMongodbSystemOptions should expose correct properties") { val config = MongodbExposedConfiguration( connectionString = "mongodb://remote:27017", host = "remote", port = 27017, replicaSetUrl = "mongodb://remote:27017" ) val options = ProvidedMongodbSystemOptions( config = config, runMigrations = false, configureExposedConfiguration = { _ -> listOf("test=true") } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("MongodbSystemOptions should have sensible defaults") { val options = object : MongodbSystemOptions( configureExposedConfiguration = { _ -> listOf() } ) {} options.databaseOptions shouldNotBe null options.container shouldNotBe null options.serde shouldNotBe null options.jsonWriterSettings shouldNotBe null } test("StoveMongoJsonWriterSettings.objectIdAsString should be configured") { StoveMongoJsonWriterSettings.objectIdAsString shouldNotBe null } }) ================================================ FILE: lib/stove-mongodb/src/test/kotlin/com/trendyol/stove/mongodb/MongodbTestSystemTests.kt ================================================ package com.trendyol.stove.mongodb import com.fasterxml.jackson.annotation.JsonAlias import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.FunSpec import io.kotest.inspectors.forAny import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay import org.bson.codecs.pojo.annotations.* import org.bson.types.ObjectId import org.junit.jupiter.api.assertThrows import org.slf4j.* import org.testcontainers.mongodb.MongoDBContainer import org.testcontainers.utility.DockerImageName // ============================================================================ // Shared components // ============================================================================ class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } // ============================================================================ // Strategy interface // ============================================================================ sealed interface MongodbTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): MongodbTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedMongodbStrategy() else ContainerMongodbStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerMongodbStrategy : MongodbTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting MongoDB tests with container mode") val options = MongodbSystemOptions { listOf() } Stove() .with { mongodb { options } applicationUnderTest(NoOpApplication()) }.run() // Test pause/unpause functionality stove { mongodb { logger.info("pausing...") pause() delay(1000) logger.info("unpausing...") unpause() delay(1000) logger.info("operating normally...") logger.info(inspect().toString()) } } } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("MongoDB container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedMongodbStrategy : MongodbTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: MongoDBContainer override suspend fun start() { logger.info("Starting MongoDB tests with provided mode") // Start an external container to simulate a provided instance externalContainer = MongoDBContainer(DockerImageName.parse("mongo:7.0")) .apply { start() } logger.info("External MongoDB container started at ${externalContainer.connectionString}") val options = MongodbSystemOptions .provided( connectionString = externalContainer.connectionString, host = externalContainer.host, port = externalContainer.firstMappedPort, runMigrations = true, cleanup = { _ -> logger.info("Running cleanup on provided instance") // Clean up test data if needed }, configureExposedConfiguration = { _ -> listOf() } ) Stove() .with { mongodb { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("MongoDB provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = MongodbTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } // ============================================================================ // Tests // ============================================================================ class MongodbTestSystemTests : FunSpec({ data class ExampleInstanceWithObjectId( @param:BsonId @param:JsonAlias("_id") val id: ObjectId, @param:BsonProperty("aggregateId") val aggregateId: String, @param:BsonProperty("description") val description: String ) data class ExampleInstanceWithStringObjectId( @param:JsonAlias("_id") val id: String, @param:BsonProperty("aggregateId") val aggregateId: String, @param:BsonProperty("description") val description: String ) test("should save and get with objectId") { val id = ObjectId() stove { mongodb { save( ExampleInstanceWithObjectId( id = id, aggregateId = id.toHexString(), description = testCase.name.name ), id.toHexString() ) shouldGet(id.toHexString()) { actual -> actual.aggregateId shouldBe id.toHexString() actual.description shouldBe testCase.name.name } } } } test("should save and get with string objectId") { val id = ObjectId() stove { mongodb { save( ExampleInstanceWithStringObjectId( id = id.toHexString(), aggregateId = id.toHexString(), description = testCase.name.name ), id.toHexString() ) shouldGet(id.toHexString()) { actual -> actual.aggregateId shouldBe id.toHexString() actual.description shouldBe testCase.name.name } } } } data class ExampleInstanceWithObjectIdForQuery( val id: String, val description: String ) test("Get with query should work") { val id1 = ObjectId() val id2 = ObjectId() val id3 = ObjectId() val firstDesc = "same description" val secondDesc = "different description" stove { mongodb { save( ExampleInstanceWithObjectId( id = id1, aggregateId = id1.toHexString(), description = firstDesc ), id1.toHexString() ) save( ExampleInstanceWithObjectId( id = id2, aggregateId = id2.toHexString(), description = secondDesc ), id2.toHexString() ) save( ExampleInstanceWithObjectId( id = id3, aggregateId = id3.toHexString(), description = secondDesc ), id3.toHexString() ) shouldQuery("{\"description\": \"$secondDesc\"}") { actual -> actual.count() shouldBe 2 actual.forAny { it.id shouldBe id2.toHexString() } actual.forAny { it.id shouldBe id3.toHexString() } } shouldQuery("{\"description\": \"$firstDesc\"}") { actual -> actual.count() shouldBe 1 actual.first().id shouldBe id1 } } } } test("should throw assertion error when document does exist") { val id1 = ObjectId() stove { mongodb { save( ExampleInstanceWithObjectId( id = id1, aggregateId = id1.toHexString(), description = testCase.name.name + "1" ), id1.toHexString() ) shouldGet(id1.toHexString()) { actual -> actual.aggregateId shouldBe id1.toHexString() } assertThrows { shouldNotExist(id1.toHexString()) } } } } test("should not throw exception when given does not exist id") { val notExistDocId = ObjectId() stove { mongodb { shouldNotExist(notExistDocId.toHexString()) } } } test("should delete") { val id = ObjectId() stove { mongodb { save( ExampleInstanceWithObjectId( id = id, aggregateId = id.toHexString(), description = testCase.name.name ), id.toHexString() ) shouldQuery("{\"aggregateId\": \"${id.toHexString()}\"}") { actual -> actual.size shouldBe 1 } shouldDelete(id.toHexString()) shouldQuery("{\"aggregateId\": \"${id.toHexString()}\"}") { actual -> actual.size shouldBe 0 } } } } test("complex type") { data class Nested( val id: String, val name: String ) data class ComplexType( val id: String, val name: String, val nested: Nested ) val id = ObjectId() val nestedId = ObjectId() stove { mongodb { save( ComplexType( id = id.toHexString(), name = "name", nested = Nested( id = nestedId.toHexString(), name = "nested" ) ), id.toHexString() ) shouldGet(id.toHexString()) { actual -> actual.id shouldBe id.toHexString() actual.name shouldBe "name" actual.nested.id shouldBe actual.nested.id actual.nested.name shouldBe "nested" } shouldQuery( query = "{\"nested.id\": \"${nestedId.toHexString()}\"}" ) { actual -> actual.size shouldBe 1 actual.first().id shouldBe id.toHexString() } } } } }) ================================================ FILE: lib/stove-mongodb/src/test/kotlin/com/trendyol/stove/mongodb/ObjectIdJsonOperationsTest.kt ================================================ package com.trendyol.stove.mongodb import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.readValue import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.bson.types.ObjectId class ObjectIdJsonOperationsTest : FunSpec({ val mapper = ObjectMapper() .registerModule(KotlinModule.Builder().build()) .registerModule(ObjectIdModule()) test("ObjectIdSerializer should serialize ObjectId to hex string") { val objectId = ObjectId("507f1f77bcf86cd799439011") val container = ObjectIdContainer(objectId) val json = mapper.writeValueAsString(container) json shouldBe """{"id":"507f1f77bcf86cd799439011"}""" } test("ObjectIdDeserializer should deserialize hex string to ObjectId") { val json = """{"id":"507f1f77bcf86cd799439011"}""" val container = mapper.readValue(json) container.id shouldBe ObjectId("507f1f77bcf86cd799439011") } test("ObjectIdDeserializer should deserialize MongoDB extended JSON format") { val json = "{\"id\":{\"\$oid\":\"507f1f77bcf86cd799439011\"}}" val container = mapper.readValue(json) container.id shouldBe ObjectId("507f1f77bcf86cd799439011") } test("round-trip serialization should preserve ObjectId") { val original = ObjectIdContainer(ObjectId("507f1f77bcf86cd799439011")) val json = mapper.writeValueAsString(original) val deserialized = mapper.readValue(json) deserialized.id shouldBe original.id } test("ObjectIdModule should register both serializer and deserializer") { val freshMapper = ObjectMapper() .registerModule(KotlinModule.Builder().build()) .registerModule(ObjectIdModule()) val objectId = ObjectId() val json = freshMapper.writeValueAsString(ObjectIdContainer(objectId)) val result = freshMapper.readValue(json) result.id shouldBe objectId } test("should handle multiple ObjectId fields") { val id1 = ObjectId("507f1f77bcf86cd799439011") val id2 = ObjectId("507f191e810c19729de860ea") val container = MultiObjectIdContainer(id1, id2) val json = mapper.writeValueAsString(container) val result = mapper.readValue(json) result.first shouldBe id1 result.second shouldBe id2 } }) data class ObjectIdContainer( val id: ObjectId ) data class MultiObjectIdContainer( val first: ObjectId, val second: ObjectId ) ================================================ FILE: lib/stove-mongodb/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.mongodb.StoveConfig ================================================ FILE: lib/stove-mongodb/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: lib/stove-mssql/api/stove-mssql.api ================================================ public final class com/trendyol/stove/mssql/MsSqlContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mssql/MsSqlOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mssql/MsSqlOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/mssql/MsSqlOptions; public final fun component3 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mssql/MsSqlOptions;Ljava/lang/String;)Lcom/trendyol/stove/mssql/MsSqlContext; public static synthetic fun copy$default (Lcom/trendyol/stove/mssql/MsSqlContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/mssql/MsSqlOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/mssql/MsSqlContext; public fun equals (Ljava/lang/Object;)Z public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/mssql/MsSqlOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public class com/trendyol/stove/mssql/MsSqlOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/mssql/MsSqlOptions$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mssql/MssqlContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mssql/MssqlContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getApplicationName ()Ljava/lang/String; public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/mssql/MssqlContainerOptions; public fun getDatabaseName ()Ljava/lang/String; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getPassword ()Ljava/lang/String; public fun getUserName ()Ljava/lang/String; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mssql/MsSqlOptions; } public final class com/trendyol/stove/mssql/MsSqlOptions$Companion { public final fun provided (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mssql/ProvidedMsSqlOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/mssql/MsSqlOptions$Companion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/mssql/ProvidedMsSqlOptions; } public final class com/trendyol/stove/mssql/MsSqlOptionsKt { public static final fun mssql-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mssql-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mssql-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun mssql-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/mssql/MsSqlSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/mssql/MsSqlSystem$Companion; public field sqlOperations Lcom/trendyol/stove/rdbms/NativeSqlOperations; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public final fun getSqlOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun ops (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setSqlOperations (Lcom/trendyol/stove/rdbms/NativeSqlOperations;)V public final fun shouldExecute (Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldExecute$default (Lcom/trendyol/stove/mssql/MsSqlSystem;Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/mssql/MsSqlSystem$Companion { public final fun operations (Lcom/trendyol/stove/mssql/MsSqlSystem;)Lcom/trendyol/stove/rdbms/NativeSqlOperations; } public final class com/trendyol/stove/mssql/MssqlContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mssql/ToolsPath;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mssql/ToolsPath;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lcom/trendyol/stove/mssql/ToolsPath; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mssql/ToolsPath;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mssql/MssqlContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/mssql/MssqlContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mssql/ToolsPath;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/mssql/MssqlContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public final fun getToolsPath ()Lcom/trendyol/stove/mssql/ToolsPath; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mssql/ProvidedMsSqlOptions : com/trendyol/stove/mssql/MsSqlOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public final class com/trendyol/stove/mssql/SqlMigrationContext { public fun (Lcom/trendyol/stove/mssql/MsSqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;)V public final fun component1 ()Lcom/trendyol/stove/mssql/MsSqlOptions; public final fun component2 ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun copy (Lcom/trendyol/stove/mssql/MsSqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/mssql/SqlMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/mssql/SqlMigrationContext;Lcom/trendyol/stove/mssql/MsSqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/mssql/SqlMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getExecuteAsRoot ()Lkotlin/jvm/functions/Function2; public final fun getOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun getOptions ()Lcom/trendyol/stove/mssql/MsSqlOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public class com/trendyol/stove/mssql/StoveMsSqlContainer : org/testcontainers/mssqlserver/MSSQLServerContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public abstract class com/trendyol/stove/mssql/ToolsPath { public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getPath ()Ljava/lang/String; public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mssql/ToolsPath$After2019 : com/trendyol/stove/mssql/ToolsPath { public static final field INSTANCE Lcom/trendyol/stove/mssql/ToolsPath$After2019; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mssql/ToolsPath$Before2019 : com/trendyol/stove/mssql/ToolsPath { public static final field INSTANCE Lcom/trendyol/stove/mssql/ToolsPath$Before2019; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mssql/ToolsPath$Custom : com/trendyol/stove/mssql/ToolsPath { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;)Lcom/trendyol/stove/mssql/ToolsPath$Custom; public static synthetic fun copy$default (Lcom/trendyol/stove/mssql/ToolsPath$Custom;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/mssql/ToolsPath$Custom; public fun equals (Ljava/lang/Object;)Z public fun getPath ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } ================================================ FILE: lib/stove-mssql/build.gradle.kts ================================================ dependencies { api(projects.lib.stoveRdbms) api(libs.testcontainers.mssql) api(libs.microsoft.sqlserver.jdbc) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided MSSQL instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting MSSQL tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-mssql/src/main/kotlin/com/trendyol/stove/mssql/MsSqlOptions.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.mssql import arrow.core.getOrElse import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.rdbms.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.testcontainers.utility.DockerImageName sealed class ToolsPath( open val path: String ) { data object Before2019 : ToolsPath("mssql-tools") data object After2019 : ToolsPath("mssql-tools18") data class Custom( override val path: String ) : ToolsPath(path) override fun toString(): String = path } open class StoveMsSqlContainer( override val imageNameAccess: DockerImageName ) : org.testcontainers.mssqlserver.MSSQLServerContainer(imageNameAccess), StoveContainer data class MssqlContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = org.testcontainers.mssqlserver.MSSQLServerContainer.IMAGE, override val tag: String = "2022-latest", override val compatibleSubstitute: String? = null, /** * There is a breaking change introduced in the mssql-tools path after 2019. * Depending on your tag, you may need to set this value. */ val toolsPath: ToolsPath = ToolsPath.After2019, override val useContainerFn: UseContainerFn = { StoveMsSqlContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions /** * Options for configuring the MSSQL system in container mode. */ @StoveDsl open class MsSqlOptions( open val applicationName: String, open val databaseName: String, open val userName: String, open val password: String, open val container: MssqlContainerOptions = MssqlContainerOptions(), open val cleanup: suspend (NativeSqlOperations) -> Unit = {}, override val configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided MSSQL instance * instead of a testcontainer. * * @param jdbcUrl The JDBC URL for the MSSQL instance * @param host The host of the MSSQL instance * @param port The port of the MSSQL instance * @param applicationName The application name * @param databaseName The database name * @param userName The username for authentication * @param password The password for authentication * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( jdbcUrl: String, host: String, port: Int, applicationName: String, databaseName: String, userName: String, password: String, runMigrations: Boolean = true, cleanup: suspend (NativeSqlOperations) -> Unit = {}, configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ): ProvidedMsSqlOptions = ProvidedMsSqlOptions( config = RelationalDatabaseExposedConfiguration( jdbcUrl = jdbcUrl, host = host, port = port, username = userName, password = password ), applicationName = applicationName, databaseName = databaseName, userName = userName, password = password, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided MSSQL instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedMsSqlOptions( /** * The configuration for the provided MSSQL instance. */ val config: RelationalDatabaseExposedConfiguration, applicationName: String, databaseName: String, userName: String, password: String, cleanup: suspend (NativeSqlOperations) -> Unit = {}, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) : MsSqlOptions( applicationName = applicationName, databaseName = databaseName, userName = userName, password = password, container = MssqlContainerOptions(), cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: RelationalDatabaseExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } @StoveDsl data class SqlMigrationContext( val options: MsSqlOptions, val operations: NativeSqlOperations, val executeAsRoot: suspend (String) -> Unit ) /** * Convenience type alias for MSSQL migrations. * * Instead of writing `DatabaseMigration`, use `MsSqlMigration`: * ```kotlin * class MyMigration : MsSqlMigration { * override val order: Int = 1 * override suspend fun execute(connection: SqlMigrationContext) { ... } * } * ``` */ typealias MsSqlMigration = DatabaseMigration @StoveDsl data class MsSqlContext( val runtime: SystemRuntime, val options: MsSqlOptions, val keyName: String? = null ) internal fun Stove.withMsSql( options: MsSqlOptions, runtime: SystemRuntime ): Stove { getOrRegister(MsSqlSystem(this, MsSqlContext(runtime, options))) return this } internal fun Stove.withMsSql( key: SystemKey, options: MsSqlOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, MsSqlSystem(this, MsSqlContext(runtime, options, keyName = keyDisplayName(key)))) return this } internal fun Stove.mssql(): MsSqlSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(MsSqlSystem::class) } internal fun Stove.mssql(key: SystemKey): MsSqlSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(MsSqlSystem::class, "No MsSqlSystem registered with key '${keyDisplayName(key)}'") } /** * Configures MSSQL system. * * For container-based setup: * ```kotlin * mssql { * MsSqlOptions( * applicationName = "myapp", * databaseName = "mydb", * userName = "sa", * password = "password", * cleanup = { ops -> ops.execute("TRUNCATE TABLE ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * mssql { * MsSqlOptions.provided( * jdbcUrl = "jdbc:sqlserver://localhost:1433;databaseName=mydb", * host = "localhost", * port = 1433, * applicationName = "myapp", * databaseName = "mydb", * userName = "sa", * password = "password", * runMigrations = true, * cleanup = { ops -> ops.execute("TRUNCATE TABLE ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.mssql( configure: () -> MsSqlOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedMsSqlOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .acceptLicense() .withEnv("MSSQL_USER", options.userName) .withEnv("MSSQL_SA_PASSWORD", options.password) .withEnv("MSSQL_DB", options.databaseName) .withPassword(options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveMsSqlContainer } .apply(options.container.containerFn) } } return stove.withMsSql(options, runtime) } fun WithDsl.mssql( key: SystemKey, configure: () -> MsSqlOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedMsSqlOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .acceptLicense() .withEnv("MSSQL_USER", options.userName) .withEnv("MSSQL_SA_PASSWORD", options.password) .withEnv("MSSQL_DB", options.databaseName) .withPassword(options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveMsSqlContainer } .apply(options.container.containerFn) } } return stove.withMsSql(key, options, runtime) } suspend fun ValidationDsl.mssql( validation: @StoveDsl suspend MsSqlSystem.() -> Unit ): Unit = validation(this.stove.mssql()) suspend fun ValidationDsl.mssql( key: SystemKey, validation: @StoveDsl suspend MsSqlSystem.() -> Unit ): Unit = validation(this.stove.mssql(key)) ================================================ FILE: lib/stove-mssql/src/main/kotlin/com/trendyol/stove/mssql/MsSqlSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.mssql import com.trendyol.stove.functional.* import com.trendyol.stove.rdbms.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.runBlocking import kotliquery.* import org.slf4j.* @StoveDsl class MsSqlSystem internal constructor( override val stove: Stove, private val mssqlContext: MsSqlContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var sqlOperations: NativeSqlOperations override val reportSystemName: String = "MSSQL" + (mssqlContext.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: RelationalDatabaseExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(mssqlContext.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() sqlOperations = NativeSqlOperations(database(exposedConfiguration)) runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun close(): Unit = runBlocking { Try { if (::sqlOperations.isInitialized) { mssqlContext.options.cleanup(sqlOperations) sqlOperations.close() } executeWithReuseCheck { stop() } }.recover { logger.warn("got an error while stopping the MSSQL system") }.let { } } override fun configuration(): List = mssqlContext.options.configureExposedConfiguration(exposedConfiguration) suspend inline fun shouldQuery( query: String, parameters: List> = emptyList(), crossinline mapper: (Row) -> T, crossinline assertion: (List) -> Unit ): MsSqlSystem { report( action = "Query", input = arrow.core.Some(query.trim()), metadata = mapOf("sql" to query.trim()) ) { val results = sqlOperations.select(sql = query, parameters = parameters) { mapper(it) } assertion(results) results } return this } suspend fun shouldExecute(sql: String, parameters: List> = emptyList()): MsSqlSystem { report( action = "Execute SQL", input = arrow.core.Some(sql.trim()), metadata = mapOf("sql" to sql.trim()) ) { val affectedRows = sqlOperations.execute(sql = sql, parameters = parameters) check(affectedRows >= 0) { "Failed to execute sql: $sql" } "$affectedRows row(s) affected" } return this } suspend fun ops(operations: suspend NativeSqlOperations.() -> Unit) { operations(sqlOperations) } /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return MsSqlSystem */ suspend fun pause(): MsSqlSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return MsSqlSystem */ suspend fun unpause(): MsSqlSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): RelationalDatabaseExposedConfiguration = when { mssqlContext.options is ProvidedMsSqlOptions -> mssqlContext.options.config mssqlContext.runtime is StoveMsSqlContainer -> startMsSqlContainer(mssqlContext.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${mssqlContext.runtime::class}") } private suspend fun startMsSqlContainer(container: StoveMsSqlContainer): RelationalDatabaseExposedConfiguration = state.capture { container.start() RelationalDatabaseExposedConfiguration( jdbcUrl = container.jdbcUrl, host = container.host, port = container.firstMappedPort, username = container.username, password = container.password ) } private suspend fun runMigrationsIfNeeded() { if (!shouldRunMigrations()) return val executeAsRoot = createExecuteAsRootFn() createDatabaseIfNeeded(executeAsRoot) mssqlContext.options.migrationCollection.run( SqlMigrationContext(mssqlContext.options, sqlOperations) { executeAsRoot(it) } ) } private fun shouldRunMigrations(): Boolean = when { mssqlContext.options is ProvidedMsSqlOptions -> mssqlContext.options.runMigrations mssqlContext.runtime is StoveMsSqlContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${mssqlContext.runtime::class}") } private fun createExecuteAsRootFn(): suspend (String) -> Unit = when { mssqlContext.options is ProvidedMsSqlOptions -> { sql: String -> sqlOperations.execute(sql) } mssqlContext.runtime is StoveMsSqlContainer -> containerExecuteAsRoot(mssqlContext.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${mssqlContext.runtime::class}") } private suspend fun createDatabaseIfNeeded(executeAsRoot: suspend (String) -> Unit) { if (mssqlContext.runtime is StoveMsSqlContainer) { executeAsRoot("CREATE DATABASE ${mssqlContext.options.databaseName}") } } private fun containerExecuteAsRoot(container: StoveMsSqlContainer): suspend (String) -> Unit = { sql: String -> // Use execCommand which works via Docker client directly, supporting both // fresh starts and subsequent runs with reuse (where container isn't "started" by testcontainers) container .execCommand( "/opt/${mssqlContext.options.container.toolsPath.path}/bin/sqlcmd", "-S", "localhost", "-U", mssqlContext.options.userName, "-C", "-P", mssqlContext.options.password, "-Q", sql ).let { check(it.exitCode == 0) { """ Failed to execute sql: $sql Reason: ${it.stderr} ToolsPath: ${mssqlContext.options.container.toolsPath} ContainerTag: ${mssqlContext.options.container.imageWithTag} Exit code: ${it.exitCode} Recommendation: Try setting the toolsPath to ToolsPath.Before2019 or ToolsPath.After2019 depending on your mssql version. """.trimIndent() } } } private fun database(exposedConfiguration: RelationalDatabaseExposedConfiguration): Session = sessionOf( exposedConfiguration.jdbcUrl, exposedConfiguration.username, exposedConfiguration.password ) private inline fun withContainerOrWarn( operation: String, action: (StoveMsSqlContainer) -> Unit ): MsSqlSystem = when (val runtime = mssqlContext.runtime) { is StoveMsSqlContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveMsSqlContainer) -> Unit) { if (mssqlContext.runtime is StoveMsSqlContainer) { action(mssqlContext.runtime) } } companion object { /** * Exposes the [NativeSqlOperations] to the [MsSqlSystem]. * Use this for advanced SQL operations not covered by the DSL. */ fun MsSqlSystem.operations(): NativeSqlOperations = sqlOperations } } ================================================ FILE: lib/stove-mssql/src/test/kotlin/com/trendyol/stove/mssql/MsSqlOptionsTest.kt ================================================ package com.trendyol.stove.mssql import com.trendyol.stove.rdbms.RelationalDatabaseExposedConfiguration import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class MsSqlOptionsTest : FunSpec({ test("MsSqlOptions.provided should create ProvidedMsSqlOptions with correct config") { val options = MsSqlOptions.provided( jdbcUrl = "jdbc:sqlserver://localhost:1433;databaseName=testdb", host = "localhost", port = 1433, applicationName = "myapp", databaseName = "testdb", userName = "sa", password = "sapass", configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) options.providedConfig.jdbcUrl shouldBe "jdbc:sqlserver://localhost:1433;databaseName=testdb" options.providedConfig.host shouldBe "localhost" options.providedConfig.port shouldBe 1433 options.providedConfig.username shouldBe "sa" options.providedConfig.password shouldBe "sapass" options.runMigrationsForProvided shouldBe true } test("ProvidedMsSqlOptions should expose correct properties") { val config = RelationalDatabaseExposedConfiguration( jdbcUrl = "jdbc:sqlserver://remote:1433", host = "remote", port = 1433, username = "user", password = "pass" ) val options = ProvidedMsSqlOptions( config = config, applicationName = "app", databaseName = "db", userName = "user", password = "pass", runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("MssqlContainerOptions should have defaults") { val opts = MssqlContainerOptions() opts.tag shouldBe "2022-latest" opts.toolsPath shouldBe ToolsPath.After2019 } test("ToolsPath sealed class variants") { ToolsPath.Before2019.path shouldBe "mssql-tools" ToolsPath.After2019.path shouldBe "mssql-tools18" ToolsPath.Custom("custom-path").path shouldBe "custom-path" } test("MsSqlOptions should require application and database name") { val options = object : MsSqlOptions( applicationName = "test-app", databaseName = "test-db", userName = "sa", password = "sapass", configureExposedConfiguration = { _ -> listOf() } ) {} options.applicationName shouldBe "test-app" options.databaseName shouldBe "test-db" options.userName shouldBe "sa" options.password shouldBe "sapass" options.container shouldNotBe null } }) ================================================ FILE: lib/stove-mssql/src/test/kotlin/com/trendyol/stove/mssql/MssqlSystemTest.kt ================================================ package com.trendyol.stove.mssql import com.trendyol.stove.database.migrations.* import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.functional.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe import kotliquery.param import org.slf4j.* import org.testcontainers.mssqlserver.MSSQLServerContainer import org.testcontainers.utility.DockerImageName // ============================================================================ // Shared components // ============================================================================ class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } class InitialMigration : MsSqlMigration { private val logger: Logger = LoggerFactory.getLogger(javaClass) override val order: Int = MigrationPriority.HIGHEST.value + 1 override suspend fun execute(connection: SqlMigrationContext) { val sql = """ CREATE TABLE Person ( PersonID int, LastName varchar(255), FirstName varchar(255), Address varchar(255), City varchar(255) ); """.trimIndent() logger.info("Executing migration: $sql") Try { connection.executeAsRoot(sql) }.recover { logger.error("Migration failed", it) throw it } logger.info("Migration executed successfully") } } data class Person( val personId: Int, val lastName: String, val firstName: String, val address: String, val city: String ) // ============================================================================ // Strategy interface // ============================================================================ sealed interface MsSqlTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): MsSqlTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedMsSqlStrategy() else ContainerMsSqlStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerMsSqlStrategy : MsSqlTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting MSSQL tests with container mode") val options = MsSqlOptions( applicationName = "test", databaseName = "test", userName = "sa", password = "Password12!", container = MssqlContainerOptions( toolsPath = ToolsPath.After2019, image = "mcr.microsoft.com/mssql/server", tag = "2022-CU16-ubuntu-22.04" ) { withStartupAttempts(3) }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { mssql { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("MSSQL container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedMsSqlStrategy : MsSqlTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: MSSQLServerContainer override suspend fun start() { logger.info("Starting MSSQL tests with provided mode") // Start an external container to simulate a provided instance externalContainer = MSSQLServerContainer( DockerImageName.parse("mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04") ).apply { acceptLicense() withPassword("Password12!") start() } logger.info("External MSSQL container started at ${externalContainer.jdbcUrl}") val options = MsSqlOptions .provided( jdbcUrl = externalContainer.jdbcUrl, host = externalContainer.host, port = externalContainer.firstMappedPort, applicationName = "test", databaseName = "master", // Use master for provided since we can't easily create DB userName = externalContainer.username, password = externalContainer.password, runMigrations = true, cleanup = { sqlOps -> logger.info("Running cleanup on provided instance") Try { sqlOps.execute("DROP TABLE IF EXISTS Person") } }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { mssql { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("MSSQL provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = MsSqlTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } // ============================================================================ // Tests // ============================================================================ class MssqlSystemTests : ShouldSpec({ should("work") { stove { mssql { ops { val result = select("SELECT 1") { it.int(1) } result.first() shouldBe 1 } shouldExecute("insert into Person values (1, 'Doe', 'John', '123 Main St', 'Springfield')") shouldQuery( query = "select * from Person", mapper = { Person( it.int(1), it.string(2), it.string(3), it.string(4), it.string(5) ) } ) { result -> result.size shouldBe 1 result.first().apply { personId shouldBe 1 lastName shouldBe "Doe" firstName shouldBe "John" address shouldBe "123 Main St" city shouldBe "Springfield" } } } } } should("work with parameterized queries") { stove { mssql { // Insert with parameters shouldExecute( sql = "insert into Person values (?, ?, ?, ?, ?)", parameters = listOf( 2.param(), "Smith".param(), "Jane".param(), "456 Oak Ave".param(), "Boston".param() ) ) shouldExecute( sql = "insert into Person values (?, ?, ?, ?, ?)", parameters = listOf( 3.param(), "Johnson".param(), "Mike".param(), "789 Pine Rd".param(), "Boston".param() ) ) // Query with parameters shouldQuery( query = "select * from Person where City = ? order by PersonID", parameters = listOf("Boston".param()), mapper = { Person( it.int(1), it.string(2), it.string(3), it.string(4), it.string(5) ) } ) { result -> result.size shouldBe 2 result.first().apply { personId shouldBe 2 lastName shouldBe "Smith" firstName shouldBe "Jane" city shouldBe "Boston" } result.last().apply { personId shouldBe 3 lastName shouldBe "Johnson" firstName shouldBe "Mike" city shouldBe "Boston" } } // Query with multiple parameters shouldQuery( query = "select * from Person where LastName = ? and FirstName = ?", parameters = listOf("Smith".param(), "Jane".param()), mapper = { Person( it.int(1), it.string(2), it.string(3), it.string(4), it.string(5) ) } ) { result -> result.size shouldBe 1 result.first().apply { lastName shouldBe "Smith" firstName shouldBe "Jane" address shouldBe "456 Oak Ave" } } } } } }) ================================================ FILE: lib/stove-mssql/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.mssql.StoveConfig ================================================ FILE: lib/stove-mssql/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: lib/stove-mysql/api/stove-mysql.api ================================================ public final class com/trendyol/stove/mysql/MySqlContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mysql/MySqlContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/mysql/MySqlContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/mysql/MySqlContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/mysql/MySqlMigrationContext { public fun (Lcom/trendyol/stove/mysql/MySqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;)V public final fun component1 ()Lcom/trendyol/stove/mysql/MySqlOptions; public final fun component2 ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun copy (Lcom/trendyol/stove/mysql/MySqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/mysql/MySqlMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/mysql/MySqlMigrationContext;Lcom/trendyol/stove/mysql/MySqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/mysql/MySqlMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getExecuteAsRoot ()Lkotlin/jvm/functions/Function2; public final fun getOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun getOptions ()Lcom/trendyol/stove/mysql/MySqlOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public class com/trendyol/stove/mysql/MySqlOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/mysql/MySqlOptions$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mysql/MySqlContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/mysql/MySqlContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/mysql/MySqlContainerOptions; public fun getDatabaseName ()Ljava/lang/String; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getPassword ()Ljava/lang/String; public fun getUsername ()Ljava/lang/String; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mysql/MySqlOptions; } public final class com/trendyol/stove/mysql/MySqlOptions$Companion { public final fun provided (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/mysql/ProvidedMySqlOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/mysql/MySqlOptions$Companion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/mysql/ProvidedMySqlOptions; } public final class com/trendyol/stove/mysql/MySqlSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/mysql/MySqlSystem$Companion; public field sqlOperations Lcom/trendyol/stove/rdbms/NativeSqlOperations; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public final fun getSqlOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setSqlOperations (Lcom/trendyol/stove/rdbms/NativeSqlOperations;)V public final fun shouldExecute (Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldExecute$default (Lcom/trendyol/stove/mysql/MySqlSystem;Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/mysql/MySqlSystem$Companion { public final fun operations (Lcom/trendyol/stove/mysql/MySqlSystem;)Lcom/trendyol/stove/rdbms/NativeSqlOperations; } public final class com/trendyol/stove/mysql/OptionsKt { public static final field DEFAULT_MYSQL_IMAGE_NAME Ljava/lang/String; public static final fun mysql-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mysql-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mysql-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun mysql-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/mysql/ProvidedMySqlOptions : com/trendyol/stove/mysql/MySqlOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/mysql/StoveMySqlContainer : org/testcontainers/mysql/MySQLContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } ================================================ FILE: lib/stove-mysql/build.gradle.kts ================================================ dependencies { api(projects.lib.stoveRdbms) api(libs.testcontainers.mysql) api(libs.mysql.connector) testImplementation(projects.testExtensions.stoveExtensionsKotest) testImplementation(libs.logback.classic) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided MySQL instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting MySQL tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-mysql/src/main/kotlin/com/trendyol/stove/mysql/MySqlSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.mysql import com.trendyol.stove.functional.* import com.trendyol.stove.rdbms.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.runBlocking import kotliquery.* import org.slf4j.* /** * MySQL database system for testing relational data operations. */ @StoveDsl class MySqlSystem internal constructor( override val stove: Stove, private val mysqlContext: MySqlContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var sqlOperations: NativeSqlOperations override val reportSystemName: String = "MySQL" + (mysqlContext.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: RelationalDatabaseExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(mysqlContext.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() sqlOperations = NativeSqlOperations(database(exposedConfiguration)) runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun close(): Unit = runBlocking { Try { if (::sqlOperations.isInitialized) { mysqlContext.options.cleanup(sqlOperations) sqlOperations.close() } executeWithReuseCheck { stop() } }.recover { logger.warn("MySQL stop failed", it) } } override fun configuration(): List = mysqlContext.options.configureExposedConfiguration(exposedConfiguration) suspend inline fun shouldQuery( query: String, parameters: List> = emptyList(), crossinline mapper: (Row) -> T, crossinline assertion: (List) -> Unit ): MySqlSystem { report( action = "Query", input = arrow.core.Some(query.trim()), metadata = mapOf("sql" to query.trim()) ) { val results = sqlOperations.select(sql = query, parameters = parameters) { mapper(it) } assertion(results) results } return this } suspend fun shouldExecute(sql: String, parameters: List> = emptyList()): MySqlSystem { report( action = "Execute SQL", input = arrow.core.Some(sql.trim()), metadata = mapOf("sql" to sql.trim()) ) { val affectedRows = sqlOperations.execute(sql = sql, parameters = parameters) check(affectedRows >= 0) { "Failed to execute sql: $sql" } "$affectedRows row(s) affected" } return this } /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return MySqlSystem */ suspend fun pause(): MySqlSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return MySqlSystem */ suspend fun unpause(): MySqlSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): RelationalDatabaseExposedConfiguration = when { mysqlContext.options is ProvidedMySqlOptions -> mysqlContext.options.config mysqlContext.runtime is StoveMySqlContainer -> startMySqlContainer(mysqlContext.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${mysqlContext.runtime::class}") } private suspend fun startMySqlContainer(container: StoveMySqlContainer): RelationalDatabaseExposedConfiguration = state.capture { container.start() RelationalDatabaseExposedConfiguration( jdbcUrl = container.jdbcUrl, host = container.host, port = container.firstMappedPort, username = container.username, password = container.password ) } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { val executeAsRoot = createExecuteAsRootFn() mysqlContext.options.migrationCollection.run( MySqlMigrationContext(mysqlContext.options, sqlOperations) { executeAsRoot(it) } ) } } private fun shouldRunMigrations(): Boolean = when { mysqlContext.options is ProvidedMySqlOptions -> mysqlContext.options.runMigrations mysqlContext.runtime is StoveMySqlContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${mysqlContext.runtime::class}") } private fun createExecuteAsRootFn(): suspend (String) -> Unit = when { mysqlContext.options is ProvidedMySqlOptions -> { sql: String -> sqlOperations.execute(sql) } mysqlContext.runtime is StoveMySqlContainer -> { sql: String -> val container = mysqlContext.runtime // Use execCommand which works via Docker client directly, supporting both // fresh starts and subsequent runs with reuse (where container isn't "started" by testcontainers) container .execCommand( "/bin/bash", "-c", "mysql -u ${container.username} -p${container.password} ${container.databaseName} -e \"$sql\"" ).let { check(it.exitCode == 0) { "Failed to execute sql: $sql, reason: ${it.stderr}" } } } else -> throw UnsupportedOperationException("Unsupported runtime type: ${mysqlContext.runtime::class}") } private fun database(exposedConfiguration: RelationalDatabaseExposedConfiguration): Session = sessionOf( url = exposedConfiguration.jdbcUrl, user = exposedConfiguration.username, password = exposedConfiguration.password ) private inline fun withContainerOrWarn( operation: String, action: (StoveMySqlContainer) -> Unit ): MySqlSystem = when (val runtime = mysqlContext.runtime) { is StoveMySqlContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveMySqlContainer) -> Unit) { if (mysqlContext.runtime is StoveMySqlContainer) { action(mysqlContext.runtime) } } companion object { /** * Exposes the [NativeSqlOperations] to the [MySqlSystem]. * Use this for advanced SQL operations not covered by the DSL. */ fun MySqlSystem.operations(): NativeSqlOperations = sqlOperations } } ================================================ FILE: lib/stove-mysql/src/main/kotlin/com/trendyol/stove/mysql/Options.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.mysql import arrow.core.getOrElse import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.rdbms.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.testcontainers.mysql.MySQLContainer import org.testcontainers.utility.DockerImageName const val DEFAULT_MYSQL_IMAGE_NAME = "mysql" open class StoveMySqlContainer( override val imageNameAccess: DockerImageName ) : MySQLContainer(imageNameAccess), StoveContainer /** * Container options for MySQL. */ data class MySqlContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = DEFAULT_MYSQL_IMAGE_NAME, override val tag: String = "8.4", override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveMySqlContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions /** * Options for configuring the MySQL system in container mode. */ @StoveDsl open class MySqlOptions( open val databaseName: String = "stove", open val username: String = "sa", open val password: String = "sa", open val container: MySqlContainerOptions = MySqlContainerOptions(), open val cleanup: suspend (NativeSqlOperations) -> Unit = {}, override val configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided MySQL instance * instead of a testcontainer. * * @param jdbcUrl The JDBC URL for the MySQL instance * @param host The host of the MySQL instance * @param port The port of the MySQL instance * @param databaseName The database name * @param username The username for authentication * @param password The password for authentication * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( jdbcUrl: String, host: String, port: Int, databaseName: String = "stove", username: String = "sa", password: String = "sa", runMigrations: Boolean = true, cleanup: suspend (NativeSqlOperations) -> Unit = {}, configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ): ProvidedMySqlOptions = ProvidedMySqlOptions( config = RelationalDatabaseExposedConfiguration( jdbcUrl = jdbcUrl, host = host, port = port, username = username, password = password ), databaseName = databaseName, username = username, password = password, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided MySQL instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedMySqlOptions( /** * The configuration for the provided MySQL instance. */ val config: RelationalDatabaseExposedConfiguration, databaseName: String = "stove", username: String = "sa", password: String = "sa", cleanup: suspend (NativeSqlOperations) -> Unit = {}, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) : MySqlOptions( databaseName = databaseName, username = username, password = password, container = MySqlContainerOptions(), cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: RelationalDatabaseExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } @StoveDsl data class MySqlMigrationContext( val options: MySqlOptions, val operations: NativeSqlOperations, val executeAsRoot: suspend (String) -> Unit ) /** * Convenience type alias for MySQL migrations. * * Instead of writing `DatabaseMigration`, use `MySqlMigration`: * ```kotlin * class MyMigration : MySqlMigration { * override val order: Int = 1 * override suspend fun execute(connection: MySqlMigrationContext) { ... } * } * ``` */ typealias MySqlMigration = DatabaseMigration internal class MySqlContext( val runtime: SystemRuntime, val options: MySqlOptions, val keyName: String? = null ) internal fun Stove.withMySql( options: MySqlOptions, runtime: SystemRuntime ): Stove { getOrRegister(MySqlSystem(this, MySqlContext(runtime, options))) return this } internal fun Stove.withMySql( key: SystemKey, options: MySqlOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, MySqlSystem(this, MySqlContext(runtime, options, keyName = keyDisplayName(key)))) return this } internal fun Stove.mysql(): MySqlSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(MySqlSystem::class) } internal fun Stove.mysql(key: SystemKey): MySqlSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(MySqlSystem::class, "No MySqlSystem registered with key '${keyDisplayName(key)}'") } /** * Configures MySQL system. * * For container-based setup: * ```kotlin * mysql { * MySqlOptions( * databaseName = "mydb", * cleanup = { ops -> ops.execute("TRUNCATE TABLE ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * mysql { * MySqlOptions.provided( * jdbcUrl = "jdbc:mysql://localhost:3306/mydb", * host = "localhost", * port = 3306, * username = "user", * password = "pass", * runMigrations = true, * cleanup = { ops -> ops.execute("TRUNCATE TABLE ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.mysql( configure: () -> MySqlOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedMySqlOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withDatabaseName(options.databaseName) .withUsername(options.username) .withPassword(options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveMySqlContainer } .apply(options.container.containerFn) } } return stove.withMySql(options, runtime) } fun WithDsl.mysql( key: SystemKey, configure: () -> MySqlOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedMySqlOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withDatabaseName(options.databaseName) .withUsername(options.username) .withPassword(options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveMySqlContainer } .apply(options.container.containerFn) } } return stove.withMySql(key, options, runtime) } suspend fun ValidationDsl.mysql(validation: @StoveDsl suspend MySqlSystem.() -> Unit): Unit = validation(this.stove.mysql()) suspend fun ValidationDsl.mysql(key: SystemKey, validation: @StoveDsl suspend MySqlSystem.() -> Unit): Unit = validation(this.stove.mysql(key)) ================================================ FILE: lib/stove-mysql/src/test/kotlin/com/trendyol/stove/mysql/MySqlOptionsTest.kt ================================================ package com.trendyol.stove.mysql import com.trendyol.stove.rdbms.RelationalDatabaseExposedConfiguration import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class MySqlOptionsTest : FunSpec({ test("MySqlOptions.provided should create ProvidedMySqlOptions with correct config") { val options = MySqlOptions.provided( jdbcUrl = "jdbc:mysql://localhost:3306/testdb", host = "localhost", port = 3306, databaseName = "testdb", username = "root", password = "rootpass", configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) options.providedConfig.jdbcUrl shouldBe "jdbc:mysql://localhost:3306/testdb" options.providedConfig.host shouldBe "localhost" options.providedConfig.port shouldBe 3306 options.providedConfig.username shouldBe "root" options.providedConfig.password shouldBe "rootpass" options.runMigrationsForProvided shouldBe true } test("ProvidedMySqlOptions should expose correct properties") { val config = RelationalDatabaseExposedConfiguration( jdbcUrl = "jdbc:mysql://remote:3306/db", host = "remote", port = 3306, username = "user", password = "pass" ) val options = ProvidedMySqlOptions( config = config, databaseName = "db", runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("MySqlOptions should have sensible defaults") { val options = object : MySqlOptions( configureExposedConfiguration = { _ -> listOf() } ) {} options.databaseName shouldBe "stove" options.username shouldBe "sa" options.password shouldBe "sa" options.container shouldNotBe null } test("MySqlContainerOptions should have defaults") { val opts = MySqlContainerOptions() opts.image shouldBe DEFAULT_MYSQL_IMAGE_NAME opts.tag shouldBe "8.4" } }) ================================================ FILE: lib/stove-mysql/src/test/kotlin/com/trendyol/stove/mysql/MySqlSystemTests.kt ================================================ package com.trendyol.stove.mysql import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.shouldBe import kotliquery.param /** * MySQL system tests that run against both container-based and provided instances. */ class MySqlSystemTests : FunSpec({ data class IdAndDescription( val id: Long, val description: String ) test("migration should create MigrationHistory table") { stove { mysql { shouldQuery( "SELECT * FROM MigrationHistory", mapper = { row -> IdAndDescription(row.long("id"), row.string("description")) } ) { actual -> actual.size shouldBeGreaterThan 0 actual.first() shouldBe IdAndDescription(1, "InitialMigration") } } } } test("should execute DDL and DML statements") { stove { mysql { shouldExecute("DROP TABLE IF EXISTS Dummies") shouldExecute( """ CREATE TABLE IF NOT EXISTS Dummies ( id INT AUTO_INCREMENT PRIMARY KEY, description VARCHAR (50) NOT NULL ) """.trimIndent() ) shouldExecute("INSERT INTO Dummies (description) VALUES ('${testCase.name.name}')") shouldQuery( "SELECT * FROM Dummies", mapper = { IdAndDescription(it.long("id"), it.string("description")) } ) { actual -> actual.size shouldBeGreaterThan 0 actual.first().description shouldBe testCase.name.name } } } } test("should handle multiple inserts and queries") { stove { mysql { shouldExecute("DROP TABLE IF EXISTS TestItems") shouldExecute( """ CREATE TABLE IF NOT EXISTS TestItems ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR (100) NOT NULL, value INT NOT NULL ) """.trimIndent() ) repeat(5) { i -> shouldExecute("INSERT INTO TestItems (name, value) VALUES ('item_$i', $i)") } data class TestItem( val id: Long, val name: String, val value: Int ) shouldQuery( "SELECT * FROM TestItems ORDER BY value", mapper = { row -> TestItem(row.long("id"), row.string("name"), row.int("value")) } ) { actual -> actual.size shouldBe 5 actual.forEachIndexed { index, item -> item.name shouldBe "item_$index" item.value shouldBe index } } } } } class NullableIdAndDescription { var id: Long? = null var description: String? = null } test("should work with mutable classes") { stove { mysql { shouldExecute("DROP TABLE IF EXISTS Dummies") shouldExecute( """ CREATE TABLE IF NOT EXISTS Dummies ( id INT AUTO_INCREMENT PRIMARY KEY, description VARCHAR (50) NOT NULL ) """.trimIndent() ) shouldExecute("INSERT INTO Dummies (description) VALUES ('${testCase.name.name}')") shouldQuery( "SELECT * FROM Dummies", mapper = { row -> val result = NullableIdAndDescription() result.id = row.long("id") result.description = row.string("description") result } ) { actual -> actual.size shouldBeGreaterThan 0 actual.first().description shouldBe testCase.name.name } } } } test("should work with parameterized queries") { stove { mysql { shouldExecute("DROP TABLE IF EXISTS Products") shouldExecute( """ CREATE TABLE IF NOT EXISTS Products ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR (100) NOT NULL, price DECIMAL(10,2) NOT NULL, category VARCHAR (50) NOT NULL ) """.trimIndent() ) // Insert with parameters shouldExecute( sql = "INSERT INTO Products (name, price, category) VALUES (?, ?, ?)", parameters = listOf("Laptop".param(), 999.99.param(), "Electronics".param()) ) shouldExecute( sql = "INSERT INTO Products (name, price, category) VALUES (?, ?, ?)", parameters = listOf("Mouse".param(), 29.99.param(), "Electronics".param()) ) shouldExecute( sql = "INSERT INTO Products (name, price, category) VALUES (?, ?, ?)", parameters = listOf("Desk".param(), 299.99.param(), "Furniture".param()) ) data class Product( val id: Long, val name: String, val price: Double, val category: String ) // Query with parameters shouldQuery( query = "SELECT * FROM Products WHERE category = ? ORDER BY price", parameters = listOf("Electronics".param()), mapper = { row -> Product( row.long("id"), row.string("name"), row.double("price"), row.string("category") ) } ) { actual -> actual.size shouldBe 2 actual.first().name shouldBe "Mouse" actual.last().name shouldBe "Laptop" } // Query with multiple parameters shouldQuery( query = "SELECT * FROM Products WHERE category = ? AND price > ?", parameters = listOf("Electronics".param(), 50.0.param()), mapper = { row -> Product( row.long("id"), row.string("name"), row.double("price"), row.string("category") ) } ) { actual -> actual.size shouldBe 1 actual.first().apply { name shouldBe "Laptop" price shouldBe 999.99 category shouldBe "Electronics" } } } } } }) ================================================ FILE: lib/stove-mysql/src/test/kotlin/com/trendyol/stove/mysql/TestSystemConfig.kt ================================================ @file:Suppress("DEPRECATION") package com.trendyol.stove.mysql import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import org.testcontainers.containers.MySQLContainer // ============================================================================ // Shared components // ============================================================================ class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } class InitialMigration : MySqlMigration { private val logger: Logger = LoggerFactory.getLogger(InitialMigration::class.java) override val order: Int = 1 override suspend fun execute(connection: MySqlMigrationContext) { logger.info("Executing InitialMigration") connection.operations.execute("DROP TABLE IF EXISTS MigrationHistory") connection.operations.execute( """ CREATE TABLE IF NOT EXISTS MigrationHistory ( id INT AUTO_INCREMENT PRIMARY KEY, description VARCHAR (50) NOT NULL ) """.trimIndent() ) connection.operations.execute("INSERT INTO MigrationHistory (description) VALUES ('InitialMigration')") logger.info("InitialMigration executed") } } // ============================================================================ // Strategy interface // ============================================================================ sealed interface MySqlTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): MySqlTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedMySqlStrategy() else ContainerMySqlStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerMySqlStrategy : MySqlTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting MySQL tests with container mode") val options = MySqlOptions( databaseName = "testing", username = "stove", password = "Password12!", container = MySqlContainerOptions( tag = "9.6.0" ), configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { mysql { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { Stove.stop() logger.info("MySQL container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedMySqlStrategy : MySqlTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: MySQLContainer<*> override suspend fun start() { logger.info("Starting MySQL tests with provided mode") // Start an external container to simulate a provided instance externalContainer = MySQLContainer("mysql:9.6.0").apply { withDatabaseName("testing") withUsername("stove") withPassword("Password12!") start() } logger.info("External MySQL container started at ${externalContainer.jdbcUrl}") val options = MySqlOptions .provided( jdbcUrl = externalContainer.jdbcUrl, host = externalContainer.host, port = externalContainer.firstMappedPort, databaseName = "testing", username = externalContainer.username, password = externalContainer.password, runMigrations = true, cleanup = { sqlOps -> logger.info("Running cleanup on provided instance") sqlOps.execute("DROP TABLE IF EXISTS MigrationHistory") }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { mysql { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { Stove.stop() externalContainer.stop() logger.info("MySQL provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = MySqlTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } ================================================ FILE: lib/stove-mysql/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.mysql.StoveConfig ================================================ FILE: lib/stove-mysql/src/test/resources/logback.xml ================================================ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n ================================================ FILE: lib/stove-postgres/api/stove-postgres.api ================================================ public final class com/trendyol/stove/postgres/OptionsKt { public static final field DEFAULT_POSTGRES_IMAGE_NAME Ljava/lang/String; public static final fun postgresql-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun postgresql-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun postgresql-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun postgresql-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/postgres/PostgresSqlMigrationContext { public fun (Lcom/trendyol/stove/postgres/PostgresqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;)V public final fun component1 ()Lcom/trendyol/stove/postgres/PostgresqlOptions; public final fun component2 ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun copy (Lcom/trendyol/stove/postgres/PostgresqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/postgres/PostgresSqlMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/postgres/PostgresSqlMigrationContext;Lcom/trendyol/stove/postgres/PostgresqlOptions;Lcom/trendyol/stove/rdbms/NativeSqlOperations;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/postgres/PostgresSqlMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getExecuteAsRoot ()Lkotlin/jvm/functions/Function2; public final fun getOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun getOptions ()Lcom/trendyol/stove/postgres/PostgresqlOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/postgres/PostgresqlContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/postgres/PostgresqlContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/postgres/PostgresqlContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/postgres/PostgresqlContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public class com/trendyol/stove/postgres/PostgresqlOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/postgres/PostgresqlOptions$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/postgres/PostgresqlContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/trendyol/stove/postgres/PostgresqlContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/postgres/PostgresqlContainerOptions; public fun getDatabaseName ()Ljava/lang/String; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getPassword ()Ljava/lang/String; public fun getUsername ()Ljava/lang/String; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/postgres/PostgresqlOptions; } public final class com/trendyol/stove/postgres/PostgresqlOptions$Companion { public final fun provided (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/postgres/ProvidedPostgresqlOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/postgres/PostgresqlOptions$Companion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/postgres/ProvidedPostgresqlOptions; } public final class com/trendyol/stove/postgres/PostgresqlSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/postgres/PostgresqlSystem$Companion; public field sqlOperations Lcom/trendyol/stove/rdbms/NativeSqlOperations; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public final fun getSqlOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setSqlOperations (Lcom/trendyol/stove/rdbms/NativeSqlOperations;)V public final fun shouldExecute (Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldExecute$default (Lcom/trendyol/stove/postgres/PostgresqlSystem;Ljava/lang/String;Ljava/util/List;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/postgres/PostgresqlSystem$Companion { public final fun operations (Lcom/trendyol/stove/postgres/PostgresqlSystem;)Lcom/trendyol/stove/rdbms/NativeSqlOperations; } public final class com/trendyol/stove/postgres/ProvidedPostgresqlOptions : com/trendyol/stove/postgres/PostgresqlOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/postgres/StovePostgresqlContainer : org/testcontainers/postgresql/PostgreSQLContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } ================================================ FILE: lib/stove-postgres/build.gradle.kts ================================================ dependencies { api(projects.lib.stoveRdbms) api(libs.testcontainers.postgres) api(libs.postgresql) testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided PostgreSQL instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting PostgreSQL tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-postgres/src/main/kotlin/com/trendyol/stove/postgres/Options.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.postgres import arrow.core.getOrElse import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.rdbms.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.testcontainers.postgresql.PostgreSQLContainer import org.testcontainers.utility.DockerImageName const val DEFAULT_POSTGRES_IMAGE_NAME = "postgres" open class StovePostgresqlContainer( override val imageNameAccess: DockerImageName ) : PostgreSQLContainer(imageNameAccess), StoveContainer data class PostgresqlContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = DEFAULT_POSTGRES_IMAGE_NAME, override val tag: String = "latest", override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StovePostgresqlContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions /** * Options for configuring the PostgreSQL system in container mode. */ @StoveDsl open class PostgresqlOptions( open val databaseName: String = "stove", open val username: String = "sa", open val password: String = "sa", open val container: PostgresqlContainerOptions = PostgresqlContainerOptions(), open val cleanup: suspend (NativeSqlOperations) -> Unit = {}, override val configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided PostgreSQL instance * instead of a testcontainer. * * @param jdbcUrl The JDBC URL for the PostgreSQL instance * @param host The host of the PostgreSQL instance * @param port The port of the PostgreSQL instance * @param databaseName The database name * @param username The username for authentication * @param password The password for authentication * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( jdbcUrl: String, host: String, port: Int, databaseName: String = "stove", username: String = "sa", password: String = "sa", runMigrations: Boolean = true, cleanup: suspend (NativeSqlOperations) -> Unit = {}, configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ): ProvidedPostgresqlOptions = ProvidedPostgresqlOptions( config = RelationalDatabaseExposedConfiguration( jdbcUrl = jdbcUrl, host = host, port = port, username = username, password = password ), databaseName = databaseName, username = username, password = password, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided PostgreSQL instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedPostgresqlOptions( /** * The configuration for the provided PostgreSQL instance. */ val config: RelationalDatabaseExposedConfiguration, databaseName: String = "stove", username: String = "sa", password: String = "sa", cleanup: suspend (NativeSqlOperations) -> Unit = {}, /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) : PostgresqlOptions( databaseName = databaseName, username = username, password = password, container = PostgresqlContainerOptions(), cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: RelationalDatabaseExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } @StoveDsl data class PostgresSqlMigrationContext( val options: PostgresqlOptions, val operations: NativeSqlOperations, val executeAsRoot: suspend (String) -> Unit ) /** * Convenience type alias for PostgreSQL migrations. * * Instead of writing `DatabaseMigration`, use `PostgresqlMigration`: * ```kotlin * class MyMigration : PostgresqlMigration { * override val order: Int = 1 * override suspend fun execute(connection: PostgresSqlMigrationContext) { ... } * } * ``` */ typealias PostgresqlMigration = DatabaseMigration internal class PostgresqlContext( val runtime: SystemRuntime, val options: PostgresqlOptions, val keyName: String? = null ) internal fun Stove.withPostgresql( options: PostgresqlOptions, runtime: SystemRuntime ): Stove { getOrRegister(PostgresqlSystem(this, PostgresqlContext(runtime, options))) return this } internal fun Stove.withPostgresql( key: SystemKey, options: PostgresqlOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, PostgresqlSystem(this, PostgresqlContext(runtime, options, keyName = keyDisplayName(key)))) return this } internal fun Stove.postgresql(): PostgresqlSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(PostgresqlSystem::class) } internal fun Stove.postgresql(key: SystemKey): PostgresqlSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(PostgresqlSystem::class, "No PostgresqlSystem registered with key '${keyDisplayName(key)}'") } /** * Configures PostgreSQL system. * * For container-based setup: * ```kotlin * postgresql { * PostgresqlOptions( * databaseName = "mydb", * cleanup = { ops -> ops.execute("TRUNCATE TABLE ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * postgresql { * PostgresqlOptions.provided( * jdbcUrl = "jdbc:postgresql://localhost:5432/mydb", * host = "localhost", * port = 5432, * username = "user", * password = "pass", * runMigrations = true, * cleanup = { ops -> ops.execute("TRUNCATE TABLE ...") }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.postgresql( configure: () -> PostgresqlOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedPostgresqlOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withDatabaseName(options.databaseName) .withUsername(options.username) .withPassword(options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StovePostgresqlContainer } .apply(options.container.containerFn) } } return stove.withPostgresql(options, runtime) } fun WithDsl.postgresql( key: SystemKey, configure: () -> PostgresqlOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedPostgresqlOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.imageWithTag, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withDatabaseName(options.databaseName) .withUsername(options.username) .withPassword(options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StovePostgresqlContainer } .apply(options.container.containerFn) } } return stove.withPostgresql(key, options, runtime) } suspend fun ValidationDsl.postgresql(validation: @StoveDsl suspend PostgresqlSystem.() -> Unit): Unit = validation(this.stove.postgresql()) suspend fun ValidationDsl.postgresql(key: SystemKey, validation: @StoveDsl suspend PostgresqlSystem.() -> Unit): Unit = validation(this.stove.postgresql(key)) ================================================ FILE: lib/stove-postgres/src/main/kotlin/com/trendyol/stove/postgres/PostgresqlSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.postgres import com.trendyol.stove.functional.* import com.trendyol.stove.rdbms.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.runBlocking import kotliquery.* import org.slf4j.* /** * PostgreSQL database system for testing relational data operations. * * Provides a DSL for testing PostgreSQL operations: * - SQL query execution with typed results * - DDL/DML statement execution * - Schema migrations * - Container pause/unpause for fault injection * * ## Querying Data * * ```kotlin * postgresql { * // Query with row mapping * shouldQuery( * query = "SELECT id, name, email FROM users WHERE status = 'active'", * mapper = { row -> * User( * id = row.long("id"), * name = row.string("name"), * email = row.string("email") * ) * } * ) { users -> * users.size shouldBeGreaterThan 0 * users.all { it.email.contains("@") } shouldBe true * } * } * ``` * * ## Executing SQL * * ```kotlin * postgresql { * // Execute DML/DDL statements * shouldExecute("INSERT INTO users (name, email) VALUES ('John', 'john@example.com')") * shouldExecute("UPDATE users SET status = 'active' WHERE id = 123") * shouldExecute("DELETE FROM users WHERE id = 123") * * // Create tables * shouldExecute(""" * CREATE TABLE IF NOT EXISTS orders ( * id SERIAL PRIMARY KEY, * user_id BIGINT REFERENCES users(id), * total DECIMAL(10,2), * created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP * ) * """) * } * ``` * * ## Fault Injection Testing * * Test application behavior during database outages: * * ```kotlin * postgresql { * // Pause the database container * pause() * } * * // Test application behavior during outage * http { * getResponse("/api/health") { response -> * response.status shouldBe 503 * } * } * * postgresql { * // Resume the database * unpause() * } * * // Verify recovery * http { * getResponse("/api/health") { response -> * response.status shouldBe 200 * } * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should create order and store in database") { * stove { * // Create order via API * http { * postAndExpectBody( * uri = "/orders", * body = CreateOrderRequest(userId = 123, amount = 99.99).some() * ) { response -> * response.status shouldBe 201 * } * } * * // Verify in database * postgresql { * shouldQuery( * query = "SELECT * FROM orders WHERE user_id = 123", * mapper = { row -> Order(row.long("id"), row.decimal("total")) } * ) { orders -> * orders shouldHaveSize 1 * orders.first().total shouldBe BigDecimal("99.99") * } * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * postgresql { * PostgresqlOptions( * configureExposedConfiguration = { cfg -> * listOf( * "spring.datasource.url=${cfg.jdbcUrl}", * "spring.datasource.username=${cfg.username}", * "spring.datasource.password=${cfg.password}" * ) * } * ).migrations { * register() * register() * } * } * } * ``` * * @property stove The parent test system. * @see PostgresqlOptions * @see RelationalDatabaseExposedConfiguration */ @StoveDsl class PostgresqlSystem internal constructor( override val stove: Stove, private val postgresContext: PostgresqlContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { @PublishedApi internal lateinit var sqlOperations: NativeSqlOperations override val reportSystemName: String = "PostgreSQL" + (postgresContext.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: RelationalDatabaseExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(postgresContext.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() sqlOperations = NativeSqlOperations(database(exposedConfiguration)) runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun close(): Unit = runBlocking { Try { if (::sqlOperations.isInitialized) { postgresContext.options.cleanup(sqlOperations) sqlOperations.close() } executeWithReuseCheck { stop() } }.recover { logger.warn("PostgreSQL stop failed", it) } } override fun configuration(): List = postgresContext.options.configureExposedConfiguration(exposedConfiguration) suspend inline fun shouldQuery( query: String, parameters: List> = emptyList(), crossinline mapper: (Row) -> T, crossinline assertion: (List) -> Unit ): PostgresqlSystem { report( action = "Query", input = arrow.core.Some(query.trim()), metadata = mapOf("sql" to query.trim()) ) { val results = sqlOperations.select(sql = query, parameters = parameters) { mapper(it) } assertion(results) results } return this } suspend fun shouldExecute(sql: String, parameters: List> = emptyList()): PostgresqlSystem { report( action = "Execute SQL", input = arrow.core.Some(sql.trim()), metadata = mapOf("sql" to sql.trim()) ) { val affectedRows = sqlOperations.execute(sql = sql, parameters = parameters) check(affectedRows >= 0) { "Failed to execute sql: $sql" } "$affectedRows row(s) affected" } return this } /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return PostgresqlSystem */ suspend fun pause(): PostgresqlSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return PostgresqlSystem */ suspend fun unpause(): PostgresqlSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): RelationalDatabaseExposedConfiguration = when { postgresContext.options is ProvidedPostgresqlOptions -> postgresContext.options.config postgresContext.runtime is StovePostgresqlContainer -> startPostgresContainer(postgresContext.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${postgresContext.runtime::class}") } private suspend fun startPostgresContainer(container: StovePostgresqlContainer): RelationalDatabaseExposedConfiguration = state.capture { container.start() RelationalDatabaseExposedConfiguration( jdbcUrl = container.jdbcUrl, host = container.host, port = container.firstMappedPort, username = container.username, password = container.password ) } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { val executeAsRoot = createExecuteAsRootFn() postgresContext.options.migrationCollection.run( PostgresSqlMigrationContext(postgresContext.options, sqlOperations) { executeAsRoot(it) } ) } } private fun shouldRunMigrations(): Boolean = when { postgresContext.options is ProvidedPostgresqlOptions -> postgresContext.options.runMigrations postgresContext.runtime is StovePostgresqlContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${postgresContext.runtime::class}") } private fun createExecuteAsRootFn(): suspend (String) -> Unit = when { postgresContext.options is ProvidedPostgresqlOptions -> { sql: String -> sqlOperations.execute(sql) } postgresContext.runtime is StovePostgresqlContainer -> { sql: String -> val container = postgresContext.runtime // Use execCommand which works via Docker client directly, supporting both // fresh starts and subsequent runs with reuse (where container isn't "started" by testcontainers) container .execCommand( "/bin/bash", "-c", "psql -U ${container.username} -d ${container.databaseName} -c \"$sql\"" ).let { check(it.exitCode == 0) { "Failed to execute sql: $sql, reason: ${it.stderr}" } } } else -> throw UnsupportedOperationException("Unsupported runtime type: ${postgresContext.runtime::class}") } private fun database(exposedConfiguration: RelationalDatabaseExposedConfiguration): Session = sessionOf( url = exposedConfiguration.jdbcUrl, user = exposedConfiguration.username, password = exposedConfiguration.password ) private inline fun withContainerOrWarn( operation: String, action: (StovePostgresqlContainer) -> Unit ): PostgresqlSystem = when (val runtime = postgresContext.runtime) { is StovePostgresqlContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StovePostgresqlContainer) -> Unit) { if (postgresContext.runtime is StovePostgresqlContainer) { action(postgresContext.runtime) } } companion object { /** * Exposes the [NativeSqlOperations] to the [PostgresqlSystem]. * Use this for advanced SQL operations not covered by the DSL. */ fun PostgresqlSystem.operations(): NativeSqlOperations = sqlOperations } } ================================================ FILE: lib/stove-postgres/src/test/kotlin/com/trendyol/stove/postgres/PostgresqlOptionsTest.kt ================================================ package com.trendyol.stove.postgres import com.trendyol.stove.rdbms.RelationalDatabaseExposedConfiguration import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class PostgresqlOptionsTest : FunSpec({ test("PostgresqlOptions.provided should create ProvidedPostgresqlOptions with correct config") { val options = PostgresqlOptions.provided( jdbcUrl = "jdbc:postgresql://localhost:5432/testdb", host = "localhost", port = 5432, databaseName = "testdb", username = "postgres", password = "pgpass", configureExposedConfiguration = { cfg -> listOf("spring.datasource.url=${cfg.jdbcUrl}") } ) options.providedConfig.jdbcUrl shouldBe "jdbc:postgresql://localhost:5432/testdb" options.providedConfig.host shouldBe "localhost" options.providedConfig.port shouldBe 5432 options.providedConfig.username shouldBe "postgres" options.providedConfig.password shouldBe "pgpass" options.runMigrationsForProvided shouldBe true } test("ProvidedPostgresqlOptions should expose correct properties") { val config = RelationalDatabaseExposedConfiguration( jdbcUrl = "jdbc:postgresql://remote:5432/db", host = "remote", port = 5432, username = "user", password = "pass" ) val options = ProvidedPostgresqlOptions( config = config, databaseName = "db", runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("PostgresqlOptions should have sensible defaults") { val options = object : PostgresqlOptions( configureExposedConfiguration = { _ -> listOf() } ) {} options.databaseName shouldBe "stove" options.username shouldBe "sa" options.password shouldBe "sa" options.container shouldNotBe null } test("PostgresqlContainerOptions should have defaults") { val opts = PostgresqlContainerOptions() opts.image shouldBe DEFAULT_POSTGRES_IMAGE_NAME opts.tag shouldBe "latest" } }) ================================================ FILE: lib/stove-postgres/src/test/kotlin/com/trendyol/stove/postgres/PostgresqlSystemTest.kt ================================================ package com.trendyol.stove.postgres import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.shouldBe import kotliquery.param /** * PostgreSQL system tests that run against both container-based and provided instances. * * These tests verify: * - Basic CRUD operations work correctly * - Migrations are executed properly * - The same test code works for both container and provided modes * * To run with provided instance mode: * ``` * ./gradlew :lib:stove-testing-e2e-rdbms-postgres:test -DuseProvided=true * ``` */ class PostgresqlSystemTests : FunSpec({ data class IdAndDescription( val id: Long, val description: String ) test("migration should create MigrationHistory table") { stove { postgresql { shouldQuery( "SELECT * FROM MigrationHistory", mapper = { row -> IdAndDescription(row.long("id"), row.string("description")) } ) { actual -> actual.size shouldBeGreaterThan 0 actual.first() shouldBe IdAndDescription(1, "InitialMigration") } } } } test("should execute DDL and DML statements") { stove { postgresql { shouldExecute( """ DROP TABLE IF EXISTS Dummies; CREATE TABLE IF NOT EXISTS Dummies ( id serial PRIMARY KEY, description VARCHAR (50) NOT NULL ); """.trimIndent() ) shouldExecute("INSERT INTO Dummies (description) VALUES ('${testCase.name.name}')") shouldQuery( "SELECT * FROM Dummies", mapper = { IdAndDescription(it.long("id"), it.string("description")) } ) { actual -> actual.size shouldBeGreaterThan 0 actual.first().description shouldBe testCase.name.name } } } } test("should handle multiple inserts and queries") { stove { postgresql { shouldExecute( """ DROP TABLE IF EXISTS TestItems; CREATE TABLE IF NOT EXISTS TestItems ( id serial PRIMARY KEY, name VARCHAR (100) NOT NULL, value INT NOT NULL ); """.trimIndent() ) // Insert multiple records repeat(5) { i -> shouldExecute("INSERT INTO TestItems (name, value) VALUES ('item_$i', $i)") } // Query and verify data class TestItem( val id: Long, val name: String, val value: Int ) shouldQuery( "SELECT * FROM TestItems ORDER BY value", mapper = { row -> TestItem(row.long("id"), row.string("name"), row.int("value")) } ) { actual -> actual.size shouldBe 5 actual.forEachIndexed { index, item -> item.name shouldBe "item_$index" item.value shouldBe index } } } } } class NullableIdAndDescription { var id: Long? = null var description: String? = null } test("should work with mutable classes") { stove { postgresql { shouldExecute( """ DROP TABLE IF EXISTS Dummies; CREATE TABLE IF NOT EXISTS Dummies ( id serial PRIMARY KEY, description VARCHAR (50) NOT NULL ); """.trimIndent() ) shouldExecute("INSERT INTO Dummies (description) VALUES ('${testCase.name.name}')") shouldQuery( "SELECT * FROM Dummies", mapper = { row -> val result = NullableIdAndDescription() result.id = row.long("id") result.description = row.string("description") result } ) { actual -> actual.size shouldBeGreaterThan 0 actual.first().description shouldBe testCase.name.name } } } } test("should work with parameterized queries") { stove { postgresql { shouldExecute( """ DROP TABLE IF EXISTS Users; CREATE TABLE IF NOT EXISTS Users ( id serial PRIMARY KEY, name VARCHAR (100) NOT NULL, age INT NOT NULL, email VARCHAR (100) NOT NULL ); """.trimIndent() ) // Insert with parameters shouldExecute( sql = "INSERT INTO Users (name, age, email) VALUES (?, ?, ?)", parameters = listOf("Alice".param(), 30.param(), "alice@example.com".param()) ) shouldExecute( sql = "INSERT INTO Users (name, age, email) VALUES (?, ?, ?)", parameters = listOf("Bob".param(), 25.param(), "bob@example.com".param()) ) data class User( val id: Long, val name: String, val age: Int, val email: String ) // Query with parameters shouldQuery( query = "SELECT * FROM Users WHERE age > ? ORDER BY age", parameters = listOf(20.param()), mapper = { row -> User( row.long("id"), row.string("name"), row.int("age"), row.string("email") ) } ) { actual -> actual.size shouldBe 2 actual.first().name shouldBe "Bob" actual.last().name shouldBe "Alice" } // Query with multiple parameters shouldQuery( query = "SELECT * FROM Users WHERE name = ? AND age = ?", parameters = listOf("Alice".param(), 30.param()), mapper = { row -> User( row.long("id"), row.string("name"), row.int("age"), row.string("email") ) } ) { actual -> actual.size shouldBe 1 actual.first().apply { name shouldBe "Alice" age shouldBe 30 email shouldBe "alice@example.com" } } } } } }) ================================================ FILE: lib/stove-postgres/src/test/kotlin/com/trendyol/stove/postgres/TestSystemConfig.kt ================================================ package com.trendyol.stove.postgres import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.slf4j.* import org.testcontainers.postgresql.PostgreSQLContainer // ============================================================================ // Shared components // ============================================================================ class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } class InitialMigration : PostgresqlMigration { private val logger: Logger = LoggerFactory.getLogger(InitialMigration::class.java) override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { logger.info("Executing InitialMigration") connection.operations.execute( """ DROP TABLE IF EXISTS MigrationHistory; CREATE TABLE IF NOT EXISTS MigrationHistory ( id serial PRIMARY KEY, description VARCHAR (50) NOT NULL ); INSERT INTO MigrationHistory (description) VALUES ('InitialMigration'); """.trimIndent() ) logger.info("InitialMigration executed") } } // ============================================================================ // Strategy interface // ============================================================================ sealed interface PostgresTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): PostgresTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedPostgresStrategy() else ContainerPostgresStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerPostgresStrategy : PostgresTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting PostgreSQL tests with container mode") val options = PostgresqlOptions( databaseName = "testing", configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { postgresql { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("PostgreSQL container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedPostgresStrategy : PostgresTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: PostgreSQLContainer override suspend fun start() { logger.info("Starting PostgreSQL tests with provided mode") // Start an external container to simulate a provided instance externalContainer = PostgreSQLContainer("postgres:15-alpine").apply { withDatabaseName("testing") withUsername("postgres") withPassword("postgres") start() } logger.info("External PostgreSQL container started at ${externalContainer.jdbcUrl}") val options = PostgresqlOptions .provided( jdbcUrl = externalContainer.jdbcUrl, host = externalContainer.host, port = externalContainer.firstMappedPort, databaseName = "testing", username = externalContainer.username, password = externalContainer.password, runMigrations = true, cleanup = { sqlOps -> logger.info("Running cleanup on provided instance") sqlOps.execute("DROP TABLE IF EXISTS MigrationHistory CASCADE") }, configureExposedConfiguration = { _ -> listOf() } ).migrations { register() } Stove() .with { postgresql { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("PostgreSQL provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = PostgresTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } ================================================ FILE: lib/stove-postgres/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.postgres.StoveConfig ================================================ FILE: lib/stove-postgres/src/test/resources/logback.xml ================================================ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n ================================================ FILE: lib/stove-rdbms/api/stove-rdbms.api ================================================ public final class com/trendyol/stove/rdbms/NativeSqlOperations : java/lang/AutoCloseable { public fun (Lkotliquery/Session;)V public fun close ()V public final fun execute (Ljava/lang/String;Ljava/util/List;)I public static synthetic fun execute$default (Lcom/trendyol/stove/rdbms/NativeSqlOperations;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)I public final fun select (Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function1;)Ljava/util/List; public static synthetic fun select$default (Lcom/trendyol/stove/rdbms/NativeSqlOperations;Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/util/List; } public abstract class com/trendyol/stove/rdbms/RelationalDatabaseContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lkotlin/jvm/functions/Function1;)V public final fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; } public final class com/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()I public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getHost ()Ljava/lang/String; public final fun getJdbcUrl ()Ljava/lang/String; public final fun getPassword ()Ljava/lang/String; public final fun getPort ()I public final fun getUsername ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract class com/trendyol/stove/rdbms/RelationalDatabaseSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/rdbms/RelationalDatabaseSystem$Companion; protected field exposedConfiguration Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; protected field sqlOperations Lcom/trendyol/stove/rdbms/NativeSqlOperations; protected fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/rdbms/RelationalDatabaseContext;)V public fun close ()V public fun configuration ()Ljava/util/List; protected abstract fun database (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;)Lkotliquery/Session; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; protected final fun getContext ()Lcom/trendyol/stove/rdbms/RelationalDatabaseContext; protected final fun getExposedConfiguration ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public final fun getInternalSqlOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; protected abstract fun getProvidedConfig ()Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; protected final fun getSqlOperations ()Lcom/trendyol/stove/rdbms/NativeSqlOperations; public final fun getStove ()Lcom/trendyol/stove/system/Stove; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; protected final fun setExposedConfiguration (Lcom/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration;)V public final fun setInternalSqlOperations (Lcom/trendyol/stove/rdbms/NativeSqlOperations;)V protected final fun setSqlOperations (Lcom/trendyol/stove/rdbms/NativeSqlOperations;)V public final fun shouldExecute (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/rdbms/RelationalDatabaseSystem$Companion { public final fun operations (Lcom/trendyol/stove/rdbms/RelationalDatabaseSystem;)Lcom/trendyol/stove/rdbms/NativeSqlOperations; } ================================================ FILE: lib/stove-rdbms/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.testcontainers.jdbc) api(libs.kotliquery) api(libs.h2Database) testImplementation(libs.mockito.kotlin) } ================================================ FILE: lib/stove-rdbms/src/main/kotlin/com/trendyol/stove/rdbms/NativeSqlOperations.kt ================================================ package com.trendyol.stove.rdbms import kotliquery.* class NativeSqlOperations( private val session: Session ) : AutoCloseable { fun execute( sql: String, parameters: List> = emptyList() ): Int = session .run(queryOf(sql, *parameters.toTypedArray()).asUpdate) fun select( sql: String, parameters: List> = emptyList(), rowMapper: (Row) -> T ): List = session .run(queryOf(sql, *parameters.toTypedArray()).map(rowMapper).asList) override fun close() { session.close() } } ================================================ FILE: lib/stove-rdbms/src/main/kotlin/com/trendyol/stove/rdbms/RelationalDatabaseContext.kt ================================================ package com.trendyol.stove.rdbms import com.trendyol.stove.system.abstractions.SystemRuntime abstract class RelationalDatabaseContext( val runtime: SystemRuntime, val configureExposedConfiguration: (RelationalDatabaseExposedConfiguration) -> List ) ================================================ FILE: lib/stove-rdbms/src/main/kotlin/com/trendyol/stove/rdbms/RelationalDatabaseExposedConfiguration.kt ================================================ package com.trendyol.stove.rdbms import com.trendyol.stove.system.abstractions.ExposedConfiguration data class RelationalDatabaseExposedConfiguration( val jdbcUrl: String, val host: String, val port: Int, val password: String, val username: String ) : ExposedConfiguration ================================================ FILE: lib/stove-rdbms/src/main/kotlin/com/trendyol/stove/rdbms/RelationalDatabaseSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.rdbms import arrow.core.Some import com.trendyol.stove.containers.StoveContainer import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.Reports import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.runBlocking import kotliquery.* import org.slf4j.* import org.testcontainers.containers.JdbcDatabaseContainer @Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") @StoveDsl abstract class RelationalDatabaseSystem> protected constructor( final override val stove: Stove, protected val context: RelationalDatabaseContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { private val logger: Logger = LoggerFactory.getLogger(javaClass) protected lateinit var exposedConfiguration: RelationalDatabaseExposedConfiguration protected lateinit var sqlOperations: NativeSqlOperations private val state: StateStorage = stove.createStateStorage>() protected abstract fun database(exposedConfiguration: RelationalDatabaseExposedConfiguration): Session override suspend fun run() { exposedConfiguration = when (val runtime = context.runtime) { is StoveContainer -> { val jdbcContainer = runtime as JdbcDatabaseContainer<*> state.capture { jdbcContainer.start() RelationalDatabaseExposedConfiguration( jdbcUrl = jdbcContainer.jdbcUrl, host = jdbcContainer.host, port = jdbcContainer.firstMappedPort, password = jdbcContainer.password, username = jdbcContainer.username ) } } is ProvidedRuntime -> { getProvidedConfig() } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } sqlOperations = NativeSqlOperations(database(exposedConfiguration)) } /** * Gets the provided configuration from subclass options. * Subclasses should override this to provide their specific provided config. */ protected abstract fun getProvidedConfig(): RelationalDatabaseExposedConfiguration override fun configuration(): List = context.configureExposedConfiguration(exposedConfiguration) suspend inline fun shouldQuery( query: String, crossinline mapper: (Row) -> T, crossinline assertion: (List) -> Unit ): SELF { report( action = "Query", input = Some(query.trim()), metadata = mapOf("sql" to query.trim()) ) { val results = internalSqlOperations.select(query) { mapper(it) } assertion(results) "${results.size} row(s) returned" } return this as SELF } suspend fun shouldExecute(sql: String): SELF { report( action = "Execute SQL", input = Some(sql.trim()), metadata = mapOf("sql" to sql.trim()) ) { val affectedRows = internalSqlOperations.execute(sql) check(affectedRows >= 0) { "Failed to execute sql: $sql" } "$affectedRows row(s) affected" } return this as SELF } override suspend fun stop() { if (context.runtime is StoveContainer) { (context.runtime as JdbcDatabaseContainer<*>).stop() } } override fun close(): Unit = runBlocking { Try { // Note: cleanup is handled in subclass via options sqlOperations.close() executeWithReuseCheck { stop() } }.recover { val containerInfo = when (val runtime = context.runtime) { is JdbcDatabaseContainer<*> -> runtime.containerName is ProvidedRuntime -> "provided instance" else -> "unknown runtime" } logger.warn("got an error while stopping the container $containerInfo ") }.let { } } @PublishedApi internal var internalSqlOperations: NativeSqlOperations get() = sqlOperations set(value) { sqlOperations = value } companion object { /** * Exposes the [NativeSqlOperations] to the [RelationalDatabaseSystem]. * Use this for advanced SQL operations not covered by the DSL. */ @Suppress("unused") fun RelationalDatabaseSystem<*>.operations(): NativeSqlOperations = this.sqlOperations } } ================================================ FILE: lib/stove-rdbms/src/test/kotlin/com/trendyol/stove/rdbms/RelationalDatabaseContextTest.kt ================================================ package com.trendyol.stove.rdbms import com.trendyol.stove.system.abstractions.ProvidedRuntime import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class RelationalDatabaseContextTest : FunSpec({ test("should hold runtime and configuration mapper") { val context = object : RelationalDatabaseContext( runtime = ProvidedRuntime, configureExposedConfiguration = { cfg -> listOf("jdbc=${cfg.jdbcUrl}") } ) {} context.runtime shouldBe ProvidedRuntime context.configureExposedConfiguration( RelationalDatabaseExposedConfiguration("jdbc:h2:mem:test", "localhost", 0, "", "sa") ) shouldBe listOf("jdbc=jdbc:h2:mem:test") } }) ================================================ FILE: lib/stove-rdbms/src/test/kotlin/com/trendyol/stove/rdbms/RelationalDatabaseSystemTest.kt ================================================ package com.trendyol.stove.rdbms import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ProvidedRuntime import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import kotliquery.sessionOf class RelationalDatabaseSystemTest : FunSpec({ test("run should initialize configuration and sql operations for provided runtime") { val stove = Stove() val system = TestRelationalDatabaseSystem(stove) runBlocking { system.run() } system.configuration().joinToString() shouldContain "jdbc:h2:mem:testdb" system.internalSqlOperations shouldNotBe null } test("shouldExecute and shouldQuery should use sql operations") { val stove = Stove() val system = TestRelationalDatabaseSystem(stove) runBlocking { system.run() system.shouldExecute( """ CREATE TABLE IF NOT EXISTS users ( id INT PRIMARY KEY, name VARCHAR(50) ) """.trimIndent() ) system.shouldExecute("INSERT INTO users (id, name) VALUES (1, 'alice')") data class User( val id: Int, val name: String ) system.shouldQuery( query = "SELECT * FROM users", mapper = { row -> User(row.int("id"), row.string("name")) } ) { users -> users.size shouldBe 1 users.first().name shouldBe "alice" } } } }) private class TestRelationalDatabaseSystem( stove: Stove ) : RelationalDatabaseSystem( stove = stove, context = object : RelationalDatabaseContext( runtime = ProvidedRuntime, configureExposedConfiguration = { cfg -> listOf("jdbcUrl=${cfg.jdbcUrl}") } ) {} ) { private val config = RelationalDatabaseExposedConfiguration( jdbcUrl = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", host = "localhost", port = 0, username = "sa", password = "" ) override fun database(exposedConfiguration: RelationalDatabaseExposedConfiguration) = sessionOf( url = exposedConfiguration.jdbcUrl, user = exposedConfiguration.username, password = exposedConfiguration.password ) override fun getProvidedConfig(): RelationalDatabaseExposedConfiguration = config } ================================================ FILE: lib/stove-redis/api/stove-redis.api ================================================ public final class com/trendyol/stove/redis/ProvidedRedisOptions : com/trendyol/stove/redis/RedisOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/redis/RedisExposedConfiguration;ILjava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/redis/RedisExposedConfiguration;ILjava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/redis/RedisExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/redis/RedisExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public final class com/trendyol/stove/redis/RedisContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/redis/RedisContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/redis/RedisContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/redis/RedisContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/redis/RedisContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/redis/RedisOptions;Ljava/lang/String;)V public synthetic fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/redis/RedisOptions;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/redis/RedisOptions; public final fun component3 ()Ljava/lang/String; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/redis/RedisOptions;Ljava/lang/String;)Lcom/trendyol/stove/redis/RedisContext; public static synthetic fun copy$default (Lcom/trendyol/stove/redis/RedisContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/redis/RedisOptions;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/redis/RedisContext; public fun equals (Ljava/lang/Object;)Z public final fun getKeyName ()Ljava/lang/String; public final fun getOptions ()Lcom/trendyol/stove/redis/RedisOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/redis/RedisExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/redis/RedisExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/redis/RedisExposedConfiguration;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/redis/RedisExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getDatabase ()Ljava/lang/String; public final fun getHost ()Ljava/lang/String; public final fun getPassword ()Ljava/lang/String; public final fun getPort ()I public final fun getRedisUri ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/redis/RedisMigrationContext { public fun (Lio/lettuce/core/api/StatefulRedisConnection;Lcom/trendyol/stove/redis/RedisOptions;)V public final fun component1 ()Lio/lettuce/core/api/StatefulRedisConnection; public final fun component2 ()Lcom/trendyol/stove/redis/RedisOptions; public final fun copy (Lio/lettuce/core/api/StatefulRedisConnection;Lcom/trendyol/stove/redis/RedisOptions;)Lcom/trendyol/stove/redis/RedisMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/redis/RedisMigrationContext;Lio/lettuce/core/api/StatefulRedisConnection;Lcom/trendyol/stove/redis/RedisOptions;ILjava/lang/Object;)Lcom/trendyol/stove/redis/RedisMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getConnection ()Lio/lettuce/core/api/StatefulRedisConnection; public final fun getOptions ()Lcom/trendyol/stove/redis/RedisOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public class com/trendyol/stove/redis/RedisOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/redis/RedisOptions$Companion; public fun (ILjava/lang/String;Lcom/trendyol/stove/redis/RedisContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (ILjava/lang/String;Lcom/trendyol/stove/redis/RedisContainerOptions;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainer ()Lcom/trendyol/stove/redis/RedisContainerOptions; public fun getDatabase ()I public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getPassword ()Ljava/lang/String; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/redis/RedisOptions; } public final class com/trendyol/stove/redis/RedisOptions$Companion { public final fun provided (Ljava/lang/String;ILjava/lang/String;IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/redis/ProvidedRedisOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/redis/RedisOptions$Companion;Ljava/lang/String;ILjava/lang/String;IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/redis/ProvidedRedisOptions; } public final class com/trendyol/stove/redis/RedisOptionsKt { public static final fun redis-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun redis-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun redis-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun redis-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/redis/RedisSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public static final field Companion Lcom/trendyol/stove/redis/RedisSystem$Companion; public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/redis/RedisContext;)V public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/redis/RedisSystem$Companion { public final fun client (Lcom/trendyol/stove/redis/RedisSystem;)Lio/lettuce/core/RedisClient; } public class com/trendyol/stove/redis/StoveRedisContainer : com/redis/testcontainers/RedisContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } ================================================ FILE: lib/stove-redis/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.lettuce.core) api(libs.testcontainers.redis) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) } val testWithProvided = tasks.register("testWithProvided") { group = "verification" description = "Runs tests with an externally provided Redis instance" testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath useJUnitPlatform() systemProperty("useProvided", "true") doFirst { println("Starting Redis tests with provided instance...") } } tasks.test.configure { dependsOn(testWithProvided) } ================================================ FILE: lib/stove-redis/src/main/kotlin/com/trendyol/stove/redis/RedisOptions.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.redis import arrow.core.getOrElse import com.redis.testcontainers.RedisContainer import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.lettuce.core.RedisClient import io.lettuce.core.api.StatefulRedisConnection import org.testcontainers.utility.DockerImageName open class StoveRedisContainer( override val imageNameAccess: DockerImageName ) : RedisContainer(imageNameAccess), StoveContainer data class RedisContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = RedisContainer.DEFAULT_IMAGE_NAME.unversionedPart, override val tag: String = RedisContainer.DEFAULT_TAG, override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveRedisContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions /** * Context provided to Redis migrations. * Contains the Redis connection and options for performing setup operations. * * @property connection The Redis connection for executing commands * @property options The Redis system options */ @StoveDsl data class RedisMigrationContext( val connection: StatefulRedisConnection, val options: RedisOptions ) /** * Convenience type alias for Redis migrations. * * Instead of writing `DatabaseMigration`, use `RedisMigration`: * ```kotlin * class MyMigration : RedisMigration { * override val order: Int = 1 * override suspend fun execute(connection: RedisMigrationContext) { ... } * } * ``` */ typealias RedisMigration = DatabaseMigration /** * Options for configuring the Redis system in container mode. */ @StoveDsl open class RedisOptions( open val database: Int = 8, open val password: String = "password", open val container: RedisContainerOptions = RedisContainerOptions(), open val cleanup: suspend (RedisClient) -> Unit = {}, override val configureExposedConfiguration: (RedisExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { /** * Creates options configured to use an externally provided Redis instance * instead of a testcontainer. * * @param host The Redis host * @param port The Redis port * @param password The Redis password * @param database The Redis database number * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( host: String, port: Int, password: String, database: Int = 8, runMigrations: Boolean = true, cleanup: suspend (RedisClient) -> Unit = {}, configureExposedConfiguration: (RedisExposedConfiguration) -> List ): ProvidedRedisOptions = ProvidedRedisOptions( config = RedisExposedConfiguration( host = host, port = port, redisUri = "redis://$host:$port", database = database.toString(), password = password ), database = database, password = password, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided Redis instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedRedisOptions( /** * The configuration for the provided Redis instance. */ val config: RedisExposedConfiguration, database: Int = 8, password: String = "password", /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, cleanup: suspend (RedisClient) -> Unit = {}, configureExposedConfiguration: (RedisExposedConfiguration) -> List ) : RedisOptions( database = database, password = password, container = RedisContainerOptions(), cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: RedisExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } @StoveDsl data class RedisExposedConfiguration( val host: String, val port: Int, val redisUri: String, val database: String, val password: String ) : ExposedConfiguration @StoveDsl data class RedisContext( val runtime: SystemRuntime, val options: RedisOptions, val keyName: String? = null ) /** * Configures Redis system. * * For container-based setup: * ```kotlin * redis { * RedisOptions( * database = 8, * password = "password", * cleanup = { client -> client.connect().sync().flushall() }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` * * For provided (external) instance: * ```kotlin * redis { * RedisOptions.provided( * host = "localhost", * port = 6379, * password = "password", * database = 8, * cleanup = { client -> client.connect().sync().flushall() }, * configureExposedConfiguration = { cfg -> listOf(...) } * ) * } * ``` */ fun WithDsl.redis( configure: () -> RedisOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedRedisOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.image, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withCommand("redis-server", "--requirepass", options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveRedisContainer } .apply(options.container.containerFn) } } return stove.withRedis(options, runtime) } fun WithDsl.redis( key: SystemKey, configure: () -> RedisOptions ): Stove { val options = configure() val runtime: SystemRuntime = if (options is ProvidedRedisOptions) { ProvidedRuntime } else { withProvidedRegistry( options.container.image, options.container.registry, options.container.compatibleSubstitute ) { dockerImageName -> options.container .useContainerFn(dockerImageName) .withCommand("redis-server", "--requirepass", options.password) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveRedisContainer } .apply(options.container.containerFn) } } return stove.withRedis(key, options, runtime) } suspend fun ValidationDsl.redis(validation: suspend RedisSystem.() -> Unit): Unit = validation(this.stove.redis()) suspend fun ValidationDsl.redis(key: SystemKey, validation: suspend RedisSystem.() -> Unit): Unit = validation(this.stove.redis(key)) internal fun Stove.redis(): RedisSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(RedisSystem::class) } internal fun Stove.redis(key: SystemKey): RedisSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException(RedisSystem::class, "No RedisSystem registered with key '${keyDisplayName(key)}'") } internal fun Stove.withRedis( options: RedisOptions, runtime: SystemRuntime ): Stove { getOrRegister(RedisSystem(this, RedisContext(runtime, options))) return this } internal fun Stove.withRedis( key: SystemKey, options: RedisOptions, runtime: SystemRuntime ): Stove { getOrRegister(key, RedisSystem(this, RedisContext(runtime, options, keyName = keyDisplayName(key)))) return this } ================================================ FILE: lib/stove-redis/src/main/kotlin/com/trendyol/stove/redis/RedisSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.redis import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.* import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.lettuce.core.* import kotlinx.coroutines.runBlocking import org.slf4j.* import reactor.core.publisher.Mono /** * Redis cache/data store system for testing caching operations. * * Provides access to a Lettuce Redis client for testing Redis operations. * Use [client] to access the underlying [RedisClient] for all Redis operations. * * ## Accessing Redis Client * * All Redis operations are performed through the Lettuce client: * * ```kotlin * redis { * val conn = client().connect().sync() * * // Set a simple string value * conn.set("user:123", "John Doe") * * // Set with expiration (TTL in seconds) * conn.setex("session:abc", 3600, sessionData) * * // Get a value * val value = conn.get("user:123") * value shouldBe "John Doe" * * // Check key existence * val exists = conn.exists("user:123") * exists shouldBe 1L * * // Delete a key * conn.del("user:123") * * // Get TTL * val ttl = conn.ttl("session:abc") * ttl shouldBeGreaterThan 0 * } * ``` * * ## Fault Injection Testing * * Test application behavior during cache outages: * * ```kotlin * redis { * pause() // Simulate Redis outage * } * * // Test application graceful degradation * http { * get("/users/123") { user -> * // Should still work (from database fallback) * user.name shouldBe "John" * } * } * * redis { * unpause() // Restore Redis * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should cache user after first request") { * stove { * // Ensure cache is empty * redis { * val conn = client().connect().sync() * conn.get("user:cache:123") shouldBe null * } * * // First request - cache miss, loads from DB * http { * get("/users/123") { user -> * user.name shouldBe "John" * } * } * * // Verify user is now cached * redis { * val conn = client().connect().sync() * val cached = conn.get("user:cache:123") * cached shouldNotBe null * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * redis { * RedisSystemOptions( * database = 0, * configureExposedConfiguration = { cfg -> * listOf( * "spring.redis.host=${cfg.host}", * "spring.redis.port=${cfg.port}" * ) * } * ) * } * } * ``` * * @property stove The parent test system. * @see RedisSystemOptions * @see RedisExposedConfiguration */ @StoveDsl class RedisSystem( override val stove: Stove, private val context: RedisContext ) : PluggedSystem, RunAware, ExposesConfiguration, Reports { private lateinit var client: RedisClient override val reportSystemName: String = "Redis" + (context.keyName?.let { " [$it]" } ?: "") private lateinit var exposedConfiguration: RedisExposedConfiguration private val logger: Logger = LoggerFactory.getLogger(javaClass) private val state: StateStorage = stove.createStateStorage(context.keyName) override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() client = createClient(exposedConfiguration) runMigrationsIfNeeded() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun close(): Unit = runBlocking { Try { if (::client.isInitialized) { context.options.cleanup(client) client.shutdown() } executeWithReuseCheck { stop() } }.recover { logger.warn("Redis client shutdown failed", it) } } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return RedisSystem */ suspend fun pause(): RedisSystem { report( action = "Pause container", metadata = mapOf("operation" to "fault-injection") ) { withContainerOrWarn("pause") { it.pause() } } return this } /** * Unpauses the container. Use with care, as it will unpause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return RedisSystem */ suspend fun unpause(): RedisSystem { report(action = "Unpause container") { withContainerOrWarn("unpause") { it.unpause() } } return this } private suspend fun obtainExposedConfiguration(): RedisExposedConfiguration = when { context.options is ProvidedRedisOptions -> context.options.config context.runtime is StoveRedisContainer -> startRedisContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startRedisContainer(container: StoveRedisContainer): RedisExposedConfiguration = state.capture { container.start() RedisExposedConfiguration( host = container.host, port = container.firstMappedPort, redisUri = container.redisURI, database = context.options.database.toString(), password = context.options.password ) } private fun createClient(config: RedisExposedConfiguration): RedisClient = RedisClient.create( RedisURI.create(config.host, config.port).apply { setCredentialsProvider { Mono.just(RedisCredentials.just(null, config.password)) } } ) private inline fun withContainerOrWarn( operation: String, action: (StoveRedisContainer) -> Unit ): RedisSystem = when (val runtime = context.runtime) { is StoveRedisContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveRedisContainer) -> Unit) { if (context.runtime is StoveRedisContainer) { action(context.runtime) } } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { client.connect().use { connection -> context.options.migrationCollection.run(RedisMigrationContext(connection, context.options)) } } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedRedisOptions -> context.options.runMigrations context.runtime is StoveRedisContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private fun isInitialized(): Boolean = ::client.isInitialized companion object { fun RedisSystem.client(): RedisClient { if (!isInitialized()) throw SystemNotInitializedException(RedisSystem::class) return client } } } ================================================ FILE: lib/stove-redis/src/test/kotlin/com/trendyol/stove/redis/RedisOptionsTest.kt ================================================ package com.trendyol.stove.redis import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class RedisOptionsTest : FunSpec({ test("RedisExposedConfiguration should hold connection details") { val cfg = RedisExposedConfiguration( host = "localhost", port = 6379, redisUri = "redis://localhost:6379", database = "8", password = "secret" ) cfg.host shouldBe "localhost" cfg.port shouldBe 6379 cfg.redisUri shouldBe "redis://localhost:6379" cfg.database shouldBe "8" cfg.password shouldBe "secret" } test("RedisOptions.provided should create ProvidedRedisOptions with correct config") { val options = RedisOptions.provided( host = "redis-host", port = 6379, password = "pass", database = 5, configureExposedConfiguration = { cfg -> listOf("redis.host=${cfg.host}", "redis.port=${cfg.port}") } ) options.providedConfig.host shouldBe "redis-host" options.providedConfig.port shouldBe 6379 options.providedConfig.redisUri shouldBe "redis://redis-host:6379" options.providedConfig.database shouldBe "5" options.providedConfig.password shouldBe "pass" options.runMigrationsForProvided shouldBe true } test("ProvidedRedisOptions should expose correct properties") { val config = RedisExposedConfiguration( host = "remote", port = 6380, redisUri = "redis://remote:6380", database = "3", password = "p" ) val options = ProvidedRedisOptions( config = config, database = 3, password = "p", runMigrations = false, configureExposedConfiguration = { _ -> listOf() } ) options.config shouldBe config options.providedConfig shouldBe config options.runMigrationsForProvided shouldBe false } test("RedisOptions should have sensible defaults") { val options = object : RedisOptions( configureExposedConfiguration = { _ -> listOf() } ) {} options.database shouldBe 8 options.password shouldBe "password" options.container shouldNotBe null } }) ================================================ FILE: lib/stove-redis/src/test/kotlin/com/trendyol/stove/redis/RedisSystemTests.kt ================================================ package com.trendyol.stove.redis import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.redis.RedisSystem.Companion.client import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.future.await import org.slf4j.* import org.testcontainers.containers.GenericContainer import org.testcontainers.utility.DockerImageName // ============================================================================ // Shared components // ============================================================================ class NoOpApplication : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } // ============================================================================ // Strategy interface // ============================================================================ sealed interface RedisTestStrategy { val logger: Logger suspend fun start() suspend fun stop() companion object { fun select(): RedisTestStrategy { val useProvided = System.getenv("USE_PROVIDED")?.toBoolean() ?: System.getProperty("useProvided")?.toBoolean() ?: false return if (useProvided) ProvidedRedisStrategy() else ContainerRedisStrategy() } } } // ============================================================================ // Container-based strategy (default) // ============================================================================ class ContainerRedisStrategy : RedisTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) override suspend fun start() { logger.info("Starting Redis tests with container mode") val options = RedisOptions( container = RedisContainerOptions(), configureExposedConfiguration = { _ -> listOf() } ) Stove() .with { redis { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() logger.info("Redis container tests completed") } } // ============================================================================ // Provided instance strategy // ============================================================================ class ProvidedRedisStrategy : RedisTestStrategy { override val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var externalContainer: GenericContainer<*> override suspend fun start() { logger.info("Starting Redis tests with provided mode") // Start an external container to simulate a provided instance externalContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) .withExposedPorts(6379) .withCommand("redis-server", "--requirepass", "password") .apply { start() } logger.info("External Redis container started at ${externalContainer.host}:${externalContainer.firstMappedPort}") val options = RedisOptions .provided( host = externalContainer.host, port = externalContainer.firstMappedPort, password = "password", runMigrations = true, cleanup = { client -> logger.info("Running cleanup on provided instance") // Clean up test data if needed }, configureExposedConfiguration = { _ -> listOf() } ) Stove() .with { redis { options } applicationUnderTest(NoOpApplication()) }.run() } override suspend fun stop() { com.trendyol.stove.system.Stove .stop() externalContainer.stop() logger.info("Redis provided tests completed") } } // ============================================================================ // Kotest project config - selects strategy based on environment // ============================================================================ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) private val strategy = RedisTestStrategy.select() override suspend fun beforeProject() = strategy.start() override suspend fun afterProject() = strategy.stop() } // ============================================================================ // Tests // ============================================================================ class RedisSystemTests : ShouldSpec({ should("work") { stove { redis { client() .connect() .async() .ping() .await() shouldBe "PONG" } } } }) ================================================ FILE: lib/stove-redis/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.redis.StoveConfig ================================================ FILE: lib/stove-tracing/api/stove-tracing.api ================================================ public abstract class com/trendyol/stove/tracing/OTLPReceiverError : java/lang/Exception { public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/trendyol/stove/tracing/OTLPReceiverError$StartupFailed : com/trendyol/stove/tracing/OTLPReceiverError { public fun (ILjava/io/IOException;)V public final fun component1 ()I public final fun component2 ()Ljava/io/IOException; public final fun copy (ILjava/io/IOException;)Lcom/trendyol/stove/tracing/OTLPReceiverError$StartupFailed; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/OTLPReceiverError$StartupFailed;ILjava/io/IOException;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/OTLPReceiverError$StartupFailed; public fun equals (Ljava/lang/Object;)Z public fun getCause ()Ljava/io/IOException; public synthetic fun getCause ()Ljava/lang/Throwable; public final fun getPort ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/OTLPSpanReceiver { public fun (Lcom/trendyol/stove/tracing/StoveTraceCollector;I)V public synthetic fun (Lcom/trendyol/stove/tracing/StoveTraceCollector;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEndpoint ()Ljava/lang/String; public final fun start ()Larrow/core/Either; public final fun stop ()V } public final class com/trendyol/stove/tracing/StoveTraceCollector { public fun ()V public final fun addSpanListener (Lcom/trendyol/stove/reporting/SpanEventListener;)V public final fun clear (Ljava/lang/String;)V public final fun clearAll ()V public final fun clearForTest (Ljava/lang/String;)V public final fun getAllTraces ()Ljava/util/Map; public final fun getFailedSpans (Ljava/lang/String;)Ljava/util/List; public final fun getTestId (Ljava/lang/String;)Ljava/lang/String; public final fun getTrace (Ljava/lang/String;)Ljava/util/List; public final fun getTraceTree (Ljava/lang/String;)Lcom/trendyol/stove/tracing/SpanNode; public final fun getTracesForTest (Ljava/lang/String;)Ljava/util/List; public final fun hasFailures (Ljava/lang/String;)Z public final fun record (Lcom/trendyol/stove/tracing/SpanInfo;)V public final fun recordAll (Ljava/util/Collection;)V public final fun registerTrace (Ljava/lang/String;Ljava/lang/String;)V public final fun removeSpanListener (Lcom/trendyol/stove/reporting/SpanEventListener;)V public final fun spanCount (Ljava/lang/String;)I public final fun totalSpanCount ()I public final fun traceCount ()I public final fun waitForSpans (Ljava/lang/String;IJ)Ljava/util/List; public static synthetic fun waitForSpans$default (Lcom/trendyol/stove/tracing/StoveTraceCollector;Ljava/lang/String;IJILjava/lang/Object;)Ljava/util/List; } public final class com/trendyol/stove/tracing/TraceReportBuilder { public static final field DEFAULT_ERROR_MESSAGE Ljava/lang/String; public static final field INSTANCE Lcom/trendyol/stove/tracing/TraceReportBuilder; public final fun buildFullReport ()Ljava/lang/String; public final fun shouldEnrichFailures (Lcom/trendyol/stove/system/StoveOptions;)Z } public final class com/trendyol/stove/tracing/TraceValidationDsl { public fun (Lcom/trendyol/stove/tracing/StoveTraceCollector;Ljava/lang/String;)V public final fun executionTimeShouldBeGreaterThan-LRDsOJo (J)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun executionTimeShouldBeLessThan-LRDsOJo (J)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun findSpan (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/tracing/SpanInfo; public final fun findSpanByName (Ljava/lang/String;)Lcom/trendyol/stove/tracing/SpanInfo; public final fun getFailedSpanCount ()I public final fun getFailedSpans ()Ljava/util/List; public final fun getSpanCount ()I public final fun getTotalDuration-UwyO8pc ()J public final fun renderSummary ()Ljava/lang/String; public final fun renderTree ()Ljava/lang/String; public final fun shouldContainSpan (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldContainSpanMatching (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldHaveFailedSpan (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldHaveSpanWithAttribute (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldHaveSpanWithAttributeContaining (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldNotContainSpan (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldNotHaveFailedSpans ()Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanCountShouldBe (I)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanCountShouldBeAtLeast (I)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanCountShouldBeAtMost (I)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanTree ()Lcom/trendyol/stove/tracing/SpanNode; } public final class com/trendyol/stove/tracing/TracingConstants { public static final field DEFAULT_MAX_SPANS_PER_TRACE I public static final field DEFAULT_OTLP_GRPC_PORT I public static final field DEFAULT_OTLP_HTTP_PORT I public static final field DEFAULT_SPAN_POLL_INTERVAL_MS J public static final field DEFAULT_SPAN_WAIT_TIMEOUT_MS J public static final field INSTANCE Lcom/trendyol/stove/tracing/TracingConstants; public static final field MAX_STACK_TRACE_LINES I public static final field NANOS_TO_MILLIS J public static final field OTEL_EXCEPTION_EVENT_NAME Ljava/lang/String; public static final field OTEL_EXCEPTION_MESSAGE_ATTRIBUTE Ljava/lang/String; public static final field OTEL_EXCEPTION_STACKTRACE_ATTRIBUTE Ljava/lang/String; public static final field OTEL_EXCEPTION_TYPE_ATTRIBUTE Ljava/lang/String; public static final field OTEL_SERVICE_NAME_ATTRIBUTE Ljava/lang/String; public static final field OTEL_STATUS_CODE_ERROR I public static final field SERVER_SHUTDOWN_TIMEOUT_SECONDS J public static final field STOVE_TRACING_PORT_ENV Ljava/lang/String; public static final field STRAGGLER_WAIT_TIME_MS J public final fun getGRPC_INTERNAL_SPAN_PATTERNS ()Ljava/util/List; } public abstract interface annotation class com/trendyol/stove/tracing/TracingDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/tracing/TracingOptions { public fun ()V public final fun copy ()Lcom/trendyol/stove/tracing/TracingOptions; public final fun disabled ()Lcom/trendyol/stove/tracing/TracingOptions; public final fun enableSpanReceiver (Ljava/lang/Integer;)Lcom/trendyol/stove/tracing/TracingOptions; public static synthetic fun enableSpanReceiver$default (Lcom/trendyol/stove/tracing/TracingOptions;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/TracingOptions; public final fun enabled ()Lcom/trendyol/stove/tracing/TracingOptions; public final fun getEnabled ()Z public final fun getMaxSpansPerTrace ()I public final fun getSpanCollectionTimeout-UwyO8pc ()J public final fun getSpanFilter ()Lkotlin/jvm/functions/Function1; public final fun getSpanReceiverEnabled ()Z public final fun getSpanReceiverPort ()I public final fun maxSpansPerTrace (I)Lcom/trendyol/stove/tracing/TracingOptions; public final fun spanCollectionTimeout-LRDsOJo (J)Lcom/trendyol/stove/tracing/TracingOptions; public final fun spanFilter (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/tracing/TracingOptions; } public final class com/trendyol/stove/tracing/TracingSystem : com/trendyol/stove/reporting/SpanListenerRegistry, com/trendyol/stove/reporting/TraceProvider, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware { public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/tracing/TracingSystemOptions;)V public fun addSpanListener (Lcom/trendyol/stove/reporting/SpanEventListener;)V public fun close ()V public final fun currentContext ()Larrow/core/Option; public final fun endTrace ()V public final fun ensureTraceStarted (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getStove ()Lcom/trendyol/stove/system/Stove; public fun getTraceVisualizationForCurrentTest (J)Larrow/core/Option; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun validation ()Larrow/core/Option; public final fun validation (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; } public final class com/trendyol/stove/tracing/TracingSystemKt { public static final fun tracing-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun tracing-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/system/Stove; public static synthetic fun tracing-ypJx7X8$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/system/Stove; public static final fun tracingSystem-71YW2E0 (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/tracing/TracingSystem; } public final class com/trendyol/stove/tracing/TracingSystemOptions { public fun ()V public fun (Lcom/trendyol/stove/tracing/TracingOptions;)V public synthetic fun (Lcom/trendyol/stove/tracing/TracingOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/tracing/TracingOptions; public final fun copy (Lcom/trendyol/stove/tracing/TracingOptions;)Lcom/trendyol/stove/tracing/TracingSystemOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/tracing/TracingSystemOptions;Lcom/trendyol/stove/tracing/TracingOptions;ILjava/lang/Object;)Lcom/trendyol/stove/tracing/TracingSystemOptions; public fun equals (Ljava/lang/Object;)Z public final fun getTracingOptions ()Lcom/trendyol/stove/tracing/TracingOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/tracing/TracingValidationScope { public fun (Lcom/trendyol/stove/tracing/TraceContext;Lcom/trendyol/stove/tracing/TraceValidationDsl;Lcom/trendyol/stove/tracing/StoveTraceCollector;)V public final fun executionTimeShouldBeGreaterThan-LRDsOJo (J)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun executionTimeShouldBeLessThan-LRDsOJo (J)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun findSpan (Lkotlin/jvm/functions/Function1;)Larrow/core/Option; public final fun findSpanByName (Ljava/lang/String;)Larrow/core/Option; public final fun getAllTraceVisualizations ()Ljava/util/List; public final fun getCollector ()Lcom/trendyol/stove/tracing/StoveTraceCollector; public final fun getCtx ()Lcom/trendyol/stove/tracing/TraceContext; public final fun getFailedSpanCount ()I public final fun getFailedSpans ()Ljava/util/List; public final fun getRootSpanId ()Ljava/lang/String; public final fun getSpanCount ()I public final fun getTestId ()Ljava/lang/String; public final fun getTotalDuration-UwyO8pc ()J public final fun getTraceId ()Ljava/lang/String; public final fun getTraceVisualization ()Lcom/trendyol/stove/tracing/TraceVisualization; public final fun renderSummary ()Ljava/lang/String; public final fun renderTree ()Ljava/lang/String; public final fun shouldContainSpan (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldContainSpanMatching (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldHaveFailedSpan (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldHaveSpanWithAttribute (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldHaveSpanWithAttributeContaining (Ljava/lang/String;Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldNotContainSpan (Ljava/lang/String;)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun shouldNotHaveFailedSpans ()Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanCountShouldBe (I)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanCountShouldBeAtLeast (I)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanCountShouldBeAtMost (I)Lcom/trendyol/stove/tracing/TraceValidationDsl; public final fun spanTree ()Larrow/core/Option; public final fun toTraceparent ()Ljava/lang/String; public final fun waitForSpans (IJ)Ljava/util/List; public static synthetic fun waitForSpans$default (Lcom/trendyol/stove/tracing/TracingValidationScope;IJILjava/lang/Object;)Ljava/util/List; } ================================================ FILE: lib/stove-tracing/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) implementation(libs.kotlinx.core) implementation(libs.kotlinx.slf4j) // OTLP gRPC protocol support for receiving spans from OTel Java Agent implementation(libs.opentelemetry.proto) implementation(libs.io.grpc) implementation(libs.io.grpc.stub) implementation(libs.io.grpc.protobuf) implementation(libs.io.grpc.netty) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.logback.classic) } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/Constants.kt ================================================ package com.trendyol.stove.tracing /** * Constants used throughout the tracing module. * Centralizes magic numbers and configuration defaults. */ object TracingConstants { /** Default gRPC port for OTLP protocol */ const val DEFAULT_OTLP_GRPC_PORT = 4317 /** Environment variable name for OTLP port (set by Gradle configuration) */ const val STOVE_TRACING_PORT_ENV = "STOVE_TRACING_PORT" /** Default HTTP port for OTLP protocol */ const val DEFAULT_OTLP_HTTP_PORT = 4318 /** Nanoseconds to milliseconds conversion factor */ const val NANOS_TO_MILLIS = 1_000_000L /** Default polling interval when waiting for spans (milliseconds) */ const val DEFAULT_SPAN_POLL_INTERVAL_MS = 50L /** Default timeout when waiting for spans (milliseconds) */ const val DEFAULT_SPAN_WAIT_TIMEOUT_MS = 2000L /** Additional wait time for straggler spans after first span arrives (milliseconds) */ const val STRAGGLER_WAIT_TIME_MS = 200L /** Server shutdown grace period (seconds) */ const val SERVER_SHUTDOWN_TIMEOUT_SECONDS = 5L /** Default maximum spans per trace to prevent memory issues */ const val DEFAULT_MAX_SPANS_PER_TRACE = 1000 /** Maximum stack trace lines to display in trace trees */ const val MAX_STACK_TRACE_LINES = 3 /** OpenTelemetry status code for ERROR */ const val OTEL_STATUS_CODE_ERROR = 2 /** Service name attribute key in OpenTelemetry */ const val OTEL_SERVICE_NAME_ATTRIBUTE = "service.name" /** gRPC internal span patterns to filter out */ val GRPC_INTERNAL_SPAN_PATTERNS = listOf( "TraceService/Export", "opentelemetry.proto.collector" ) /** OpenTelemetry exception event name */ const val OTEL_EXCEPTION_EVENT_NAME = "exception" /** OpenTelemetry exception type attribute key */ const val OTEL_EXCEPTION_TYPE_ATTRIBUTE = "exception.type" /** OpenTelemetry exception message attribute key */ const val OTEL_EXCEPTION_MESSAGE_ATTRIBUTE = "exception.message" /** OpenTelemetry exception stacktrace attribute key */ const val OTEL_EXCEPTION_STACKTRACE_ATTRIBUTE = "exception.stacktrace" } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/OTLPSpanReceiver.kt ================================================ package com.trendyol.stove.tracing import arrow.core.Either import arrow.core.left import arrow.core.right import com.google.protobuf.ByteString import io.grpc.Server import io.grpc.ServerBuilder import io.grpc.Status import io.grpc.stub.StreamObserver import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse import io.opentelemetry.proto.collector.trace.v1.TraceServiceGrpc import io.opentelemetry.proto.trace.v1.ResourceSpans import io.opentelemetry.proto.trace.v1.ScopeSpans import org.slf4j.LoggerFactory import java.io.IOException import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock /** * OTLP-compatible span receiver that collects spans from the application under test via gRPC. * * This uses gRPC protocol which is the default for OpenTelemetry Java Agent and avoids * classloader isolation issues since the agent communicates via protocol rather than shared classes. * * Example app configuration (OpenTelemetry Java Agent): * ``` * -Dotel.traces.exporter=otlp * -Dotel.exporter.otlp.protocol=grpc * -Dotel.exporter.otlp.endpoint=http://localhost:4317 * -Dotel.service.name=my-service * ``` * * Inspired by TestSpanCollector from beholder-otel-extension. */ class OTLPSpanReceiver( private val collector: StoveTraceCollector, private val port: Int = TracingConstants.DEFAULT_OTLP_GRPC_PORT ) { private val logger = LoggerFactory.getLogger(OTLPSpanReceiver::class.java) private val lock = ReentrantLock() private var server: Server? = null @Volatile private var running = false val endpoint: String get() = "http://localhost:$port" fun start(): Either = lock.withLock { try { if (running) { return Unit.right() } server = ServerBuilder .forPort(port) .addService(TraceServiceImpl(collector, logger)) .build() .start() running = true logger.info("[StoveOtlp] Started OTLP gRPC collector on port {}", port) Unit.right() } catch (e: IOException) { OTLPReceiverError.StartupFailed(port, e).left() } } fun stop(): Unit = lock.withLock { if (!running || server == null) { return } try { server?.shutdown() val terminated = server?.awaitTermination( TracingConstants.SERVER_SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS ) ?: false if (!terminated) { server?.shutdownNow() } } catch (_: InterruptedException) { server?.shutdownNow() Thread.currentThread().interrupt() } running = false logger.info("[StoveOtlp] Stopped OTLP gRPC collector") } } /** * gRPC service implementation that receives OTLP trace data. */ private class TraceServiceImpl( private val collector: StoveTraceCollector, private val logger: org.slf4j.Logger ) : TraceServiceGrpc.TraceServiceImplBase() { override fun export( request: ExportTraceServiceRequest, responseObserver: StreamObserver ) { try { val spans = extractSpansFromRequest(request) spans.forEach { collector.record(it) } if (spans.isNotEmpty()) { logger.info( "[StoveOtlp] Received {} spans from service '{}'", spans.size, spans.firstOrNull()?.serviceName ?: "unknown" ) } logger.debug("[StoveOtlp] Processed {} spans total", spans.size) responseObserver.onNext(ExportTraceServiceResponse.getDefaultInstance()) responseObserver.onCompleted() } catch (e: IOException) { logger.error("[StoveOtlp] IO error processing spans", e) responseObserver.onError( Status.INTERNAL .withDescription("Failed to process spans: ${e.message}") .withCause(e) .asException() ) } catch (e: IllegalArgumentException) { logger.warn("[StoveOtlp] Invalid span data received", e) responseObserver.onError( Status.INVALID_ARGUMENT .withDescription("Invalid span data: ${e.message}") .withCause(e) .asException() ) } catch (e: IllegalStateException) { logger.warn("[StoveOtlp] Unexpected state during span processing", e) responseObserver.onError( Status.FAILED_PRECONDITION .withDescription("Unexpected state: ${e.message}") .withCause(e) .asException() ) } } private fun extractSpansFromRequest(request: ExportTraceServiceRequest): List = request.resourceSpansList .flatMap { resourceSpans -> extractSpansFromResource(resourceSpans) } .filterNot { isInternalGrpcSpan(it.operationName) } private fun extractSpansFromResource(resourceSpans: ResourceSpans): List { val serviceName = extractServiceName(resourceSpans) return resourceSpans.scopeSpansList .flatMap { scopeSpans -> extractSpansFromScope(scopeSpans, serviceName) } } private fun extractServiceName(resourceSpans: ResourceSpans): String = resourceSpans.resource ?.attributesList ?.find { it.key == TracingConstants.OTEL_SERVICE_NAME_ATTRIBUTE } ?.value ?.stringValue ?: "unknown" private fun extractSpansFromScope(scopeSpans: ScopeSpans, serviceName: String): List = scopeSpans.spansList.map { span -> SpanInfo( traceId = span.traceId.toHex(), spanId = span.spanId.toHex(), parentSpanId = if (span.parentSpanId.isEmpty) null else span.parentSpanId.toHex(), operationName = span.name, serviceName = serviceName, startTimeNanos = span.startTimeUnixNano, endTimeNanos = span.endTimeUnixNano, status = when (span.status.codeValue) { TracingConstants.OTEL_STATUS_CODE_ERROR -> SpanStatus.ERROR else -> SpanStatus.OK }, attributes = span.attributesList.associate { kv -> kv.key to extractAttributeValue(kv.value) }, exception = extractExceptionFromSpan(span) ) } private fun extractExceptionFromSpan( span: io.opentelemetry.proto.trace.v1.Span ): ExceptionInfo? { // First try to extract exception from events (preferred - contains full details) val exceptionEvent = span.eventsList.find { event -> event.name == TracingConstants.OTEL_EXCEPTION_EVENT_NAME } if (exceptionEvent != null) { val attributes = exceptionEvent.attributesList.associate { kv -> kv.key to extractAttributeValue(kv.value) } val exceptionType = attributes[TracingConstants.OTEL_EXCEPTION_TYPE_ATTRIBUTE] ?: "Exception" val exceptionMessage = attributes[TracingConstants.OTEL_EXCEPTION_MESSAGE_ATTRIBUTE] ?: "" val stackTrace = attributes[TracingConstants.OTEL_EXCEPTION_STACKTRACE_ATTRIBUTE] ?.split("\n") ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() return ExceptionInfo(exceptionType, exceptionMessage, stackTrace) } // Fallback to status message if error status but no exception event if (span.status.codeValue == TracingConstants.OTEL_STATUS_CODE_ERROR && span.status.message.isNotEmpty() ) { return ExceptionInfo("Error", span.status.message, emptyList()) } return null } private fun extractAttributeValue(value: io.opentelemetry.proto.common.v1.AnyValue): String = when { value.hasStringValue() -> value.stringValue value.hasIntValue() -> value.intValue.toString() value.hasBoolValue() -> value.boolValue.toString() value.hasDoubleValue() -> value.doubleValue.toString() value.hasArrayValue() -> value.arrayValue.valuesList.joinToString(prefix = "[", postfix = "]") { formatJsonValue(it) } value.hasKvlistValue() -> value.kvlistValue.valuesList.joinToString(prefix = "{", postfix = "}") { kv -> "\"${escapeJson(kv.key)}\":${formatJsonValue(kv.value)}" } value.hasBytesValue() -> value.bytesValue.toHex() else -> "" } private fun formatJsonValue(value: io.opentelemetry.proto.common.v1.AnyValue): String = if (value.hasStringValue()) { "\"${escapeJson(value.stringValue)}\"" } else { extractAttributeValue(value) } private fun escapeJson(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"") private fun isInternalGrpcSpan(spanName: String): Boolean = TracingConstants.GRPC_INTERNAL_SPAN_PATTERNS.any { pattern -> spanName.contains(pattern) } private fun ByteString.toHex(): String { if (isEmpty) return "" return toByteArray().joinToString("") { "%02x".format(it) } } } /** * Errors that can occur during OTLP receiver operations. */ sealed class OTLPReceiverError( message: String, cause: Throwable? = null ) : Exception(message, cause) { data class StartupFailed( val port: Int, override val cause: IOException ) : OTLPReceiverError("Failed to start OTLP gRPC collector on port $port", cause) } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/StoveTraceCollector.kt ================================================ package com.trendyol.stove.tracing import com.trendyol.stove.reporting.SpanEventListener import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList class StoveTraceCollector { private val logger = org.slf4j.LoggerFactory.getLogger(StoveTraceCollector::class.java) private val spans = ConcurrentHashMap>() private val traceToTest = ConcurrentHashMap() private val spanListeners = CopyOnWriteArrayList() /** Register a listener to receive span events */ fun addSpanListener(listener: SpanEventListener) { spanListeners.add(listener) } /** Remove a previously registered span listener */ fun removeSpanListener(listener: SpanEventListener) { spanListeners.remove(listener) } fun registerTrace(traceId: String, testId: String) { traceToTest[traceId] = testId spans.computeIfAbsent(traceId) { CopyOnWriteArrayList() } } fun record(span: SpanInfo) { spans.computeIfAbsent(span.traceId) { CopyOnWriteArrayList() }.add(span) spanListeners.forEach { runCatching { it.onSpanRecorded(span) }.onFailure { e -> logger.warn("Span listener failed on onSpanRecorded", e) } } } fun recordAll(spansToRecord: Collection) { spansToRecord.forEach { record(it) } } fun getTrace(traceId: String): List = spans[traceId]?.toList() ?: emptyList() fun getTraceTree(traceId: String): SpanNode? = SpanTree.build(getTrace(traceId)) fun getTracesForTest(testId: String): List = traceToTest.filterValues { it == testId }.keys.toList() fun getTestId(traceId: String): String? = traceToTest[traceId] fun getAllTraces(): Map> = spans.mapValues { it.value.toList() } fun getFailedSpans(traceId: String): List = getTrace(traceId).filter { it.isFailed } fun hasFailures(traceId: String): Boolean = getTrace(traceId).any { it.isFailed } fun clear(traceId: String) { spans.remove(traceId) traceToTest.remove(traceId) } fun clearForTest(testId: String) { val traceIds = getTracesForTest(testId) traceIds.forEach { clear(it) } } fun clearAll() { spans.clear() traceToTest.clear() } fun spanCount(traceId: String): Int = spans[traceId]?.size ?: 0 fun totalSpanCount(): Int = spans.values.sumOf { it.size } fun traceCount(): Int = spans.size /** * Waits for at least the expected number of spans to be collected. * Inspired by beholder-otel-extension's TestSpanCollector. * * @param traceId the trace ID to wait for * @param expectedCount minimum number of spans to wait for * @param timeoutMs maximum wait time in milliseconds * @return the collected spans for the trace */ fun waitForSpans( traceId: String, expectedCount: Int, timeoutMs: Long = TracingConstants.DEFAULT_SPAN_WAIT_TIMEOUT_MS ): List { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { val currentSpans = getTrace(traceId) if (currentSpans.size >= expectedCount) { return currentSpans } try { Thread.sleep(TracingConstants.DEFAULT_SPAN_POLL_INTERVAL_MS) } catch (_: InterruptedException) { Thread.currentThread().interrupt() break } } return getTrace(traceId) } } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/TraceReportBuilder.kt ================================================ package com.trendyol.stove.tracing import arrow.core.getOrElse import arrow.core.toOption import com.trendyol.stove.system.Stove import com.trendyol.stove.system.StoveOptions /** * Builds trace-enriched failure reports for test extensions. * * This centralizes the common logic for building reports that include * both Stove's execution report and the execution trace tree. */ @Suppress("TooManyFunctions") object TraceReportBuilder { private const val SPAN_WAIT_TIME_MS = 500L private const val NO_SPANS_MESSAGE = "No spans in trace" // ANSI color codes for the header private object Colors { const val RESET = "\u001B[0m" const val BOLD = "\u001B[1m" const val CYAN = "\u001B[36m" const val BRIGHT_CYAN = "\u001B[96m" } const val DEFAULT_ERROR_MESSAGE = "Test failed" /** * Builds the full report including Stove execution report and trace tree. */ fun buildFullReport(): String { val options = Stove.options() val report = Stove.reporter().dumpIfFailed(options.failureRenderer) val traceTree = getColoredTraceTreeIfEnabled() return buildReport(report, traceTree) } /** * Checks if failure enrichment should be performed based on options. */ fun StoveOptions.shouldEnrichFailures(): Boolean = dumpReportOnTestFailure && reportingEnabled private fun getColoredTraceTreeIfEnabled(): String = TraceContext .current() .toOption() .flatMap { Stove.getSystemOrNone() } .flatMap { it.getTraceVisualizationForCurrentTest(SPAN_WAIT_TIME_MS) } .map { visualization -> // Use the colored tree for terminal display visualization.coloredTree.let { tree -> if (tree.isNotEmpty() && tree != NO_SPANS_MESSAGE) tree else "" } }.getOrElse { "" } private fun buildReport(stoveReport: String, traceTree: String): String = buildString { if (stoveReport.isNotEmpty()) { append(stoveReport) } if (traceTree.isNotEmpty() && traceTree != NO_SPANS_MESSAGE) { if (isNotEmpty()) appendLine().appendLine() appendLine(buildColoredHeader()) append(traceTree) } } private fun buildColoredHeader(): String = buildString { val headerLine = "${Colors.CYAN}═══════════════════════════════════════════════════════════════${Colors.RESET}" val title = "${Colors.BOLD}${Colors.BRIGHT_CYAN}EXECUTION TRACE${Colors.RESET} (Call Chain)" appendLine(headerLine) appendLine(title) appendLine(headerLine) } } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/TraceValidation.kt ================================================ package com.trendyol.stove.tracing import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @DslMarker annotation class TracingDsl @TracingDsl class TraceValidationDsl( private val collector: StoveTraceCollector, private val traceId: String ) { private val trace: List by lazy { collector.getTrace(traceId) } private val tree: SpanNode? by lazy { collector.getTraceTree(traceId) } fun shouldContainSpan(operationName: String): TraceValidationDsl { require(trace.any { it.operationName.contains(operationName) }) { "Expected span containing '$operationName' but found: ${trace.map { it.operationName }}" } return this } fun shouldContainSpanMatching(predicate: (SpanInfo) -> Boolean): TraceValidationDsl { require(trace.any(predicate)) { "Expected span matching predicate but none found in: ${trace.map { it.operationName }}" } return this } fun shouldNotContainSpan(operationName: String): TraceValidationDsl { val matchingSpans = trace.filter { it.operationName.contains(operationName) } require(matchingSpans.isEmpty()) { "Expected no span containing '$operationName' but found: ${matchingSpans.map { it.operationName }}" } return this } fun shouldNotHaveFailedSpans(): TraceValidationDsl { val failed = trace.filter { it.isFailed } require(failed.isEmpty()) { val failureMessages = failed.map { span -> "${span.operationName}: ${span.exception?.message ?: "unknown error"}" } "Expected no failed spans but found: $failureMessages" } return this } fun shouldHaveFailedSpan(operationName: String): TraceValidationDsl { val failedSpans = trace.filter { it.isFailed } val failedMatching = failedSpans.filter { it.operationName.contains(operationName) } require(failedMatching.isNotEmpty()) { "Expected failed span containing '$operationName' but found failed spans: ${failedSpans.map { it.operationName }}" } return this } fun executionTimeShouldBeLessThan(duration: Duration): TraceValidationDsl { val totalDuration = calculateTotalDuration() require(totalDuration <= duration) { "Expected execution time <= $duration but was $totalDuration" } return this } fun executionTimeShouldBeGreaterThan(duration: Duration): TraceValidationDsl { val totalDuration = calculateTotalDuration() require(totalDuration >= duration) { "Expected execution time >= $duration but was $totalDuration" } return this } fun spanCountShouldBe(expected: Int): TraceValidationDsl { require(trace.size == expected) { "Expected $expected spans but found ${trace.size}" } return this } fun spanCountShouldBeAtLeast(minimum: Int): TraceValidationDsl { require(trace.size >= minimum) { "Expected at least $minimum spans but found ${trace.size}" } return this } fun spanCountShouldBeAtMost(maximum: Int): TraceValidationDsl { require(trace.size <= maximum) { "Expected at most $maximum spans but found ${trace.size}" } return this } fun shouldHaveSpanWithAttribute(key: String, value: String): TraceValidationDsl { require(trace.any { it.attributes[key] == value }) { "Expected span with attribute '$key'='$value' but none found" } return this } fun shouldHaveSpanWithAttributeContaining(key: String, substring: String): TraceValidationDsl { require(trace.any { it.attributes[key]?.contains(substring) == true }) { "Expected span with attribute '$key' containing '$substring' but none found" } return this } fun getSpanCount(): Int = trace.size fun getFailedSpans(): List = trace.filter { it.isFailed } fun getFailedSpanCount(): Int = getFailedSpans().size fun findSpan(predicate: (SpanInfo) -> Boolean): SpanInfo? = trace.find(predicate) fun findSpanByName(operationName: String): SpanInfo? = trace.find { it.operationName.contains(operationName) } fun spanTree(): SpanNode? = tree fun getTotalDuration(): Duration = calculateTotalDuration() private fun calculateTotalDuration(): Duration { if (trace.isEmpty()) return 0.milliseconds val minStart = trace.minOf { it.startTimeNanos } val maxEnd = trace.maxOf { it.endTimeNanos } val durationMs = (maxEnd - minStart) / TracingConstants.NANOS_TO_MILLIS return durationMs.milliseconds } fun renderTree(): String { val root = tree ?: return "No spans in trace" return TraceTreeRenderer.render(root) } fun renderSummary(): String { val root = tree ?: return "No spans in trace" return TraceTreeRenderer.renderSummary(root) } } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/TracingOptions.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.tracing import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** * Configuration options for the Stove tracing system. * * The tracing system works by receiving OTLP spans from the application under test. * Configure your application to use the OpenTelemetry Java Agent and export spans * to the Stove OTLP receiver endpoint. */ class TracingOptions { var enabled: Boolean = false private set var spanCollectionTimeout: Duration = 5.seconds private set var spanFilter: (SpanInfo) -> Boolean = { true } private set var maxSpansPerTrace: Int = TracingConstants.DEFAULT_MAX_SPANS_PER_TRACE private set var spanReceiverEnabled: Boolean = false private set var spanReceiverPort: Int = TracingConstants.DEFAULT_OTLP_GRPC_PORT private set fun enabled(): TracingOptions = apply { enabled = true } fun disabled(): TracingOptions = apply { enabled = false } fun spanCollectionTimeout(timeout: Duration): TracingOptions = apply { spanCollectionTimeout = timeout } fun spanFilter(filter: (SpanInfo) -> Boolean): TracingOptions = apply { spanFilter = filter } fun maxSpansPerTrace(max: Int): TracingOptions = apply { maxSpansPerTrace = max } /** * Enable the OTLP span receiver to collect spans from the application under test. * * The port is determined in the following order: * 1. Explicitly provided port parameter * 2. STOVE_TRACING_PORT environment variable (set by Gradle configuration) * 3. Default port 4317 * * The application should be configured to export spans via the OpenTelemetry Java Agent: * ``` * -javaagent:path/to/opentelemetry-javaagent.jar * -Dotel.exporter.otlp.endpoint=http://localhost:{port} * -Dotel.exporter.otlp.protocol=grpc * -Dotel.service.name=my-service * ``` * * @param port The port for the OTLP gRPC receiver. If not specified, reads from * STOVE_TRACING_PORT env var or defaults to 4317. */ fun enableSpanReceiver(port: Int? = null): TracingOptions = apply { spanReceiverEnabled = true spanReceiverPort = port ?: portFromEnv() ?: TracingConstants.DEFAULT_OTLP_GRPC_PORT } private fun portFromEnv(): Int? = System.getenv(TracingConstants.STOVE_TRACING_PORT_ENV)?.toIntOrNull() fun copy(): TracingOptions = TracingOptions().also { copy -> copy.enabled = this.enabled copy.spanCollectionTimeout = this.spanCollectionTimeout copy.spanFilter = this.spanFilter copy.maxSpansPerTrace = this.maxSpansPerTrace copy.spanReceiverEnabled = this.spanReceiverEnabled copy.spanReceiverPort = this.spanReceiverPort } } ================================================ FILE: lib/stove-tracing/src/main/kotlin/com/trendyol/stove/tracing/TracingSystem.kt ================================================ @file:Suppress("unused", "TooManyFunctions") package com.trendyol.stove.tracing import arrow.core.* import com.trendyol.stove.reporting.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl @StoveDsl class TracingSystem( override val stove: Stove, private val options: TracingSystemOptions ) : PluggedSystem, RunAware, TraceProvider, SpanListenerRegistry { private val logger = org.slf4j.LoggerFactory.getLogger(TracingSystem::class.java) internal val collector: StoveTraceCollector = StoveTraceCollector() private var spanReceiver: OTLPSpanReceiver? = null override suspend fun run() { if (options.tracingOptions.spanReceiverEnabled) { startSpanReceiver() } } override suspend fun stop() { stopSpanReceiver() clearAllTraces() } override fun getTraceVisualizationForCurrentTest(waitTimeMs: Long): Option { val ctx = TraceContext.current() ?: return None val spans = pollForSpans(ctx.traceId, waitTimeMs) return createVisualizationOrFallback(ctx.traceId, ctx.testId, spans) } suspend fun ensureTraceStarted(): TraceContext = TraceContext.current() ?: startNewTrace() fun endTrace() { TraceContext.clear() } fun currentContext(): Option = TraceContext.current().toOption() fun validation(): Option = currentContext().map { createValidation(it.traceId) } fun validation(traceId: String): TraceValidationDsl = createValidation(traceId) override fun addSpanListener(listener: SpanEventListener) { collector.addSpanListener(listener) } override fun then(): Stove = stove override fun close() { clearAllTraces() } // Private helper methods private fun startSpanReceiver() { spanReceiver = OTLPSpanReceiver(collector, options.tracingOptions.spanReceiverPort) spanReceiver?.start()?.fold( ifLeft = { error -> // Port binding failure is non-fatal - tests continue without span collection // This commonly happens when multiple test modules run in parallel on CI logger.warn( "[StoveTracing] Failed to start OTLP receiver on port {}: {}. " + "Tracing spans will not be collected. This is usually caused by another " + "test module already using this port.", options.tracingOptions.spanReceiverPort, error.message ) spanReceiver = null }, ifRight = { /* Started successfully */ } ) } private fun stopSpanReceiver() { spanReceiver?.stop() } private fun clearAllTraces() { collector.clearAll() TraceContext.clear() } /** * Polls for spans with intelligent waiting strategy. * Returns as soon as first spans arrive, then waits briefly for stragglers. */ private fun pollForSpans(traceId: String, maxWaitTimeMs: Long): List { val deadline = System.currentTimeMillis() + maxWaitTimeMs // Poll until we find spans or timeout val initialSpans = pollUntilSpansArrive(traceId, deadline) // If we found spans, wait a bit more for stragglers return if (initialSpans.isNotEmpty()) { waitForStragglersAndCollect(traceId, deadline) } else { emptyList() } } /** * Polls repeatedly until spans arrive or deadline is reached. */ private fun pollUntilSpansArrive(traceId: String, deadline: Long): List { while (System.currentTimeMillis() < deadline) { val spans = collector.getTrace(traceId) if (spans.isNotEmpty()) return spans if (!sleepQuietly(TracingConstants.DEFAULT_SPAN_POLL_INTERVAL_MS)) { break // Interrupted } } return emptyList() } /** * Waits briefly for straggler spans, then collects final result. */ private fun waitForStragglersAndCollect(traceId: String, deadline: Long): List { val remainingTime = deadline - System.currentTimeMillis() val stragglerWait = minOf(TracingConstants.STRAGGLER_WAIT_TIME_MS, remainingTime).coerceAtLeast(0) sleepQuietly(stragglerWait) return collector.getTrace(traceId) } /** * Sleeps quietly, handling interruption gracefully. * Returns true if sleep completed, false if interrupted. */ private fun sleepQuietly(millis: Long): Boolean { if (millis <= 0) return true return try { Thread.sleep(millis) true } catch (_: InterruptedException) { Thread.currentThread().interrupt() false } } /** * Creates visualization from spans, or falls back to most relevant trace if empty. */ private fun createVisualizationOrFallback( traceId: String, testId: String, spans: List ): Option = when { spans.isNotEmpty() -> { TraceVisualization.from(traceId, testId, spans).some() } else -> { val traceByTestId = findTraceByTestIdAttribute(testId) when (traceByTestId) { is Some -> traceByTestId None -> findMostRelevantTrace(testId) } } } /** * Finds the most recent trace that explicitly carries the current test ID as a span attribute. * * This is a defense-in-depth fallback when the expected trace ID has no spans * (e.g., `traceparent` was rewritten by external instrumentation). */ private fun findTraceByTestIdAttribute(testId: String): Option { val matchingTrace = collector .getAllTraces() .asSequence() .filter { (_, spans) -> spans.isNotEmpty() } .filter { (_, spans) -> spans.any { span -> spanContainsTestId(span, testId) } } .maxByOrNull { (_, spans) -> spans.maxOf { it.startTimeNanos } } ?: return None val (traceId, traceSpans) = matchingTrace return TraceVisualization.from(traceId, testId, traceSpans).some() } private fun spanContainsTestId(span: SpanInfo, testId: String): Boolean = span.attributes.any { (key, value) -> isTestIdAttributeKey(key) && isTestIdAttributeValue(value, testId) } private fun isTestIdAttributeKey(key: String): Boolean { val normalized = key.lowercase() return normalized == TraceContext.STOVE_TEST_ID_HEADER.lowercase() || normalized.contains("x-stove-test-id") || normalized.contains("stove_test_id") || normalized.contains("stove.test.id") } private fun isTestIdAttributeValue(rawValue: String, testId: String): Boolean { if (rawValue == testId) return true return tokenizeAttributeValue(rawValue).any { token -> token == testId } } private fun tokenizeAttributeValue(rawValue: String): List { val normalized = rawValue .removePrefix("[") .removeSuffix("]") .removePrefix("{") .removeSuffix("}") return normalized .split(",", ";") .flatMap { token -> val separatorIndex = token.indexOfAny(charArrayOf('=', ':')) if (separatorIndex >= 0) { listOf( token.substring(0, separatorIndex), token.substring(separatorIndex + 1) ) } else { listOf(token) } }.map { token -> token.trim().trim('"', '\'', '{', '}', '[', ']') }.filter { token -> token.isNotEmpty() } } /** * Finds the most relevant trace when the expected trace ID has no spans. * Uses the most recent trace (by start time) as a heuristic, as it's likely * to be the trace closest to the test execution. */ private fun findMostRelevantTrace(testId: String): Option { val allTraces = collector.getAllTraces() if (allTraces.isEmpty()) return None // Find the trace with the most recent span start time val (traceId, traceSpans) = allTraces .filter { it.value.isNotEmpty() } .maxByOrNull { entry -> entry.value.maxOf { it.startTimeNanos } } ?: return None return TraceVisualization.from(traceId, testId, traceSpans).some() } private suspend fun startNewTrace(): TraceContext { val testId = resolveTestId() val ctx = TraceContext.start(testId) collector.registerTrace(ctx.traceId, testId) return ctx } private suspend fun resolveTestId(): String = resolveKotestTestId() ?: resolveJUnitTestId() ?: generateFallbackTestId() private suspend fun resolveKotestTestId(): String? = currentStoveTestContext()?.testId private fun resolveJUnitTestId(): String? = StoveTestContextHolder.get()?.testId private fun generateFallbackTestId(): String = "stove-trace-${System.currentTimeMillis()}" private fun createValidation(traceId: String): TraceValidationDsl = TraceValidationDsl(collector, traceId) } data class TracingSystemOptions( val tracingOptions: TracingOptions = TracingOptions().enabled() ) internal fun Stove.withTracing(options: TracingSystemOptions): Stove { this.getOrRegister(TracingSystem(this, options)) return this } internal fun Stove.tracingSystem(): TracingSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(TracingSystem::class) } fun WithDsl.tracing(configure: @StoveDsl TracingOptions.() -> Unit = {}): Stove = this.stove.withTracing(createTracingOptions(configure)) private fun createTracingOptions(configure: TracingOptions.() -> Unit): TracingSystemOptions { val options = TracingOptions() options.configure() options.enabled() return TracingSystemOptions(options) } /** * DSL scope for tracing validation - exposes trace context and all validation methods directly. */ @StoveDsl class TracingValidationScope( val ctx: TraceContext, private val validation: TraceValidationDsl, val collector: StoveTraceCollector ) { val traceId: String get() = ctx.traceId val rootSpanId: String get() = ctx.rootSpanId val testId: String get() = ctx.testId fun toTraceparent(): String = ctx.toTraceparent() // Validation method delegates fun shouldContainSpan(operationName: String) = validation.shouldContainSpan(operationName) fun shouldContainSpanMatching(predicate: (SpanInfo) -> Boolean) = validation.shouldContainSpanMatching(predicate) fun shouldNotContainSpan(operationName: String) = validation.shouldNotContainSpan(operationName) fun shouldNotHaveFailedSpans() = validation.shouldNotHaveFailedSpans() fun shouldHaveFailedSpan(operationName: String) = validation.shouldHaveFailedSpan(operationName) fun executionTimeShouldBeLessThan(duration: kotlin.time.Duration) = validation.executionTimeShouldBeLessThan(duration) fun executionTimeShouldBeGreaterThan(duration: kotlin.time.Duration) = validation.executionTimeShouldBeGreaterThan(duration) fun spanCountShouldBe(expected: Int) = validation.spanCountShouldBe(expected) fun spanCountShouldBeAtLeast(minimum: Int) = validation.spanCountShouldBeAtLeast(minimum) fun spanCountShouldBeAtMost(maximum: Int) = validation.spanCountShouldBeAtMost(maximum) fun shouldHaveSpanWithAttribute(key: String, value: String) = validation.shouldHaveSpanWithAttribute(key, value) fun shouldHaveSpanWithAttributeContaining(key: String, substring: String) = validation.shouldHaveSpanWithAttributeContaining(key, substring) // Query methods fun getSpanCount(): Int = validation.getSpanCount() fun getFailedSpans(): List = validation.getFailedSpans() fun getFailedSpanCount(): Int = validation.getFailedSpanCount() fun findSpan(predicate: (SpanInfo) -> Boolean): Option = validation.findSpan(predicate).toOption() fun findSpanByName(operationName: String): Option = validation.findSpanByName(operationName).toOption() fun spanTree(): Option = validation.spanTree().toOption() fun getTotalDuration(): kotlin.time.Duration = validation.getTotalDuration() fun renderTree(): String = validation.renderTree() fun renderSummary(): String = validation.renderSummary() /** * Waits for at least the expected number of spans to be collected. * Useful for ensuring spans have been exported before making assertions. */ fun waitForSpans(expectedCount: Int, timeoutMs: Long = 2000): List = collector.waitForSpans(traceId, expectedCount, timeoutMs) /** * Gets a visualization of the trace for this test. * Includes all spans for this trace ID formatted as a tree. */ fun getTraceVisualization(): TraceVisualization { val spans = collector.getTrace(traceId) return TraceVisualization.from(traceId, testId, spans) } /** * Gets visualizations for ALL collected traces (not just this test's trace ID). * Useful for seeing all activity during the test execution. */ fun getAllTraceVisualizations(): List { val allTraces = collector.getAllTraces() return allTraces.map { (traceId, spans) -> TraceVisualization.from(traceId, testId, spans) } } } suspend fun ValidationDsl.tracing( validation: @StoveDsl suspend TracingValidationScope.() -> Unit ) { val system = this.stove.tracingSystem() val ctx = system.ensureTraceStarted() val scope = createTracingValidationScope(system, ctx) TraceContext.withPropagation(ctx) { validation(scope) } } private fun createTracingValidationScope( system: TracingSystem, ctx: TraceContext ): TracingValidationScope { val traceValidation = system.validation(ctx.traceId) return TracingValidationScope(ctx, traceValidation, system.collector) } fun ValidationDsl.tracingSystem(): TracingSystem = this.stove.tracingSystem() ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/OtlpSpanReceiverTest.kt ================================================ package com.trendyol.stove.tracing import arrow.core.getOrElse import io.grpc.ManagedChannelBuilder import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest import io.opentelemetry.proto.collector.trace.v1.TraceServiceGrpc import io.opentelemetry.proto.common.v1.AnyValue import io.opentelemetry.proto.common.v1.ArrayValue import io.opentelemetry.proto.common.v1.KeyValue import io.opentelemetry.proto.resource.v1.Resource import io.opentelemetry.proto.trace.v1.ResourceSpans import io.opentelemetry.proto.trace.v1.ScopeSpans import io.opentelemetry.proto.trace.v1.Span import io.opentelemetry.proto.trace.v1.Status import java.util.concurrent.TimeUnit private const val TEST_STACKTRACE = """java.lang.RuntimeException: Something went wrong at com.example.Service.process(Service.kt:42) at com.example.Controller.handle(Controller.kt:15) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)""" class OtlpSpanReceiverTest : FunSpec({ val testPort = 14317 // Use a non-standard port to avoid conflicts test("start should succeed on available port") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { val result = receiver.start() result.isRight() shouldBe true receiver.endpoint shouldBe "http://localhost:$testPort" } finally { receiver.stop() } } test("start should be idempotent") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val result = receiver.start() // Second start should also succeed result.isRight() shouldBe true } finally { receiver.stop() } } test("stop should be safe to call multiple times") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) receiver.start() receiver.stop() receiver.stop() // Should not throw } test("export should record spans to collector") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() // Create a gRPC client val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) // Create a test span val request = createExportRequest( serviceName = "test-service", traceId = "0123456789abcdef0123456789abcdef", spanId = "0123456789abcdef", spanName = "test-operation" ) stub.export(request) // Give some time for the span to be recorded Thread.sleep(100) val trace = collector.getTrace("0123456789abcdef0123456789abcdef") trace shouldHaveSize 1 trace[0].operationName shouldBe "test-operation" trace[0].serviceName shouldBe "test-service" } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should filter out internal gRPC spans") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) // Create spans including internal gRPC span that should be filtered val request = createExportRequest( serviceName = "test-service", traceId = "abcdef0123456789abcdef0123456789", spanId = "fedcba9876543210", spanName = "TraceService/Export" // This should be filtered out ) stub.export(request) Thread.sleep(100) // The internal span should be filtered out val trace = collector.getTrace("abcdef0123456789abcdef0123456789") trace shouldHaveSize 0 } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should extract service name from resource attributes") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) val request = createExportRequest( serviceName = "my-custom-service", traceId = "11111111111111111111111111111111", spanId = "2222222222222222", spanName = "custom-operation" ) stub.export(request) Thread.sleep(100) val trace = collector.getTrace("11111111111111111111111111111111") trace shouldHaveSize 1 trace[0].serviceName shouldBe "my-custom-service" } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should handle multiple spans in single request") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) val traceId = "33333333333333333333333333333333" val request = createExportRequestWithMultipleSpans( serviceName = "test-service", traceId = traceId, spanNames = listOf("span1", "span2", "span3") ) stub.export(request) Thread.sleep(100) val trace = collector.getTrace(traceId) trace shouldHaveSize 3 trace.map { it.operationName } shouldBe listOf("span1", "span2", "span3") } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should handle span with error status") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) val traceId = "44444444444444444444444444444444" val request = createExportRequestWithErrorSpan( serviceName = "test-service", traceId = traceId, spanId = "5555555555555555", spanName = "failed-operation", errorMessage = "Something went wrong" ) stub.export(request) Thread.sleep(100) val trace = collector.getTrace(traceId) trace shouldHaveSize 1 trace[0].status shouldBe SpanStatus.ERROR trace[0].exception?.message shouldBe "Something went wrong" } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should extract exception from span events") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) val traceId = "88888888888888888888888888888888" val request = createExportRequestWithExceptionEvent( serviceName = "test-service", traceId = traceId, spanId = "9999999999999999", spanName = "failed-operation", exceptionType = "java.lang.RuntimeException", exceptionMessage = "Something went wrong", exceptionStacktrace = TEST_STACKTRACE ) stub.export(request) Thread.sleep(100) val trace = collector.getTrace(traceId) trace shouldHaveSize 1 trace[0].status shouldBe SpanStatus.ERROR trace[0].exception?.type shouldBe "java.lang.RuntimeException" trace[0].exception?.message shouldBe "Something went wrong" trace[0].exception?.stackTrace?.size shouldBe 4 trace[0].exception?.stackTrace?.get(1) shouldBe "at com.example.Service.process(Service.kt:42)" } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should extract span attributes") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) val traceId = "66666666666666666666666666666666" val request = createExportRequestWithAttributes( serviceName = "test-service", traceId = traceId, spanId = "7777777777777777", spanName = "db-operation", attributes = mapOf( "db.system" to "postgresql", "db.name" to "test_db" ) ) stub.export(request) Thread.sleep(100) val trace = collector.getTrace(traceId) trace shouldHaveSize 1 trace[0].attributes["db.system"] shouldBe "postgresql" trace[0].attributes["db.name"] shouldBe "test_db" } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("export should serialize array attributes as JSON-like values") { val collector = StoveTraceCollector() val receiver = OTLPSpanReceiver(collector, testPort) try { receiver.start() val channel = ManagedChannelBuilder .forAddress("localhost", testPort) .usePlaintext() .build() try { val stub = TraceServiceGrpc.newBlockingStub(channel) val traceId = "99999999999999999999999999999999" val request = createExportRequestWithArrayAttribute( serviceName = "test-service", traceId = traceId, spanId = "aaaaaaaaaaaaaaaa", spanName = "http-call", attributeKey = "http.request.header.x_stove_test_id", values = listOf("test-id-1", "test-id-2") ) stub.export(request) Thread.sleep(100) val trace = collector.getTrace(traceId) trace shouldHaveSize 1 trace[0].attributes["http.request.header.x_stove_test_id"] shouldBe "[\"test-id-1\", \"test-id-2\"]" } finally { channel.shutdown() channel.awaitTermination(5, TimeUnit.SECONDS) } } finally { receiver.stop() } } test("start should fail on port already in use") { val collector = StoveTraceCollector() val receiver1 = OTLPSpanReceiver(collector, testPort) val receiver2 = OTLPSpanReceiver(collector, testPort) try { receiver1.start() val result = receiver2.start() result.isLeft() shouldBe true result.getOrElse { it }.shouldBeInstanceOf() } finally { receiver1.stop() receiver2.stop() } } }) private fun hexStringToByteString(hex: String): com.google.protobuf.ByteString { val bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() return com.google.protobuf.ByteString .copyFrom(bytes) } private fun createExportRequest( serviceName: String, traceId: String, spanId: String, spanName: String ): ExportTraceServiceRequest { val resource = Resource .newBuilder() .addAttributes( KeyValue .newBuilder() .setKey("service.name") .setValue(AnyValue.newBuilder().setStringValue(serviceName)) ).build() val span = Span .newBuilder() .setTraceId(hexStringToByteString(traceId)) .setSpanId(hexStringToByteString(spanId)) .setName(spanName) .setStartTimeUnixNano(System.nanoTime()) .setEndTimeUnixNano(System.nanoTime() + 1_000_000) .build() val scopeSpans = ScopeSpans .newBuilder() .addSpans(span) .build() val resourceSpans = ResourceSpans .newBuilder() .setResource(resource) .addScopeSpans(scopeSpans) .build() return ExportTraceServiceRequest .newBuilder() .addResourceSpans(resourceSpans) .build() } private fun createExportRequestWithMultipleSpans( serviceName: String, traceId: String, spanNames: List ): ExportTraceServiceRequest { val resource = Resource .newBuilder() .addAttributes( KeyValue .newBuilder() .setKey("service.name") .setValue(AnyValue.newBuilder().setStringValue(serviceName)) ).build() val scopeSpansBuilder = ScopeSpans.newBuilder() spanNames.forEachIndexed { index, name -> val span = Span .newBuilder() .setTraceId(hexStringToByteString(traceId)) .setSpanId(hexStringToByteString("${index + 1}".padStart(16, '0'))) .setName(name) .setStartTimeUnixNano(System.nanoTime()) .setEndTimeUnixNano(System.nanoTime() + 1_000_000) .build() scopeSpansBuilder.addSpans(span) } val resourceSpans = ResourceSpans .newBuilder() .setResource(resource) .addScopeSpans(scopeSpansBuilder.build()) .build() return ExportTraceServiceRequest .newBuilder() .addResourceSpans(resourceSpans) .build() } private fun createExportRequestWithErrorSpan( serviceName: String, traceId: String, spanId: String, spanName: String, errorMessage: String ): ExportTraceServiceRequest { val resource = Resource .newBuilder() .addAttributes( KeyValue .newBuilder() .setKey("service.name") .setValue(AnyValue.newBuilder().setStringValue(serviceName)) ).build() val span = Span .newBuilder() .setTraceId(hexStringToByteString(traceId)) .setSpanId(hexStringToByteString(spanId)) .setName(spanName) .setStartTimeUnixNano(System.nanoTime()) .setEndTimeUnixNano(System.nanoTime() + 1_000_000) .setStatus( Status .newBuilder() .setCodeValue(TracingConstants.OTEL_STATUS_CODE_ERROR) .setMessage(errorMessage) ).build() val scopeSpans = ScopeSpans .newBuilder() .addSpans(span) .build() val resourceSpans = ResourceSpans .newBuilder() .setResource(resource) .addScopeSpans(scopeSpans) .build() return ExportTraceServiceRequest .newBuilder() .addResourceSpans(resourceSpans) .build() } private fun createExportRequestWithAttributes( serviceName: String, traceId: String, spanId: String, spanName: String, attributes: Map ): ExportTraceServiceRequest { val resource = Resource .newBuilder() .addAttributes( KeyValue .newBuilder() .setKey("service.name") .setValue(AnyValue.newBuilder().setStringValue(serviceName)) ).build() val spanBuilder = Span .newBuilder() .setTraceId(hexStringToByteString(traceId)) .setSpanId(hexStringToByteString(spanId)) .setName(spanName) .setStartTimeUnixNano(System.nanoTime()) .setEndTimeUnixNano(System.nanoTime() + 1_000_000) attributes.forEach { (key, value) -> spanBuilder.addAttributes( KeyValue .newBuilder() .setKey(key) .setValue(AnyValue.newBuilder().setStringValue(value)) ) } val scopeSpans = ScopeSpans .newBuilder() .addSpans(spanBuilder.build()) .build() val resourceSpans = ResourceSpans .newBuilder() .setResource(resource) .addScopeSpans(scopeSpans) .build() return ExportTraceServiceRequest .newBuilder() .addResourceSpans(resourceSpans) .build() } private fun createExportRequestWithExceptionEvent( serviceName: String, traceId: String, spanId: String, spanName: String, exceptionType: String, exceptionMessage: String, exceptionStacktrace: String ): ExportTraceServiceRequest { val resource = Resource .newBuilder() .addAttributes( KeyValue .newBuilder() .setKey("service.name") .setValue(AnyValue.newBuilder().setStringValue(serviceName)) ).build() val exceptionEvent = Span.Event .newBuilder() .setName(TracingConstants.OTEL_EXCEPTION_EVENT_NAME) .setTimeUnixNano(System.nanoTime()) .addAttributes( KeyValue .newBuilder() .setKey(TracingConstants.OTEL_EXCEPTION_TYPE_ATTRIBUTE) .setValue(AnyValue.newBuilder().setStringValue(exceptionType)) ).addAttributes( KeyValue .newBuilder() .setKey(TracingConstants.OTEL_EXCEPTION_MESSAGE_ATTRIBUTE) .setValue(AnyValue.newBuilder().setStringValue(exceptionMessage)) ).addAttributes( KeyValue .newBuilder() .setKey(TracingConstants.OTEL_EXCEPTION_STACKTRACE_ATTRIBUTE) .setValue(AnyValue.newBuilder().setStringValue(exceptionStacktrace)) ).build() val span = Span .newBuilder() .setTraceId(hexStringToByteString(traceId)) .setSpanId(hexStringToByteString(spanId)) .setName(spanName) .setStartTimeUnixNano(System.nanoTime()) .setEndTimeUnixNano(System.nanoTime() + 1_000_000) .setStatus( Status .newBuilder() .setCodeValue(TracingConstants.OTEL_STATUS_CODE_ERROR) .setMessage(exceptionMessage) ).addEvents(exceptionEvent) .build() val scopeSpans = ScopeSpans .newBuilder() .addSpans(span) .build() val resourceSpans = ResourceSpans .newBuilder() .setResource(resource) .addScopeSpans(scopeSpans) .build() return ExportTraceServiceRequest .newBuilder() .addResourceSpans(resourceSpans) .build() } private fun createExportRequestWithArrayAttribute( serviceName: String, traceId: String, spanId: String, spanName: String, attributeKey: String, values: List ): ExportTraceServiceRequest { val resource = Resource .newBuilder() .addAttributes( KeyValue .newBuilder() .setKey("service.name") .setValue(AnyValue.newBuilder().setStringValue(serviceName)) ).build() val arrayValue = ArrayValue .newBuilder() .addAllValues(values.map { AnyValue.newBuilder().setStringValue(it).build() }) .build() val span = Span .newBuilder() .setTraceId(hexStringToByteString(traceId)) .setSpanId(hexStringToByteString(spanId)) .setName(spanName) .setStartTimeUnixNano(System.nanoTime()) .setEndTimeUnixNano(System.nanoTime() + 1_000_000) .addAttributes( KeyValue .newBuilder() .setKey(attributeKey) .setValue( AnyValue .newBuilder() .setArrayValue(arrayValue) .build() ) ).build() val scopeSpans = ScopeSpans .newBuilder() .addSpans(span) .build() val resourceSpans = ResourceSpans .newBuilder() .setResource(resource) .addScopeSpans(scopeSpans) .build() return ExportTraceServiceRequest .newBuilder() .addResourceSpans(resourceSpans) .build() } ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/SpanEventListenerTest.kt ================================================ package com.trendyol.stove.tracing import com.trendyol.stove.reporting.SpanEventListener import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe class SpanEventListenerTest : FunSpec({ test("listener receives span events on record") { val collector = StoveTraceCollector() val received = mutableListOf() val listener = object : SpanEventListener { override fun onSpanRecorded(span: SpanInfo) { received.add(span) } } collector.addSpanListener(listener) val span = createSpan(traceId = "trace-1", spanId = "span-1") collector.record(span) received shouldHaveSize 1 received[0].spanId shouldBe "span-1" } test("listener receives events from recordAll") { val collector = StoveTraceCollector() val received = mutableListOf() val listener = object : SpanEventListener { override fun onSpanRecorded(span: SpanInfo) { received.add(span.spanId) } } collector.addSpanListener(listener) collector.recordAll( listOf( createSpan(traceId = "trace-1", spanId = "span-1"), createSpan(traceId = "trace-1", spanId = "span-2") ) ) received shouldBe listOf("span-1", "span-2") } test("throwing listener does not break collector or other listeners") { val collector = StoveTraceCollector() val received = mutableListOf() collector.addSpanListener(object : SpanEventListener { override fun onSpanRecorded(span: SpanInfo) { error("boom") } }) collector.addSpanListener(object : SpanEventListener { override fun onSpanRecorded(span: SpanInfo) { received.add(span.spanId) } }) collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) received shouldHaveSize 1 received[0] shouldBe "span-1" // Verify the span was still recorded despite listener failure collector.getTrace("trace-1") shouldHaveSize 1 } test("removed listener stops receiving events") { val collector = StoveTraceCollector() val received = mutableListOf() val listener = object : SpanEventListener { override fun onSpanRecorded(span: SpanInfo) { received.add(span.spanId) } } collector.addSpanListener(listener) collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) collector.removeSpanListener(listener) collector.record(createSpan(traceId = "trace-1", spanId = "span-2")) received shouldHaveSize 1 received[0] shouldBe "span-1" } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span123", parentSpanId: String? = null, operationName: String = "test.operation", serviceName: String = "test-service", startTimeNanos: Long = 1_000_000_000L, endTimeNanos: Long = 1_100_000_000L, status: SpanStatus = SpanStatus.OK ) = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/SpanInfoTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class SpanInfoTest : FunSpec({ test("durationMs should calculate correctly") { val span = createSpan( startTimeNanos = 1_000_000_000L, endTimeNanos = 1_500_000_000L ) span.durationMs shouldBe 500L } test("durationNanos should return correct value") { val span = createSpan( startTimeNanos = 1_000_000_000L, endTimeNanos = 1_500_000_000L ) span.durationNanos shouldBe 500_000_000L } test("isFailed should return true for ERROR status") { val span = createSpan(status = SpanStatus.ERROR) span.isFailed shouldBe true span.isSuccess shouldBe false } test("isSuccess should return true for OK status") { val span = createSpan(status = SpanStatus.OK) span.isSuccess shouldBe true span.isFailed shouldBe false } test("UNSET status should not be failed or success") { val span = createSpan(status = SpanStatus.UNSET) span.isFailed shouldBe false span.isSuccess shouldBe false } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span123", parentSpanId: String? = null, operationName: String = "test.operation", serviceName: String = "test-service", startTimeNanos: Long = 1_000_000_000L, endTimeNanos: Long = 1_100_000_000L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ) = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes, exception = exception ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/SpanTreeTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe class SpanTreeTest : FunSpec({ test("build should return null for empty list") { val tree = SpanTree.build(emptyList()) tree.shouldBeNull() } test("build should create single node for single span") { val span = createSpan(spanId = "root", parentSpanId = null) val tree = SpanTree.build(listOf(span)) tree.shouldNotBeNull() tree.span.spanId shouldBe "root" tree.children.shouldHaveSize(0) } test("build should create proper parent-child relationships") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null), createSpan(spanId = "child1", parentSpanId = "root"), createSpan(spanId = "child2", parentSpanId = "root"), createSpan(spanId = "grandchild", parentSpanId = "child1") ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() tree.span.spanId shouldBe "root" tree.children shouldHaveSize 2 val child1 = tree.children.find { it.span.spanId == "child1" } child1.shouldNotBeNull() child1.children shouldHaveSize 1 child1.children[0].span.spanId shouldBe "grandchild" } test("build should sort children by start time") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null, startTimeNanos = 1000), createSpan(spanId = "child3", parentSpanId = "root", startTimeNanos = 3000), createSpan(spanId = "child1", parentSpanId = "root", startTimeNanos = 1000), createSpan(spanId = "child2", parentSpanId = "root", startTimeNanos = 2000) ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() tree.children[0].span.spanId shouldBe "child1" tree.children[1].span.spanId shouldBe "child2" tree.children[2].span.spanId shouldBe "child3" } test("SpanNode.hasFailedDescendants should detect failures in subtree") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null, status = SpanStatus.OK), createSpan(spanId = "child", parentSpanId = "root", status = SpanStatus.OK), createSpan(spanId = "grandchild", parentSpanId = "child", status = SpanStatus.ERROR) ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() tree.hasFailedDescendants shouldBe true tree.span.isFailed shouldBe false } test("SpanNode.depth should calculate correct depth") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null), createSpan(spanId = "child", parentSpanId = "root"), createSpan(spanId = "grandchild", parentSpanId = "child") ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() tree.depth shouldBe 3 } test("SpanNode.spanCount should count all nodes") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null), createSpan(spanId = "child1", parentSpanId = "root"), createSpan(spanId = "child2", parentSpanId = "root"), createSpan(spanId = "grandchild", parentSpanId = "child1") ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() tree.spanCount shouldBe 4 } test("SpanNode.findFailurePoint should find deepest failure") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null, status = SpanStatus.OK), createSpan(spanId = "child", parentSpanId = "root", status = SpanStatus.ERROR), createSpan(spanId = "grandchild", parentSpanId = "child", status = SpanStatus.ERROR) ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() val failurePoint = tree.findFailurePoint() failurePoint.shouldNotBeNull() failurePoint.span.spanId shouldBe "grandchild" } test("SpanNode.flatten should return all spans in order") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null), createSpan(spanId = "child1", parentSpanId = "root"), createSpan(spanId = "child2", parentSpanId = "root") ) val tree = SpanTree.build(spans) tree.shouldNotBeNull() val flattened = tree.flatten() flattened shouldHaveSize 3 flattened[0].spanId shouldBe "root" } test("findSpan should locate span by predicate") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null, operationName = "root.op"), createSpan(spanId = "child", parentSpanId = "root", operationName = "child.op") ) val tree = SpanTree.build(spans)!! val found = SpanTree.findSpan(tree) { it.operationName == "child.op" } found.shouldNotBeNull() found.span.spanId shouldBe "child" } test("filterSpans should return all matching spans") { val spans = listOf( createSpan(spanId = "root", parentSpanId = null, status = SpanStatus.OK), createSpan(spanId = "child1", parentSpanId = "root", status = SpanStatus.ERROR), createSpan(spanId = "child2", parentSpanId = "root", status = SpanStatus.ERROR) ) val tree = SpanTree.build(spans)!! val failed = SpanTree.filterSpans(tree) { it.isFailed } failed shouldHaveSize 2 } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span123", parentSpanId: String? = null, operationName: String = "test.operation", serviceName: String = "test-service", startTimeNanos: Long = 1_000_000_000L, endTimeNanos: Long = 1_100_000_000L, status: SpanStatus = SpanStatus.OK ) = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/StoveTraceCollectorTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe class StoveTraceCollectorTest : FunSpec({ test("registerTrace should create empty span list") { val collector = StoveTraceCollector() collector.registerTrace("trace-1", "test-1") collector.getTrace("trace-1").shouldBeEmpty() collector.getTestId("trace-1") shouldBe "test-1" } test("record should add span to the correct trace") { val collector = StoveTraceCollector() collector.registerTrace("trace-1", "test-1") val span = createSpan(traceId = "trace-1", spanId = "span-1") collector.record(span) collector.getTrace("trace-1") shouldHaveSize 1 collector.getTrace("trace-1") shouldContain span } test("record should auto-create trace if not registered") { val collector = StoveTraceCollector() val span = createSpan(traceId = "trace-1", spanId = "span-1") collector.record(span) collector.getTrace("trace-1") shouldHaveSize 1 } test("recordAll should add multiple spans") { val collector = StoveTraceCollector() val spans = listOf( createSpan(traceId = "trace-1", spanId = "span-1"), createSpan(traceId = "trace-1", spanId = "span-2"), createSpan(traceId = "trace-1", spanId = "span-3") ) collector.recordAll(spans) collector.getTrace("trace-1") shouldHaveSize 3 } test("getTraceTree should build span tree") { val collector = StoveTraceCollector() val rootSpan = createSpan(traceId = "trace-1", spanId = "root", parentSpanId = null) val childSpan = createSpan(traceId = "trace-1", spanId = "child", parentSpanId = "root") collector.record(rootSpan) collector.record(childSpan) val tree = collector.getTraceTree("trace-1") tree.shouldNotBeNull() tree.span.spanId shouldBe "root" tree.children shouldHaveSize 1 tree.children[0].span.spanId shouldBe "child" } test("getTracesForTest should return all traces for a test") { val collector = StoveTraceCollector() collector.registerTrace("trace-1", "test-1") collector.registerTrace("trace-2", "test-1") collector.registerTrace("trace-3", "test-2") val traces = collector.getTracesForTest("test-1") traces shouldHaveSize 2 traces shouldContain "trace-1" traces shouldContain "trace-2" } test("getFailedSpans should return only failed spans") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "trace-1", spanId = "ok-1", status = SpanStatus.OK)) collector.record(createSpan(traceId = "trace-1", spanId = "error-1", status = SpanStatus.ERROR)) collector.record(createSpan(traceId = "trace-1", spanId = "ok-2", status = SpanStatus.OK)) val failed = collector.getFailedSpans("trace-1") failed shouldHaveSize 1 failed[0].spanId shouldBe "error-1" } test("hasFailures should detect failures") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "trace-1", spanId = "ok-1", status = SpanStatus.OK)) collector.hasFailures("trace-1") shouldBe false collector.record(createSpan(traceId = "trace-1", spanId = "error-1", status = SpanStatus.ERROR)) collector.hasFailures("trace-1") shouldBe true } test("clear should remove trace data") { val collector = StoveTraceCollector() collector.registerTrace("trace-1", "test-1") collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) collector.clear("trace-1") collector.getTrace("trace-1").shouldBeEmpty() collector.getTestId("trace-1").shouldBeNull() } test("clearForTest should remove all traces for a test") { val collector = StoveTraceCollector() collector.registerTrace("trace-1", "test-1") collector.registerTrace("trace-2", "test-1") collector.registerTrace("trace-3", "test-2") collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) collector.record(createSpan(traceId = "trace-2", spanId = "span-2")) collector.record(createSpan(traceId = "trace-3", spanId = "span-3")) collector.clearForTest("test-1") collector.getTrace("trace-1").shouldBeEmpty() collector.getTrace("trace-2").shouldBeEmpty() collector.getTrace("trace-3") shouldHaveSize 1 } test("clearAll should remove all data") { val collector = StoveTraceCollector() collector.registerTrace("trace-1", "test-1") collector.registerTrace("trace-2", "test-2") collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) collector.record(createSpan(traceId = "trace-2", spanId = "span-2")) collector.clearAll() collector.traceCount() shouldBe 0 collector.totalSpanCount() shouldBe 0 } test("spanCount should return correct count for trace") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) collector.record(createSpan(traceId = "trace-1", spanId = "span-2")) collector.record(createSpan(traceId = "trace-2", spanId = "span-3")) collector.spanCount("trace-1") shouldBe 2 collector.spanCount("trace-2") shouldBe 1 collector.spanCount("trace-3") shouldBe 0 } test("totalSpanCount should return total across all traces") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "trace-1", spanId = "span-1")) collector.record(createSpan(traceId = "trace-1", spanId = "span-2")) collector.record(createSpan(traceId = "trace-2", spanId = "span-3")) collector.totalSpanCount() shouldBe 3 } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span123", parentSpanId: String? = null, operationName: String = "test.operation", serviceName: String = "test-service", startTimeNanos: Long = 1_000_000_000L, endTimeNanos: Long = 1_100_000_000L, status: SpanStatus = SpanStatus.OK ) = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TraceReportBuilderTest.kt ================================================ package com.trendyol.stove.tracing import com.trendyol.stove.reporting.ReportEntry import com.trendyol.stove.reporting.StoveTestContext import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking class TraceReportBuilderTest : FunSpec({ test("buildFullReport includes stove report and trace tree") { val stove = Stove() stove.applicationUnderTest(NoOpApplicationUnderTest()) val tracingSystem = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) stove.getOrRegister(tracingSystem) runBlocking { stove.run() } val reporter = Stove.reporter() reporter.startTest(StoveTestContext("test-id", "test-name", "spec")) reporter.record( ReportEntry.failure( system = "Test", testId = "test-id", action = "failure", error = "boom" ) ) val ctx = TraceContext.start("test-id") tracingSystem.collector.registerTrace(ctx.traceId, ctx.testId) tracingSystem.collector.record( span( traceId = ctx.traceId, spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 0, endTimeNanos = 1_000_000, status = SpanStatus.OK ) ) val report = TraceReportBuilder.buildFullReport() report shouldContain "STOVE TEST EXECUTION REPORT" report shouldContain "EXECUTION TRACE" TraceContext.clear() stove.close() } test("buildFullReport returns empty when no failures and no trace") { val stove = Stove() stove.applicationUnderTest(NoOpApplicationUnderTest()) runBlocking { stove.run() } val report = TraceReportBuilder.buildFullReport() report shouldBe "" stove.close() } }) private class NoOpApplicationUnderTest : ApplicationUnderTest { override suspend fun start(configurations: List): String = "context" override suspend fun stop() = Unit } private fun span( traceId: String, spanId: String, parentSpanId: String?, operationName: String, startTimeNanos: Long, endTimeNanos: Long, status: SpanStatus ): SpanInfo = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = "service", startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TraceTreeRendererTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain class TraceTreeRendererTest : FunSpec({ test("render should show operation name and duration") { val span = createSpan( spanId = "root", operationName = "OrderService.createOrder", startTimeNanos = 0, endTimeNanos = 100_000_000 ) val tree = SpanNode(span) val output = TraceTreeRenderer.render(tree) output shouldContain "OrderService.createOrder" output shouldContain "[100ms]" } test("render should show checkmark for successful span") { val span = createSpan(status = SpanStatus.OK) val tree = SpanNode(span) val output = TraceTreeRenderer.render(tree) output shouldContain "✓" output shouldNotContain "✗" } test("render should show X for failed span") { val span = createSpan(status = SpanStatus.ERROR) val tree = SpanNode(span) val output = TraceTreeRenderer.render(tree) output shouldContain "✗" } test("render should mark failure point") { val span = createSpan( status = SpanStatus.ERROR, exception = ExceptionInfo("RuntimeException", "Something went wrong", listOf("at Test.kt:10")) ) val tree = SpanNode(span) val output = TraceTreeRenderer.render(tree) output shouldContain "◄── FAILURE POINT" output shouldContain "Error: RuntimeException: Something went wrong" } test("render should show relevant attributes") { val span = createSpan( attributes = mapOf( "db.system" to "postgresql", "db.statement" to "SELECT * FROM users", "internal.flag" to "true" ) ) val tree = SpanNode(span) val output = TraceTreeRenderer.render(tree, includeAttributes = true) output shouldContain "db.system: postgresql" output shouldContain "db.statement: SELECT * FROM users" output shouldNotContain "internal.flag" } test("render should show nested hierarchy") { val grandchild = SpanNode(createSpan(spanId = "grandchild", operationName = "Repository.save")) val child = SpanNode(createSpan(spanId = "child", operationName = "Service.process"), listOf(grandchild)) val root = SpanNode(createSpan(spanId = "root", operationName = "Controller.handle"), listOf(child)) val output = TraceTreeRenderer.render(root) output shouldContain "Controller.handle" output shouldContain "Service.process" output shouldContain "Repository.save" } test("renderCompact should produce condensed output") { val child = SpanNode(createSpan(operationName = "child.op", startTimeNanos = 0, endTimeNanos = 30_000_000)) val root = SpanNode(createSpan(operationName = "root.op", startTimeNanos = 0, endTimeNanos = 50_000_000), listOf(child)) val output = TraceTreeRenderer.renderCompact(root) output shouldContain "✓ root.op (50ms)" output shouldContain "✓ child.op (30ms)" } test("renderSummary should show statistics") { val failedChild = SpanNode( createSpan( spanId = "child", startTimeNanos = 10_000_000, endTimeNanos = 50_000_000, status = SpanStatus.ERROR, exception = ExceptionInfo("TestException", "Test error") ) ) val root = SpanNode( createSpan( spanId = "root", startTimeNanos = 0, endTimeNanos = 100_000_000, status = SpanStatus.OK ), listOf(failedChild) ) val output = TraceTreeRenderer.renderSummary(root) output shouldContain "Total spans: 2" output shouldContain "Failed spans: 1" output shouldContain "Total duration: 100ms" output shouldContain "Max depth: 2" } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span123", parentSpanId: String? = null, operationName: String = "test.operation", serviceName: String = "test-service", startTimeNanos: Long = 1_000_000_000L, endTimeNanos: Long = 1_100_000_000L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ) = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes, exception = exception ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TraceValidationTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import kotlin.time.Duration.Companion.milliseconds class TraceValidationTest : FunSpec({ test("should validate spans and counts") { val collector = StoveTraceCollector() val traceId = "trace-1" collector.registerTrace(traceId, "test-1") collector.recordAll( listOf( span( traceId = traceId, spanId = "root", parentSpanId = null, operationName = "root-op", startTimeNanos = 0, endTimeNanos = 10_000_000, status = SpanStatus.OK ), span( traceId = traceId, spanId = "child", parentSpanId = "root", operationName = "child-op", startTimeNanos = 1_000_000, endTimeNanos = 5_000_000, status = SpanStatus.ERROR, attributes = mapOf("key" to "value") ) ) ) val validation = TraceValidationDsl(collector, traceId) validation.shouldContainSpan("root") validation.shouldContainSpanMatching { it.operationName == "child-op" } validation.shouldHaveFailedSpan("child") validation.spanCountShouldBe(2) validation.spanCountShouldBeAtLeast(1) validation.spanCountShouldBeAtMost(2) validation.executionTimeShouldBeLessThan(20.milliseconds) validation.executionTimeShouldBeGreaterThan(5.milliseconds) validation.shouldHaveSpanWithAttribute("key", "value") validation.shouldHaveSpanWithAttributeContaining("key", "val") validation.getSpanCount() shouldBe 2 validation.getFailedSpanCount() shouldBe 1 validation.findSpanByName("root")?.operationName shouldBe "root-op" validation.getTotalDuration() shouldBe 10.milliseconds validation.spanTree() shouldNotBe null validation.renderTree() shouldNotBe "No spans in trace" validation.renderSummary() shouldNotBe "No spans in trace" } test("should fail when span expectations are not met") { val collector = StoveTraceCollector() val traceId = "trace-2" collector.registerTrace(traceId, "test-2") collector.record( span( traceId = traceId, spanId = "root", parentSpanId = null, operationName = "root-op", startTimeNanos = 0, endTimeNanos = 1_000_000, status = SpanStatus.OK ) ) val validation = TraceValidationDsl(collector, traceId) shouldThrow { validation.shouldNotContainSpan("root") } shouldThrow { validation.shouldHaveFailedSpan("root") } shouldThrow { validation.executionTimeShouldBeGreaterThan(5.milliseconds) } } }) private fun span( traceId: String, spanId: String, parentSpanId: String?, operationName: String, startTimeNanos: Long, endTimeNanos: Long, status: SpanStatus, attributes: Map = emptyMap() ): SpanInfo = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = "service", startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TracingDslTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlin.time.Duration.Companion.milliseconds class TracingDslTest : FunSpec({ test("shouldContainSpan should pass when span exists") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", operationName = "OrderService.create")) val dsl = TraceValidationDsl(collector, "t1") dsl.shouldContainSpan("OrderService") dsl.shouldContainSpan("create") } test("shouldContainSpan should fail when span not found") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", operationName = "OrderService.create")) val dsl = TraceValidationDsl(collector, "t1") val exception = shouldThrow { dsl.shouldContainSpan("PaymentService") } exception.message shouldContain "Expected span containing 'PaymentService'" } test("shouldNotContainSpan should pass when span absent") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", operationName = "OrderService.create")) val dsl = TraceValidationDsl(collector, "t1") dsl.shouldNotContainSpan("PaymentService") } test("shouldNotHaveFailedSpans should pass when no failures") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", status = SpanStatus.OK)) val dsl = TraceValidationDsl(collector, "t1") dsl.shouldNotHaveFailedSpans() } test("shouldNotHaveFailedSpans should fail when failures exist") { val collector = StoveTraceCollector() collector.record( createSpan( traceId = "t1", operationName = "failed.op", status = SpanStatus.ERROR, exception = ExceptionInfo("TestException", "test error") ) ) val dsl = TraceValidationDsl(collector, "t1") val exception = shouldThrow { dsl.shouldNotHaveFailedSpans() } exception.message shouldContain "Expected no failed spans" } test("shouldHaveFailedSpan should pass when matching failure exists") { val collector = StoveTraceCollector() collector.record( createSpan( traceId = "t1", operationName = "PaymentService.charge", status = SpanStatus.ERROR ) ) val dsl = TraceValidationDsl(collector, "t1") dsl.shouldHaveFailedSpan("PaymentService") } test("executionTimeShouldBeLessThan should validate duration") { val collector = StoveTraceCollector() collector.record( createSpan( traceId = "t1", startTimeNanos = 0, endTimeNanos = 50_000_000 // 50ms ) ) val dsl = TraceValidationDsl(collector, "t1") dsl.executionTimeShouldBeLessThan(100.milliseconds) shouldThrow { dsl.executionTimeShouldBeLessThan(10.milliseconds) } } test("spanCountShouldBe should validate exact count") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", spanId = "s1")) collector.record(createSpan(traceId = "t1", spanId = "s2")) val dsl = TraceValidationDsl(collector, "t1") dsl.spanCountShouldBe(2) } test("shouldHaveSpanWithAttribute should find attribute") { val collector = StoveTraceCollector() collector.record( createSpan( traceId = "t1", attributes = mapOf("db.system" to "postgresql") ) ) val dsl = TraceValidationDsl(collector, "t1") dsl.shouldHaveSpanWithAttribute("db.system", "postgresql") } test("findSpanByName should locate span") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", operationName = "OrderService.create")) val dsl = TraceValidationDsl(collector, "t1") val found = dsl.findSpanByName("OrderService") found.shouldNotBeNull() found.operationName shouldBe "OrderService.create" } test("spanTree should return span tree") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", spanId = "root", parentSpanId = null)) collector.record(createSpan(traceId = "t1", spanId = "child", parentSpanId = "root")) val dsl = TraceValidationDsl(collector, "t1") val tree = dsl.spanTree() tree.shouldNotBeNull() tree.spanCount shouldBe 2 } test("renderTree should produce output") { val collector = StoveTraceCollector() collector.record(createSpan(traceId = "t1", spanId = "root", operationName = "root.op")) val dsl = TraceValidationDsl(collector, "t1") val rendered = dsl.renderTree() rendered shouldContain "root.op" } test("tracing DSL should configure options") { val options = TracingOptions().apply { enabled() maxSpansPerTrace(500) enableSpanReceiver(port = 4318) } options.enabled shouldBe true options.maxSpansPerTrace shouldBe 500 options.spanReceiverEnabled shouldBe true options.spanReceiverPort shouldBe 4318 } }) private fun createSpan( traceId: String = "trace123", spanId: String = "span123", parentSpanId: String? = null, operationName: String = "test.operation", serviceName: String = "test-service", startTimeNanos: Long = 1_000_000_000L, endTimeNanos: Long = 1_100_000_000L, status: SpanStatus = SpanStatus.OK, attributes: Map = emptyMap(), exception: ExceptionInfo? = null ) = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = serviceName, startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes, exception = exception ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TracingOptionsTest.kt ================================================ package com.trendyol.stove.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlin.time.Duration.Companion.seconds class TracingOptionsTest : FunSpec({ test("should configure tracing options") { val options = TracingOptions() .enabled() .spanCollectionTimeout(2.seconds) .spanFilter { it.operationName == "op" } .maxSpansPerTrace(99) .enableSpanReceiver(port = 5555) options.enabled shouldBe true options.spanCollectionTimeout shouldBe 2.seconds options.maxSpansPerTrace shouldBe 99 options.spanReceiverEnabled shouldBe true options.spanReceiverPort shouldBe 5555 options.spanFilter(SpanInfo("t", "s", null, "op", "svc", 0, 1, SpanStatus.OK)) shouldBe true } test("copy should duplicate current values") { val original = TracingOptions() .enabled() .spanCollectionTimeout(3.seconds) .maxSpansPerTrace(7) .enableSpanReceiver(port = 7777) val copy = original.copy() copy.enabled shouldBe true copy.spanCollectionTimeout shouldBe 3.seconds copy.maxSpansPerTrace shouldBe 7 copy.spanReceiverEnabled shouldBe true copy.spanReceiverPort shouldBe 7777 } }) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TracingSystemTest.kt ================================================ package com.trendyol.stove.tracing import arrow.core.getOrElse import com.trendyol.stove.system.Stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import kotlinx.coroutines.runBlocking class TracingSystemTest : FunSpec({ test("ensureTraceStarted registers context and validation") { TraceContext.clear() val stove = Stove() val system = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) val ctx = runBlocking { system.ensureTraceStarted() } TraceContext.current() shouldNotBe null system.validation(ctx.traceId).getSpanCount() shouldBe 0 system.collector.traceCount() shouldBe 1 system.endTrace() TraceContext.current() shouldBe null } test("getTraceVisualizationForCurrentTest returns visualization for current trace") { TraceContext.clear() val stove = Stove() val system = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) val ctx = runBlocking { system.ensureTraceStarted() } system.collector.record( span( traceId = ctx.traceId, spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 0, endTimeNanos = 1_000_000, status = SpanStatus.OK ) ) val visualization = system.getTraceVisualizationForCurrentTest(waitTimeMs = 10) visualization.getOrElse { null }?.traceId shouldBe ctx.traceId } test("getTraceVisualizationForCurrentTest falls back to most recent trace") { TraceContext.clear() val stove = Stove() val system = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) runBlocking { system.ensureTraceStarted() } system.collector.record( span( traceId = "other-trace", spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 10, endTimeNanos = 20, status = SpanStatus.OK ) ) val visualization = system.getTraceVisualizationForCurrentTest(waitTimeMs = 1) visualization.getOrElse { null }?.traceId shouldBe "other-trace" } test("getTraceVisualizationForCurrentTest prefers trace with matching test ID attribute") { TraceContext.clear() val stove = Stove() val system = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) val ctx = system.ensureTraceStarted() system.collector.record( span( traceId = "trace-with-test-id", spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 10, endTimeNanos = 20, status = SpanStatus.OK, attributes = mapOf( "http.request.header.x_stove_test_id" to "[\"${ctx.testId}\"]" ) ) ) // More recent span from another test should not win over matching test-id trace. system.collector.record( span( traceId = "unrelated-recent-trace", spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 30, endTimeNanos = 40, status = SpanStatus.OK, attributes = mapOf( "http.request.header.x_stove_test_id" to "[\"other-test-id\"]" ) ) ) val visualization = system.getTraceVisualizationForCurrentTest(waitTimeMs = 1) visualization.getOrElse { null }?.traceId shouldBe "trace-with-test-id" } test("getTraceVisualizationForCurrentTest matches stove.test.id attributes encoded as key-value payload") { TraceContext.clear() val stove = Stove() val system = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) val ctx = system.ensureTraceStarted() system.collector.record( span( traceId = "trace-with-baggage-test-id", spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 10, endTimeNanos = 20, status = SpanStatus.OK, attributes = mapOf( "otel.baggage.stove.test.id" to "{\"stove.test.id\":\"${ctx.testId}\"}" ) ) ) // More recent trace from a different test should not be selected. system.collector.record( span( traceId = "unrelated-newer-trace", spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 30, endTimeNanos = 40, status = SpanStatus.OK, attributes = mapOf( "otel.baggage.stove.test.id" to "{\"stove.test.id\":\"other-test-id\"}" ) ) ) val visualization = system.getTraceVisualizationForCurrentTest(waitTimeMs = 1) visualization.getOrElse { null }?.traceId shouldBe "trace-with-baggage-test-id" } test("stop clears traces and context") { TraceContext.clear() val stove = Stove() val system = TracingSystem(stove, TracingSystemOptions(TracingOptions().enabled())) runBlocking { system.ensureTraceStarted() } system.collector.record( span( traceId = TraceContext.current()!!.traceId, spanId = "root", parentSpanId = null, operationName = "root", startTimeNanos = 0, endTimeNanos = 1, status = SpanStatus.OK ) ) runBlocking { system.stop() } TraceContext.current() shouldBe null system.collector.traceCount() shouldBe 0 } }) private fun span( traceId: String, spanId: String, parentSpanId: String?, operationName: String, startTimeNanos: Long, endTimeNanos: Long, status: SpanStatus, attributes: Map = emptyMap() ): SpanInfo = SpanInfo( traceId = traceId, spanId = spanId, parentSpanId = parentSpanId, operationName = operationName, serviceName = "service", startTimeNanos = startTimeNanos, endTimeNanos = endTimeNanos, status = status, attributes = attributes ) ================================================ FILE: lib/stove-tracing/src/test/kotlin/com/trendyol/stove/tracing/TracingValidationScopeTest.kt ================================================ package com.trendyol.stove.tracing import arrow.core.None import arrow.core.Some import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf import kotlin.time.Duration.Companion.milliseconds class TracingValidationScopeTest : FunSpec({ fun createScope(): Triple { val collector = StoveTraceCollector() val ctx = TraceContext.start("test-scope-1") collector.registerTrace(ctx.traceId, ctx.testId) collector.recordAll( listOf( SpanInfo( traceId = ctx.traceId, spanId = "root-span", parentSpanId = null, operationName = "root-op", serviceName = "test-service", startTimeNanos = 0, endTimeNanos = 10_000_000, status = SpanStatus.OK, attributes = mapOf("http.method" to "GET", "http.url" to "/api/users") ), SpanInfo( traceId = ctx.traceId, spanId = "child-span", parentSpanId = "root-span", operationName = "child-op", serviceName = "test-service", startTimeNanos = 1_000_000, endTimeNanos = 5_000_000, status = SpanStatus.ERROR, attributes = mapOf("db.system" to "postgresql") ) ) ) val validation = TraceValidationDsl(collector, ctx.traceId) val scope = TracingValidationScope(ctx, validation, collector) return Triple(collector, ctx, scope) } test("should expose trace context properties") { val (_, ctx, scope) = createScope() scope.traceId shouldBe ctx.traceId scope.rootSpanId shouldBe ctx.rootSpanId scope.testId shouldBe ctx.testId TraceContext.clear() } test("toTraceparent should return W3C format") { val (_, ctx, scope) = createScope() val traceparent = scope.toTraceparent() traceparent shouldBe "00-${ctx.traceId}-${ctx.rootSpanId}-01" TraceContext.clear() } test("should delegate shouldContainSpan") { val (_, _, scope) = createScope() scope.shouldContainSpan("root") scope.shouldContainSpan("child") TraceContext.clear() } test("should delegate shouldContainSpanMatching") { val (_, _, scope) = createScope() scope.shouldContainSpanMatching { it.operationName == "child-op" } TraceContext.clear() } test("should delegate shouldNotContainSpan") { val (_, _, scope) = createScope() scope.shouldNotContainSpan("nonexistent") TraceContext.clear() } test("should delegate shouldNotHaveFailedSpans throws when there are failures") { val (_, _, scope) = createScope() try { scope.shouldNotHaveFailedSpans() throw AssertionError("Expected exception") } catch (e: IllegalArgumentException) { e.message shouldContain "failed spans" } TraceContext.clear() } test("should delegate shouldHaveFailedSpan") { val (_, _, scope) = createScope() scope.shouldHaveFailedSpan("child") TraceContext.clear() } test("should delegate executionTimeShouldBeLessThan") { val (_, _, scope) = createScope() scope.executionTimeShouldBeLessThan(20.milliseconds) TraceContext.clear() } test("should delegate executionTimeShouldBeGreaterThan") { val (_, _, scope) = createScope() scope.executionTimeShouldBeGreaterThan(5.milliseconds) TraceContext.clear() } test("should delegate spanCountShouldBe") { val (_, _, scope) = createScope() scope.spanCountShouldBe(2) TraceContext.clear() } test("should delegate spanCountShouldBeAtLeast") { val (_, _, scope) = createScope() scope.spanCountShouldBeAtLeast(1) TraceContext.clear() } test("should delegate spanCountShouldBeAtMost") { val (_, _, scope) = createScope() scope.spanCountShouldBeAtMost(5) TraceContext.clear() } test("should delegate shouldHaveSpanWithAttribute") { val (_, _, scope) = createScope() scope.shouldHaveSpanWithAttribute("http.method", "GET") TraceContext.clear() } test("should delegate shouldHaveSpanWithAttributeContaining") { val (_, _, scope) = createScope() scope.shouldHaveSpanWithAttributeContaining("http.url", "/api") TraceContext.clear() } test("should delegate getSpanCount") { val (_, _, scope) = createScope() scope.getSpanCount() shouldBe 2 TraceContext.clear() } test("should delegate getFailedSpans") { val (_, _, scope) = createScope() scope.getFailedSpans().size shouldBe 1 scope.getFailedSpans().first().operationName shouldBe "child-op" TraceContext.clear() } test("should delegate getFailedSpanCount") { val (_, _, scope) = createScope() scope.getFailedSpanCount() shouldBe 1 TraceContext.clear() } test("should delegate findSpan returning Option") { val (_, _, scope) = createScope() scope.findSpan { it.operationName == "root-op" }.shouldBeInstanceOf>() scope.findSpan { it.operationName == "nonexistent" } shouldBe None TraceContext.clear() } test("should delegate findSpanByName returning Option") { val (_, _, scope) = createScope() scope.findSpanByName("root").shouldBeInstanceOf>() scope.findSpanByName("nonexistent") shouldBe None TraceContext.clear() } test("should delegate spanTree returning Option") { val (_, _, scope) = createScope() scope.spanTree().shouldBeInstanceOf>() TraceContext.clear() } test("should delegate getTotalDuration") { val (_, _, scope) = createScope() scope.getTotalDuration() shouldBe 10.milliseconds TraceContext.clear() } test("should delegate renderTree") { val (_, _, scope) = createScope() scope.renderTree() shouldNotBe "No spans in trace" scope.renderTree() shouldContain "root-op" TraceContext.clear() } test("should delegate renderSummary") { val (_, _, scope) = createScope() scope.renderSummary() shouldNotBe "No spans in trace" TraceContext.clear() } test("waitForSpans should return spans immediately when they already exist") { val (_, _, scope) = createScope() val spans = scope.waitForSpans(expectedCount = 2, timeoutMs = 1000) spans.size shouldBe 2 TraceContext.clear() } test("getTraceVisualization should return visualization") { val (_, _, scope) = createScope() val viz = scope.getTraceVisualization() viz.traceId shouldBe scope.traceId viz.testId shouldBe scope.testId viz.totalSpans shouldBe 2 viz.failedSpans shouldBe 1 TraceContext.clear() } test("getAllTraceVisualizations should return all traces") { val (collector, ctx, scope) = createScope() // Add another trace val otherTraceId = "other-trace-id" collector.registerTrace(otherTraceId, "other-test") collector.record( SpanInfo( traceId = otherTraceId, spanId = "other-span", parentSpanId = null, operationName = "other-op", serviceName = "other-service", startTimeNanos = 0, endTimeNanos = 1_000_000, status = SpanStatus.OK ) ) val allViz = scope.getAllTraceVisualizations() allViz.size shouldBe 2 TraceContext.clear() } }) ================================================ FILE: lib/stove-wiremock/api/stove-wiremock.api ================================================ public final class com/trendyol/stove/wiremock/ExtensionsKt { public static final fun containsKey (Lcom/github/benmanes/caffeine/cache/Cache;Ljava/lang/Object;)Z } public final class com/trendyol/stove/wiremock/OptionsKt { public static final fun wiremock-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun wiremock-QljV9L8 (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun wiremock-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/system/abstractions/SystemKey;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; public static final fun wiremock-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/wiremock/StubBehaviourBuilder { public fun (Lcom/github/tomakehurst/wiremock/WireMockServer;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;)V public synthetic fun (Lcom/github/tomakehurst/wiremock/WireMockServer;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun initially (Lkotlin/jvm/functions/Function0;)V public final fun then (Lkotlin/jvm/functions/Function0;)V } public final class com/trendyol/stove/wiremock/WireMockContext { public fun (IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;)V public synthetic fun (IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()Z public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Lcom/trendyol/stove/serialization/StoveSerde; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun component8 ()Ljava/lang/String; public final fun copy (IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;)Lcom/trendyol/stove/wiremock/WireMockContext; public static synthetic fun copy$default (Lcom/trendyol/stove/wiremock/WireMockContext;IZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/wiremock/WireMockContext; public fun equals (Ljava/lang/Object;)Z public final fun getAfterRequest ()Lkotlin/jvm/functions/Function2; public final fun getAfterStubRemoved ()Lkotlin/jvm/functions/Function2; public final fun getConfigure ()Lkotlin/jvm/functions/Function1; public final fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public final fun getKeyName ()Ljava/lang/String; public final fun getPort ()I public final fun getRemoveStubAfterRequestMatched ()Z public final fun getSerde ()Lcom/trendyol/stove/serialization/StoveSerde; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/wiremock/WireMockExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;I)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()I public final fun copy (Ljava/lang/String;I)Lcom/trendyol/stove/wiremock/WireMockExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/wiremock/WireMockExposedConfiguration;Ljava/lang/String;IILjava/lang/Object;)Lcom/trendyol/stove/wiremock/WireMockExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getBaseUrl ()Ljava/lang/String; public final fun getHost ()Ljava/lang/String; public final fun getPort ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/wiremock/WireMockRequestListener : com/github/tomakehurst/wiremock/extension/ServeEventListener { public fun (Lcom/github/benmanes/caffeine/cache/Cache;Lkotlin/jvm/functions/Function2;)V public fun beforeResponseSent (Lcom/github/tomakehurst/wiremock/stubbing/ServeEvent;Lcom/github/tomakehurst/wiremock/extension/Parameters;)V public fun getName ()Ljava/lang/String; } public final class com/trendyol/stove/wiremock/WireMockSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunAware, com/trendyol/stove/system/abstractions/ValidatedSystem { public static final field Companion Lcom/trendyol/stove/wiremock/WireMockSystem$Companion; public static final field STOVE_TEST_ID_KEY Ljava/lang/String; public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/wiremock/WireMockContext;)V public final fun behaviourFor (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun callsFor (Lcom/github/tomakehurst/wiremock/http/RequestMethod;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;)Ljava/util/List; public final fun callsFor (Lkotlin/jvm/functions/Function0;)Ljava/util/List; public static synthetic fun callsFor$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Lcom/github/tomakehurst/wiremock/http/RequestMethod;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/util/List; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun mockDelete (Ljava/lang/String;ILjava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockDelete$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;ILjava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockDeleteConfigure (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockDeleteConfigure$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockGet (Ljava/lang/String;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockGet$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockGetConfigure (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockGetConfigure$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockHead (Ljava/lang/String;ILjava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockHead$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;ILjava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockHeadConfigure (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockHeadConfigure$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPatch (Ljava/lang/String;ILarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPatch$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;ILarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPatchConfigure (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPatchConfigure$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPatchContaining (Ljava/lang/String;Ljava/util/Map;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPatchContaining$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Ljava/util/Map;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPost (Ljava/lang/String;ILarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPost$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;ILarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPostConfigure (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPostConfigure$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPostContaining (Ljava/lang/String;Ljava/util/Map;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPostContaining$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Ljava/util/Map;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPut (Ljava/lang/String;ILarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPut$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;ILarrow/core/Option;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPutConfigure (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPutConfigure$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun mockPutContaining (Ljava/lang/String;Ljava/util/Map;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mockPutContaining$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Ljava/lang/String;Ljava/util/Map;ILarrow/core/Option;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldHaveBeenCalled (Lcom/github/tomakehurst/wiremock/client/CountMatchingStrategy;Lkotlin/jvm/functions/Function0;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldHaveBeenCalled (Lcom/github/tomakehurst/wiremock/http/RequestMethod;Ljava/lang/String;Lcom/github/tomakehurst/wiremock/client/CountMatchingStrategy;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldHaveBeenCalled$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Lcom/github/tomakehurst/wiremock/client/CountMatchingStrategy;Lkotlin/jvm/functions/Function0;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun shouldHaveBeenCalled$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Lcom/github/tomakehurst/wiremock/http/RequestMethod;Ljava/lang/String;Lcom/github/tomakehurst/wiremock/client/CountMatchingStrategy;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun shouldNotHaveBeenCalled (Lcom/github/tomakehurst/wiremock/http/RequestMethod;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldNotHaveBeenCalled (Lkotlin/jvm/functions/Function0;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shouldNotHaveBeenCalled$default (Lcom/trendyol/stove/wiremock/WireMockSystem;Lcom/github/tomakehurst/wiremock/http/RequestMethod;Ljava/lang/String;Larrow/core/Option;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public fun validate (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/wiremock/WireMockSystem$Companion { public final fun server (Lcom/trendyol/stove/wiremock/WireMockSystem;)Lcom/github/tomakehurst/wiremock/WireMockServer; } public final class com/trendyol/stove/wiremock/WireMockSystemOptions : com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public fun ()V public fun (ILkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;)V public synthetic fun (ILkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()Lkotlin/jvm/functions/Function1; public final fun component3 ()Z public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Lkotlin/jvm/functions/Function2; public final fun component6 ()Lcom/trendyol/stove/serialization/StoveSerde; public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun copy (ILkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/wiremock/WireMockSystemOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/wiremock/WireMockSystemOptions;ILkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lcom/trendyol/stove/serialization/StoveSerde;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/wiremock/WireMockSystemOptions; public fun equals (Ljava/lang/Object;)Z public final fun getAfterRequest ()Lkotlin/jvm/functions/Function2; public final fun getAfterStubRemoved ()Lkotlin/jvm/functions/Function2; public final fun getConfigure ()Lkotlin/jvm/functions/Function1; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public final fun getPort ()I public final fun getRemoveStubAfterRequestMatched ()Z public final fun getSerde ()Lcom/trendyol/stove/serialization/StoveSerde; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/wiremock/WireMockVacuumCleaner : com/github/tomakehurst/wiremock/extension/ServeEventListener { public fun (Lcom/github/benmanes/caffeine/cache/Cache;Lkotlin/jvm/functions/Function2;)V public fun beforeResponseSent (Lcom/github/tomakehurst/wiremock/stubbing/ServeEvent;Lcom/github/tomakehurst/wiremock/extension/Parameters;)V public fun getName ()Ljava/lang/String; public final fun wireMock (Lcom/github/tomakehurst/wiremock/WireMockServer;)V } public abstract interface annotation class com/trendyol/stove/wiremock/WiremockDsl : java/lang/annotation/Annotation { } ================================================ FILE: lib/stove-wiremock/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.wiremock.standalone) api(libs.caffeine) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/Extensions.kt ================================================ package com.trendyol.stove.wiremock import com.github.benmanes.caffeine.cache.Cache fun Cache.containsKey(key: K): Boolean = this.getIfPresent(key) != null ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/Options.kt ================================================ package com.trendyol.stove.wiremock import arrow.core.getOrElse import com.github.tomakehurst.wiremock.common.ConsoleNotifier import com.github.tomakehurst.wiremock.core.WireMockConfiguration import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl /** * Configuration exposed by WireMock after it starts. * * This allows the application under test to receive the actual WireMock URL, * which is especially useful when using dynamic ports (port = 0). * * @property host The host where WireMock is running. * @property port The actual port WireMock is listening on. * @property baseUrl The complete base URL (http://host:port). */ data class WireMockExposedConfiguration( val host: String, val port: Int ) : ExposedConfiguration { val baseUrl: String get() = WireMockUrls.baseUrl(host, port) } data class WireMockSystemOptions( /** * Port of wiremock server. * Defaults to 0, which lets WireMock pick an available port automatically. * This avoids port conflicts, especially in CI environments. */ val port: Int = 0, /** * Configures wiremock server */ val configure: WireMockConfiguration.() -> WireMockConfiguration = { this.notifier(ConsoleNotifier(true)) }, /** * Removes the stub when request matches/completes * Default value is false */ val removeStubAfterRequestMatched: Boolean = false, /** * Called after stub removed */ val afterStubRemoved: AfterStubRemoved = { _, _ -> }, /** * Called after request handled */ val afterRequest: AfterRequestHandler = { _, _ -> }, /** * ObjectMapper for serialization/deserialization */ val serde: StoveSerde = StoveSerde.jackson.anyByteArraySerde(), /** * Configures the exposed configuration for the application under test. * Use this to inject WireMock's URL into your application's configuration. * * Example: * ```kotlin * WireMockSystemOptions( * port = 0, // dynamic port * configureExposedConfiguration = { cfg -> * listOf( * "external-apis.inventory.url=${cfg.baseUrl}", * "external-apis.payment.url=${cfg.baseUrl}" * ) * } * ) * ``` */ override val configureExposedConfiguration: (WireMockExposedConfiguration) -> List = { _ -> listOf() } ) : SystemOptions, ConfiguresExposedConfiguration data class WireMockContext( val port: Int, val removeStubAfterRequestMatched: Boolean, val afterStubRemoved: AfterStubRemoved, val afterRequest: AfterRequestHandler, val serde: StoveSerde, val configure: WireMockConfiguration.() -> WireMockConfiguration, val configureExposedConfiguration: (WireMockExposedConfiguration) -> List, val keyName: String? = null ) internal fun Stove.withWireMock(options: WireMockSystemOptions = WireMockSystemOptions()): Stove = WireMockSystem( stove = this, WireMockContext( options.port, options.removeStubAfterRequestMatched, options.afterStubRemoved, options.afterRequest, options.serde, options.configure, options.configureExposedConfiguration ) ).also { getOrRegister(it) } .let { this } internal fun Stove.withWireMock(key: SystemKey, options: WireMockSystemOptions = WireMockSystemOptions()): Stove = WireMockSystem( stove = this, WireMockContext( options.port, options.removeStubAfterRequestMatched, options.afterStubRemoved, options.afterRequest, options.serde, options.configure, options.configureExposedConfiguration, keyName = keyDisplayName(key) ) ).also { getOrRegister(key, it) } .let { this } internal fun Stove.wiremock(): WireMockSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(WireMockSystem::class) } internal fun Stove.wiremock(key: SystemKey): WireMockSystem = getOrNone(key).getOrElse { throw SystemNotRegisteredException( WireMockSystem::class, WireMockSystemMessages.systemNotRegistered(keyDisplayName(key)) ) } fun WithDsl.wiremock( configure: @StoveDsl () -> WireMockSystemOptions ): Stove = this.stove.withWireMock(configure()) /** * Registers a keyed WireMock system for testing multiple external service mocks. * * ```kotlin * Stove().with { * wiremock(PaymentGateway) { * WireMockSystemOptions(port = 0, configureExposedConfiguration = { cfg -> listOf(...) }) * } * } * ``` * * @param key The [SystemKey] identifying this WireMock instance. * @param configure Configuration block returning [WireMockSystemOptions]. * @return The test system for fluent chaining. */ fun WithDsl.wiremock( key: SystemKey, configure: @StoveDsl () -> WireMockSystemOptions ): Stove = this.stove.withWireMock(key, configure()) suspend fun ValidationDsl.wiremock(validation: @WiremockDsl suspend WireMockSystem.() -> Unit): Unit = validation(this.stove.wiremock()) /** * Executes WireMock assertions against a keyed WireMock instance within the validation DSL. * * ```kotlin * stove { * wiremock(PaymentGateway) { * mockGet(url = "/status", statusCode = 200) * } * } * ``` * * @param key The [SystemKey] identifying the WireMock instance. * @param validation The WireMock assertion block. */ suspend fun ValidationDsl.wiremock( key: SystemKey, validation: @WiremockDsl suspend WireMockSystem.() -> Unit ): Unit = validation(this.stove.wiremock(key)) ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockBodyMatching.kt ================================================ package com.trendyol.stove.wiremock import com.github.tomakehurst.wiremock.client.MappingBuilder import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.matching.* import com.trendyol.stove.serialization.StoveSerde internal fun MappingBuilder.configureBodyContaining( requestContaining: Map, serde: StoveSerde ) { requestContaining.forEach { (key, value) -> val matcher = createValueMatcher(value, serde) val jsonPath = WireMockJsonPath.field(key) withRequestBody(matchingJsonPath(jsonPath, matcher)) } } internal fun RequestPatternBuilder.configureBodyContaining( requestContaining: Map, serde: StoveSerde ) { requestContaining.forEach { (key, value) -> val matcher = createValueMatcher(value, serde) val jsonPath = WireMockJsonPath.field(key) withRequestBody(matchingJsonPath(jsonPath, matcher)) } } private fun createValueMatcher( value: Any, serde: StoveSerde ): StringValuePattern = when (value) { is String -> equalTo(value) is Number -> equalTo(value.toString()) is Boolean -> equalTo(value.toString()) is Map<*, *> -> equalToJson(serde.serialize(value).decodeToString(), true, true) is Collection<*> -> equalToJson(serde.serialize(value).decodeToString(), true, true) else -> equalToJson(serde.serialize(value).decodeToString(), true, true) } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockCallJournal.kt ================================================ package com.trendyol.stove.wiremock import com.github.tomakehurst.wiremock.stubbing.ServeEvent import com.github.tomakehurst.wiremock.stubbing.StubMapping import com.github.tomakehurst.wiremock.verification.LoggedRequest import com.trendyol.stove.tracing.TraceContext import java.util.concurrent.* internal class WireMockCallJournal { private val stubsByTestId = ConcurrentHashMap>() private val serveEventsByTestId = ConcurrentHashMap>() fun recordStub(stubMapping: StubMapping) { val testId = stubMapping.stoveTestId() ?: return stubsByTestId.computeIfAbsent(testId) { CopyOnWriteArrayList() }.add(stubMapping) } fun record(serveEvent: ServeEvent) { val testId = serveEvent.stoveTestId() ?: return serveEventsByTestId.computeIfAbsent(testId) { CopyOnWriteArrayList() }.add(serveEvent) } fun requests(testId: String): List = serveEvents(testId).map { it.request } fun stubs(testId: String): List = stubsByTestId[testId]?.toList() ?: emptyList() fun serveEvents(testId: String): List = serveEventsByTestId[testId]?.toList() ?: emptyList() fun clear(testId: String) { stubsByTestId.remove(testId) serveEventsByTestId.remove(testId) } fun clearAll() { stubsByTestId.clear() serveEventsByTestId.clear() } private fun ServeEvent.stoveTestId(): String? = stubMapping?.metadata?.getString(WireMockSystem.STOVE_TEST_ID_KEY) ?: request.getHeader(TraceContext.STOVE_TEST_ID_HEADER) private fun StubMapping.stoveTestId(): String? = metadata?.getString(WireMockSystem.STOVE_TEST_ID_KEY) } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockReportConstants.kt ================================================ package com.trendyol.stove.wiremock internal object WireMockHeaders { const val CONTENT_TYPE = "Content-Type" const val APPLICATION_JSON = "application/json" const val APPLICATION_JSON_UTF8 = "application/json; charset=UTF-8" } internal object WireMockUrls { fun baseUrl(host: String, port: Int): String = "http://$host:$port" } internal object WireMockReportSystem { fun name(keyName: String?): String = "WireMock" + (keyName?.let { " [$it]" } ?: "") } internal object WireMockSystemMessages { fun systemNotRegistered(keyName: String): String = "No WireMockSystem registered with key '$keyName'" } internal object WireMockReportMetadataKeys { const val STATUS_CODE = "statusCode" const val RESPONSE_HEADERS = "responseHeaders" } internal object WireMockReportActions { fun registerStub(method: String, url: String): String = "Register stub: $method $url" fun registerCustomStub(method: String, url: String): String = "Register stub: $method $url (custom)" fun registerPartialStub(url: String): String = "Register stub: $url (partial match)" fun registerBehaviourStub(url: String): String = "Register behaviour stub: $url" const val VALIDATE_ALL_REQUESTS_SHOULD_MATCH = "Validate: All requests should match registered stubs" const val VALIDATE_ALL_REQUESTS_MATCHED = "Validate: All requests matched registered stubs" const val VERIFY_REQUEST_WAS_CALLED = "Verify request was called" const val VERIFY_REQUEST_WAS_NOT_CALLED = "Verify request was not called" } internal object WireMockValidationMessages { const val REQUEST_CONTAINING_EMPTY = "requestContaining must not be empty" const val VALIDATION_FAILED = "Validation failed" const val EXPECTED_NO_UNMATCHED_REQUESTS = "0 unmatched requests" const val STOP_FAILED_PREFIX = "got an error while stopping wiremock:" fun unmatchedRequests(problems: String): String = "There are unmatched requests in the mock pipeline, please satisfy all the wanted requests.\n$problems" fun unmatchedRequestCount(count: Int): String = "$count unmatched request(s)" fun requestCount(count: Int): String = "$count request(s)" fun unmatchedRequestDetails( url: String, bodyAsString: String, queryParams: String ): String = """ Url: $url Body: $bodyAsString QueryParams: $queryParams """.trimIndent() } internal object WireMockSnapshotStateKeys { const val REGISTERED_STUBS = "registeredStubs" const val ACTIVE_STUBS = "activeStubs" const val RECEIVED_REQUESTS = "receivedRequests" const val RECORDED_REQUESTS = "recordedRequests" const val SERVED_REQUESTS = "servedRequests" const val UNMATCHED_REQUESTS = "unmatchedRequests" } internal object WireMockSnapshotFieldKeys { const val ID = "id" const val NAME = "name" const val ACTIVE = "active" const val PRIORITY = "priority" const val SCENARIO_NAME = "scenarioName" const val REQUIRED_SCENARIO_STATE = "requiredScenarioState" const val NEW_SCENARIO_STATE = "newScenarioState" const val REQUEST = "request" const val RESPONSE = "response" const val RESPONSE_DEFINITION = "responseDefinition" const val METADATA = "metadata" const val METHOD = "method" const val URL = "url" const val STATUS = "status" const val STATUS_MESSAGE = "statusMessage" const val MATCHED = "matched" const val STUB_ID = "stubId" const val STUB_NAME = "stubName" const val TIMING = "timing" const val ADDED_DELAY_MS = "addedDelayMs" const val PROCESS_TIME_MS = "processTimeMs" const val RESPONSE_SEND_TIME_MS = "responseSendTimeMs" const val SERVE_TIME_MS = "serveTimeMs" const val TOTAL_TIME_MS = "totalTimeMs" const val ABSOLUTE_URL = "absoluteUrl" const val CLIENT_IP = "clientIp" const val LOGGED_DATE = "loggedDate" const val HEADERS = "headers" const val QUERY_PARAMS = "queryParams" const val BODY = "body" const val URL_MATCHER = "urlMatcher" const val BODY_PATTERNS = "bodyPatterns" const val CUSTOM_MATCHER = "customMatcher" const val BODY_FILE_NAME = "bodyFileName" const val FAULT = "fault" const val FIXED_DELAY_MS = "fixedDelayMs" const val TRANSFORMERS = "transformers" const val MIME_TYPE = "mimeType" } internal object WireMockSnapshotSummary { fun registeredStubs(total: Int, active: Int): String = "Registered stubs (this test): $total (active: $active)" fun receivedRequests(total: Int): String = "Received requests (this test): $total" fun servedRequests(total: Int, matched: Int): String = "Served requests (this test): $total (matched: $matched)" fun unmatchedRequests(total: Int): String = "Unmatched requests: $total" } internal object WireMockSnapshotDisplayValues { const val CUSTOM_MATCHER = "" } internal object WireMockBehaviourMessages { const val INITIALLY_ONCE = "You should call initially only once" const val INITIALLY_BEFORE_THEN = "You should call initially before calling then" } internal object WireMockBehaviourNames { fun scenarioName(url: String): String = "Scenario for $url" fun state(counter: Int): String = "State$counter" } internal object WireMockJsonPath { fun field(key: String): String = "\$.$key" } internal object WireMockExtensionNames { const val VACUUM_CLEANER = "StoveVacuumCleaner" } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockRequestListener.kt ================================================ package com.trendyol.stove.wiremock import com.github.benmanes.caffeine.cache.Cache import com.github.tomakehurst.wiremock.extension.* import com.github.tomakehurst.wiremock.stubbing.* import java.util.* class WireMockRequestListener( private val stubLog: Cache, private val afterRequest: AfterRequestHandler ) : ServeEventListener { private var recordRequest: (ServeEvent) -> Unit = {} internal constructor( stubLog: Cache, afterRequest: AfterRequestHandler, recordRequest: (ServeEvent) -> Unit ) : this(stubLog, afterRequest) { this.recordRequest = recordRequest } override fun getName(): String = WireMockRequestListener::class.java.simpleName override fun beforeResponseSent( serveEvent: ServeEvent?, parameters: Parameters? ) { val event = serveEvent!! recordRequest(event) afterRequest(event, stubLog) } } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockSnapshot.kt ================================================ package com.trendyol.stove.wiremock import com.github.tomakehurst.wiremock.http.HttpHeaders import com.github.tomakehurst.wiremock.http.LoggedResponse import com.github.tomakehurst.wiremock.http.ResponseDefinition import com.github.tomakehurst.wiremock.matching.RequestPattern import com.github.tomakehurst.wiremock.stubbing.ServeEvent import com.github.tomakehurst.wiremock.stubbing.StubMapping import com.github.tomakehurst.wiremock.verification.LoggedRequest import com.trendyol.stove.reporting.SystemSnapshot import com.trendyol.stove.wiremock.WireMockSnapshotDisplayValues as Display import com.trendyol.stove.wiremock.WireMockSnapshotFieldKeys as Field import com.trendyol.stove.wiremock.WireMockSnapshotStateKeys as State internal class WireMockSnapshotBuilder( private val reportSystemName: String, private val callJournal: WireMockCallJournal, activeStubs: List ) { private val activeStubIds = activeStubs.map { it.id }.toSet() fun build(testId: String): SystemSnapshot { val registeredStubs = callJournal.stubs(testId) val activeStubs = registeredStubs.filter { it.id in activeStubIds } val serveEvents = callJournal.serveEvents(testId) val receivedRequests = serveEvents.map { it.toReceivedRequestSnapshotMap() } val unmatchedRequests = serveEvents .filterNot { it.wasMatched } .map { it.toReceivedRequestSnapshotMap() } return SystemSnapshot( system = reportSystemName, state = mapOf( State.REGISTERED_STUBS to registeredStubs.map { it.toSnapshotMap(active = it.id in activeStubIds) }, State.ACTIVE_STUBS to activeStubs.map { it.toSnapshotMap(active = true) }, State.RECEIVED_REQUESTS to receivedRequests, State.RECORDED_REQUESTS to receivedRequests, State.SERVED_REQUESTS to serveEvents.map { it.toServedSnapshotMap() }, State.UNMATCHED_REQUESTS to unmatchedRequests ), summary = buildString { appendLine(WireMockSnapshotSummary.registeredStubs(registeredStubs.size, activeStubs.size)) appendLine(WireMockSnapshotSummary.receivedRequests(receivedRequests.size)) appendLine(WireMockSnapshotSummary.servedRequests(serveEvents.size, serveEvents.count { it.wasMatched })) appendLine(WireMockSnapshotSummary.unmatchedRequests(unmatchedRequests.size)) } ) } } internal fun StubMapping.toSnapshotMap(active: Boolean): Map = snapshotMap( Field.ID to id.toString(), Field.NAME to name, Field.ACTIVE to active, Field.PRIORITY to priority, Field.SCENARIO_NAME to scenarioName, Field.REQUIRED_SCENARIO_STATE to requiredScenarioState, Field.NEW_SCENARIO_STATE to newScenarioState, Field.REQUEST to request.toSnapshotMap(), Field.RESPONSE to response.toSnapshotMap(), Field.METADATA to metadata ?.filterKeys { it != WireMockSystem.STOVE_TEST_ID_KEY } ?.takeIf { it.isNotEmpty() }, Field.METHOD to request.method?.value(), Field.URL to request.displayUrl(), Field.STATUS to response.status ) internal fun ServeEvent.toServedSnapshotMap(): Map = snapshotMap( Field.ID to id.toString(), Field.MATCHED to wasMatched, Field.STUB_ID to stubMapping?.id?.toString(), Field.STUB_NAME to stubMapping?.name, Field.REQUEST to request.toSnapshotMap(), Field.RESPONSE to response.toSnapshotMap(), Field.RESPONSE_DEFINITION to responseDefinition.toSnapshotMap(), Field.TIMING to timing?.let { snapshotMap( Field.ADDED_DELAY_MS to it.addedDelay, Field.PROCESS_TIME_MS to it.processTime, Field.RESPONSE_SEND_TIME_MS to it.responseSendTime, Field.SERVE_TIME_MS to it.serveTime, Field.TOTAL_TIME_MS to it.totalTime ) } ) internal fun ServeEvent.toReceivedRequestSnapshotMap(): Map = request.toSnapshotMap( Field.MATCHED to wasMatched, Field.STUB_ID to stubMapping?.id?.toString(), Field.STUB_NAME to stubMapping?.name ) internal fun LoggedRequest.toSnapshotMap(vararg additional: Pair): Map = snapshotMap( Field.ID to id?.toString(), Field.METHOD to method.value(), Field.URL to url, Field.ABSOLUTE_URL to absoluteUrl, Field.CLIENT_IP to clientIp, Field.LOGGED_DATE to loggedDateString, Field.HEADERS to headers.toSnapshotMap().takeIf { it.isNotEmpty() }, Field.QUERY_PARAMS to queryParams.mapValues { it.value.values() }.takeIf { it.isNotEmpty() }, Field.BODY to bodyAsString ) + snapshotMap(*additional) private fun RequestPattern.toSnapshotMap(): Map = snapshotMap( Field.METHOD to method?.value(), Field.URL to displayUrl(), Field.URL_MATCHER to urlMatcher?.toString(), Field.HEADERS to headers?.mapValues { it.value.toString() }?.takeIf { it.isNotEmpty() }, Field.QUERY_PARAMS to queryParameters?.mapValues { it.value.toString() }?.takeIf { it.isNotEmpty() }, Field.BODY_PATTERNS to bodyPatterns?.map { it.toString() }?.takeIf { it.isNotEmpty() }, Field.CUSTOM_MATCHER to customMatcher?.toString() ) private fun ResponseDefinition.toSnapshotMap(): Map = snapshotMap( Field.STATUS to status, Field.STATUS_MESSAGE to statusMessage, Field.HEADERS to headers.toSnapshotMap().takeIf { it.isNotEmpty() }, Field.BODY to body, Field.BODY_FILE_NAME to bodyFileName, Field.FAULT to fault?.name, Field.FIXED_DELAY_MS to fixedDelayMilliseconds, Field.TRANSFORMERS to transformers?.takeIf { it.isNotEmpty() } ) private fun LoggedResponse?.toSnapshotMap(): Map = this?.let { response -> snapshotMap( Field.STATUS to response.status, Field.HEADERS to response.headers.toSnapshotMap().takeIf { it.isNotEmpty() }, Field.BODY to response.bodyAsString, Field.MIME_TYPE to response.mimeType, Field.FAULT to response.fault?.name ) } ?: emptyMap() private fun HttpHeaders?.toSnapshotMap(): Map> = this?.all() ?.associate { header -> header.key() to header.values() } ?: emptyMap() private fun RequestPattern.displayUrl(): String = url ?: urlPath ?: urlPattern ?: urlPathPattern ?: urlPathTemplate ?: urlMatcher?.toString() ?: Display.CUSTOM_MATCHER private fun snapshotMap(vararg entries: Pair): Map = entries.mapNotNull { (key, value) -> value?.let { key to it } }.toMap() ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockSystem.kt ================================================ @file:Suppress("unused") package com.trendyol.stove.wiremock import arrow.core.* import com.github.benmanes.caffeine.cache.* import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.* import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import com.github.tomakehurst.wiremock.extension.Extension import com.github.tomakehurst.wiremock.http.RequestMethod import com.github.tomakehurst.wiremock.matching.* import com.github.tomakehurst.wiremock.stubbing.* import com.github.tomakehurst.wiremock.verification.LoggedRequest import com.trendyol.stove.functional.* import com.trendyol.stove.reporting.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.tracing.TraceContext import com.trendyol.stove.wiremock.WireMockHeaders.APPLICATION_JSON import com.trendyol.stove.wiremock.WireMockHeaders.APPLICATION_JSON_UTF8 import com.trendyol.stove.wiremock.WireMockHeaders.CONTENT_TYPE import com.trendyol.stove.wiremock.WireMockReportActions.VALIDATE_ALL_REQUESTS_MATCHED import com.trendyol.stove.wiremock.WireMockReportActions.VALIDATE_ALL_REQUESTS_SHOULD_MATCH import com.trendyol.stove.wiremock.WireMockReportMetadataKeys.RESPONSE_HEADERS import com.trendyol.stove.wiremock.WireMockReportMetadataKeys.STATUS_CODE import kotlinx.coroutines.runBlocking import wiremock.org.slf4j.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue /** * Callback invoked after a stub is removed (when `removeStubAfterRequestMatched` is enabled). */ typealias AfterStubRemoved = (ServeEvent, Cache) -> Unit /** * Callback invoked after a request is handled by WireMock. */ typealias AfterRequestHandler = (ServeEvent, Cache) -> Unit /** * WireMock HTTP mocking system for testing external service integrations. * * WireMock allows you to mock external HTTP services that your application depends on, * enabling isolated testing without actual network calls. * * ## Mocking GET Requests * * ```kotlin * wiremock { * // Simple GET mock * mockGet( * url = "/api/users/123", * statusCode = 200, * responseBody = User(id = "123", name = "John").some() * ) * * // GET with custom headers * mockGet( * url = "/api/users/123", * statusCode = 200, * responseBody = user.some(), * responseHeaders = mapOf( * "Content-Type" to "application/json", * "X-Custom-Header" to "value" * ) * ) * } * ``` * * ## Mocking POST Requests * * ```kotlin * wiremock { * // POST with request and response bodies * mockPost( * url = "/api/payments", * statusCode = 200, * requestBody = PaymentRequest(amount = 99.99).some(), * responseBody = PaymentResponse(transactionId = "txn-123").some() * ) * * // POST returning error * mockPost( * url = "/api/payments", * statusCode = 400, * responseBody = ErrorResponse(code = "INVALID_AMOUNT").some() * ) * } * ``` * * ## Mocking PUT, DELETE, PATCH * * ```kotlin * wiremock { * mockPut( * url = "/api/users/123", * statusCode = 200, * requestBody = UpdateUserRequest(name = "Jane").some(), * responseBody = User(id = "123", name = "Jane").some() * ) * * mockDelete( * url = "/api/users/123", * statusCode = 204 * ) * * mockPatch( * url = "/api/users/123", * statusCode = 200, * requestBody = mapOf("status" to "active").some(), * responseBody = User(id = "123", status = "active").some() * ) * } * ``` * * ## Verifying Requests * * ```kotlin * wiremock { * // Verify a request was made exactly once * shouldHaveBeenCalled(RequestMethod.GET, "/api/users/123") * * // Verify request count * shouldHaveBeenCalled(exactly(2)) { * postRequestedFor(urlEqualTo("/api/payments")) * } * * // Verify with request body * shouldHaveBeenCalled { * postRequestedFor(urlEqualTo("/api/users")) * .withRequestBody(matchingJsonPath("$.name", equalTo("John"))) * } * } * ``` * * ## Test Workflow Example * * ```kotlin * test("should process payment via external gateway") { * stove { * // Mock external payment gateway * wiremock { * mockPost( * url = "/gateway/charge", * statusCode = 200, * responseBody = GatewayResponse(success = true, txnId = "123").some() * ) * } * * // Make request to our application (which calls the gateway) * http { * postAndExpectJson( * uri = "/orders", * body = CreateOrderRequest(amount = 99.99).some() * ) { order -> * order.status shouldBe "PAID" * order.transactionId shouldBe "123" * } * } * * // Verify the gateway was called * wiremock { * shouldHaveBeenCalled(RequestMethod.POST, "/gateway/charge") * } * } * } * ``` * * ## Configuration * * ```kotlin * Stove() * .with { * wiremock { * WireMockSystemOptions( * port = 9090, * removeStubAfterRequestMatched = true, // Clean stubs after use * afterRequest = { event, _ -> * println("Request: ${event.request}") * } * ) * } * } * ``` * * @property stove The parent test system. * @see WireMockSystemOptions */ @WiremockDsl @Suppress("LargeClass", "TooManyFunctions") class WireMockSystem( override val stove: Stove, private val ctx: WireMockContext ) : PluggedSystem, ValidatedSystem, RunAware, ExposesConfiguration, Reports { override val reportSystemName: String = WireMockReportSystem.name(ctx.keyName) private val stubLog: Cache = Caffeine.newBuilder().build() private val callJournal = WireMockCallJournal() private val serde: StoveSerde = ctx.serde private val verification = WireMockVerification(this, callJournal, serde) private val completedTestIds = ConcurrentLinkedQueue() private val reportListener = object : ReportEventListener { override fun onTestStarted(ctx: StoveTestContext) { clearCompletedTestJournals() callJournal.clear(ctx.testId) } override fun onTestEnded(testId: String) { completedTestIds.add(testId) } } private var reportListenerRegistered = false private lateinit var exposedConfiguration: WireMockExposedConfiguration override fun configuration(): List = ctx.configureExposedConfiguration(exposedConfiguration) override fun snapshot(): SystemSnapshot = WireMockSnapshotBuilder(reportSystemName, callJournal, wireMock.stubMappings) .build(reporter.currentTestId()) private var wireMock: WireMockServer private val logger: Logger = LoggerFactory.getLogger(javaClass) init { val cfg = wireMockConfig() .port(ctx.port) .extensions(WireMockRequestListener(stubLog, ctx.afterRequest, callJournal::record)) val stoveExtensions = mutableListOf() if (ctx.removeStubAfterRequestMatched) { stoveExtensions.add(WireMockVacuumCleaner(stubLog, ctx.afterStubRemoved)) } stoveExtensions.map { cfg.extensions(it) } wireMock = WireMockServer(cfg.let(ctx.configure)) stoveExtensions.filterIsInstance().forEach { it.wireMock(wireMock) } } /** * Starts the WireMock server. */ override suspend fun run() { if (!reportListenerRegistered) { stove.addReportListener(reportListener) reportListenerRegistered = true } wireMock.start() exposedConfiguration = WireMockExposedConfiguration( host = LOCALHOST, port = wireMock.port() ) } /** * Stops the WireMock server. */ override suspend fun stop(): Unit = wireMock.shutdownServer() /** * Mocks a GET request with exact URL matching. * * @param url The exact URL to match. * @param statusCode The HTTP status code to return. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @return This [WireMockSystem] for chaining. */ suspend fun mockGet( url: String, statusCode: Int, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf() ): WireMockSystem = mockRequest( methodName = RequestMethod.GET.value(), url = url, method = ::get, statusCode = statusCode, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders, reportMetadata = mapOf(STATUS_CODE to statusCode, RESPONSE_HEADERS to responseHeaders) ) /** * Mocks a POST request with exact URL and request body matching. * * The request body must match exactly (ignoring field order but not extra fields). * For partial body matching, use [mockPostContaining] instead. * * @param url The exact URL to match. * @param statusCode The HTTP status code to return. * @param requestBody Optional request body to match exactly. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @return This [WireMockSystem] for chaining. * @see mockPostContaining */ suspend fun mockPost( url: String, statusCode: Int, requestBody: Option = None, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf() ): WireMockSystem = mockRequest( methodName = RequestMethod.POST.value(), url = url, method = ::post, statusCode = statusCode, requestBody = requestBody, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders ) /** * Mocks a PUT request with exact URL and request body matching. * * The request body must match exactly (ignoring field order but not extra fields). * For partial body matching, use [mockPutContaining] instead. * * @param url The exact URL to match. * @param statusCode The HTTP status code to return. * @param requestBody Optional request body to match exactly. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @return This [WireMockSystem] for chaining. * @see mockPutContaining */ suspend fun mockPut( url: String, statusCode: Int, requestBody: Option = None, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf() ): WireMockSystem = mockRequest( methodName = RequestMethod.PUT.value(), url = url, method = ::put, statusCode = statusCode, requestBody = requestBody, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders ) /** * Mocks a PATCH request with exact URL and request body matching. * * The request body must match exactly (ignoring field order but not extra fields). * For partial body matching, use [mockPatchContaining] instead. * * @param url The exact URL to match. * @param statusCode The HTTP status code to return. * @param requestBody Optional request body to match exactly. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @return This [WireMockSystem] for chaining. * @see mockPatchContaining */ suspend fun mockPatch( url: String, statusCode: Int, requestBody: Option = None, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf() ): WireMockSystem = mockRequest( methodName = RequestMethod.PATCH.value(), url = url, method = ::patch, statusCode = statusCode, requestBody = requestBody, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders ) /** * Mocks a DELETE request with exact URL matching. * * @param url The exact URL to match. * @param statusCode The HTTP status code to return. * @param metadata Optional metadata to attach to the stub. * @return This [WireMockSystem] for chaining. */ suspend fun mockDelete( url: String, statusCode: Int, metadata: Map = mapOf() ): WireMockSystem = mockRequest( methodName = RequestMethod.DELETE.value(), url = url, method = ::delete, statusCode = statusCode, metadata = metadata ) /** * Mocks a HEAD request with exact URL matching. * * @param url The exact URL to match. * @param statusCode The HTTP status code to return. * @param metadata Optional metadata to attach to the stub. * @return This [WireMockSystem] for chaining. */ suspend fun mockHead( url: String, statusCode: Int, metadata: Map = mapOf() ): WireMockSystem = mockRequest( methodName = RequestMethod.HEAD.value(), url = url, method = ::head, statusCode = statusCode, metadata = metadata ) /** * Mocks a PUT request with full configuration control. * * This method provides access to the underlying WireMock [MappingBuilder] for advanced * configuration scenarios like custom matchers, headers, or response transformers. * * @param url The URL or URL pattern to match. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * Use `{ urlPathMatching(it) }` for regex patterns. * @param configure Lambda to configure the request and response using WireMock's API. * @return This [WireMockSystem] for chaining. */ suspend fun mockPutConfigure( url: String, urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) }, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = mockRequestConfigure(RequestMethod.PUT.value(), url, urlPatternFn, ::put, configure) /** * Mocks a PATCH request with full configuration control. * * This method provides access to the underlying WireMock [MappingBuilder] for advanced * configuration scenarios like custom matchers, headers, or response transformers. * * @param url The URL or URL pattern to match. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * Use `{ urlPathMatching(it) }` for regex patterns. * @param configure Lambda to configure the request and response using WireMock's API. * @return This [WireMockSystem] for chaining. */ suspend fun mockPatchConfigure( url: String, urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) }, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = mockRequestConfigure(RequestMethod.PATCH.value(), url, urlPatternFn, ::patch, configure) /** * Mocks a GET request with full configuration control. * * This method provides access to the underlying WireMock [MappingBuilder] for advanced * configuration scenarios like custom matchers, headers, or response transformers. * * @param url The URL or URL pattern to match. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * Use `{ urlPathMatching(it) }` for regex patterns. * @param configure Lambda to configure the request and response using WireMock's API. * @return This [WireMockSystem] for chaining. */ suspend fun mockGetConfigure( url: String, urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) }, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = mockRequestConfigure(RequestMethod.GET.value(), url, urlPatternFn, ::get, configure) /** * Mocks a HEAD request with full configuration control. * * This method provides access to the underlying WireMock [MappingBuilder] for advanced * configuration scenarios like custom matchers, headers, or response transformers. * * @param url The URL or URL pattern to match. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * Use `{ urlPathMatching(it) }` for regex patterns. * @param configure Lambda to configure the request and response using WireMock's API. * @return This [WireMockSystem] for chaining. */ suspend fun mockHeadConfigure( url: String, urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) }, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = mockRequestConfigure(RequestMethod.HEAD.value(), url, urlPatternFn, ::head, configure) /** * Mocks a DELETE request with full configuration control. * * This method provides access to the underlying WireMock [MappingBuilder] for advanced * configuration scenarios like custom matchers, headers, or response transformers. * * @param url The URL or URL pattern to match. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * Use `{ urlPathMatching(it) }` for regex patterns. * @param configure Lambda to configure the request and response using WireMock's API. * @return This [WireMockSystem] for chaining. */ suspend fun mockDeleteConfigure( url: String, urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) }, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = mockRequestConfigure(RequestMethod.DELETE.value(), url, urlPatternFn, ::delete, configure) /** * Mocks a POST request with full configuration control. * * This method provides access to the underlying WireMock [MappingBuilder] for advanced * configuration scenarios like custom matchers, headers, or response transformers. * * @param url The URL or URL pattern to match. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * Use `{ urlPathMatching(it) }` for regex patterns. * @param configure Lambda to configure the request and response using WireMock's API. * @return This [WireMockSystem] for chaining. */ suspend fun mockPostConfigure( url: String, urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) }, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = mockRequestConfigure(RequestMethod.POST.value(), url, urlPatternFn, ::post, configure) /** * Configures stateful stub behavior for scenario-based testing. * * Use this method when you need different responses for the same URL based on * the order of requests (e.g., first call returns success, second returns error). * * ## Example * * ```kotlin * wiremock { * behaviourFor("/api/resource", ::post) { serde -> * initially { * aResponse().withStatus(200).withBody("first response") * } * then { * aResponse().withStatus(500).withBody("server error") * } * } * } * ``` * * @param url The URL to match. * @param method Function to create the HTTP method matcher (e.g., `::post`, `::get`). * @param block Lambda to define the sequence of responses. */ suspend fun behaviourFor( url: String, method: (String) -> MappingBuilder, block: StubBehaviourBuilder.(StoveSerde) -> Unit ) { report(action = WireMockReportActions.registerBehaviourStub(url)) { stubBehaviour( wireMockServer = wireMock, serde = serde, url = url, method = method, metadata = enrichMetadataWithTestId(emptyMap()), recordStub = ::recordStub, block = block ) } } /** * Mocks a POST request with partial body matching. * * Unlike [mockPost], this method allows matching requests where the body * **contains** the specified fields, without requiring an exact match of * the entire request body. This is useful when you only care about specific * fields in the request for test matching purposes. * * ## Features * - **AND logic**: When multiple fields are specified, ALL must match * - **Dot notation**: Use `"order.customer.id"` to match deep nested keys * - **Partial object matching**: Nested objects match if they contain at least the specified fields * - **Multiple fields**: Specify multiple keys to match several fields in one mock * * ## Examples * * ```kotlin * // Match a top-level field * wiremock { * mockPostContaining( * url = "/orders", * requestContaining = mapOf("productId" to 123), * responseBody = OrderResponse(id = "order-1").some() * ) * } * * // Match a deeply nested field using dot notation * wiremock { * mockPostContaining( * url = "/orders", * requestContaining = mapOf("order.customer.id" to "cust-123"), * responseBody = OrderResponse(id = "order-1").some() * ) * } * * // Match multiple fields at different depths * wiremock { * mockPostContaining( * url = "/orders", * requestContaining = mapOf( * "order.customer.id" to "cust-123", * "order.payment.method" to "credit_card" * ), * responseBody = OrderResponse(id = "order-1").some() * ) * } * ``` * * @param url The URL to match. * @param requestContaining Map of field paths to values. Supports dot notation for nested paths (e.g., "order.customer.id"). * @param statusCode The HTTP status code to return. Defaults to 200. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * @return This [WireMockSystem] for chaining. */ suspend fun mockPostContaining( url: String, requestContaining: Map, statusCode: Int = 200, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = mockRequestContaining( url = url, method = ::post, requestContaining = requestContaining, statusCode = statusCode, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders, urlPatternFn = urlPatternFn ) /** * Mocks a PUT request with partial body matching. * * Unlike [mockPut], this method allows matching requests where the body * **contains** the specified fields, without requiring an exact match of * the entire request body. This is useful when you only care about specific * fields in the request for test matching purposes. * * ## Features * - **AND logic**: When multiple fields are specified, ALL must match * - **Dot notation**: Use `"user.profile.settings.theme"` to match deep nested keys * - **Partial object matching**: Nested objects match if they contain at least the specified fields * - **Multiple fields**: Specify multiple keys to match several fields in one mock * * ## Examples * * ```kotlin * // Match a top-level field * wiremock { * mockPutContaining( * url = "/users/123", * requestContaining = mapOf("userId" to "user-123"), * responseBody = User(id = "123", name = "Updated").some() * ) * } * * // Match a deeply nested field using dot notation * wiremock { * mockPutContaining( * url = "/users/123", * requestContaining = mapOf("user.profile.settings.theme" to "dark"), * responseBody = User(id = "123").some() * ) * } * ``` * * @param url The URL to match. * @param requestContaining Map of field paths to values. Supports dot notation for nested paths (e.g., "user.profile.id"). * @param statusCode The HTTP status code to return. Defaults to 200. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * @return This [WireMockSystem] for chaining. */ suspend fun mockPutContaining( url: String, requestContaining: Map, statusCode: Int = 200, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = mockRequestContaining( url = url, method = ::put, requestContaining = requestContaining, statusCode = statusCode, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders, urlPatternFn = urlPatternFn ) /** * Mocks a PATCH request with partial body matching. * * Unlike [mockPatch], this method allows matching requests where the body * **contains** the specified fields, without requiring an exact match of * the entire request body. This is useful when you only care about specific * fields in the request for test matching purposes. * * ## Features * - **AND logic**: When multiple fields are specified, ALL must match * - **Dot notation**: Use `"document.section.text"` to match deep nested keys * - **Partial object matching**: Nested objects match if they contain at least the specified fields * - **Multiple fields**: Specify multiple keys to match several fields in one mock * * ## Examples * * ```kotlin * // Match a top-level field * wiremock { * mockPatchContaining( * url = "/users/123", * requestContaining = mapOf("status" to "active"), * responseBody = User(id = "123", status = "active").some() * ) * } * * // Match a deeply nested field using dot notation * wiremock { * mockPatchContaining( * url = "/documents/123", * requestContaining = mapOf("document.section.paragraph.text" to "updated"), * responseBody = Document(id = "123").some() * ) * } * ``` * * @param url The URL to match. * @param requestContaining Map of field paths to values. Supports dot notation for nested paths (e.g., "config.settings.enabled"). * @param statusCode The HTTP status code to return. Defaults to 200. * @param responseBody Optional response body to return. * @param metadata Optional metadata to attach to the stub. * @param responseHeaders Optional response headers. * @param urlPatternFn Function to create URL pattern. Defaults to exact URL matching. * @return This [WireMockSystem] for chaining. */ suspend fun mockPatchContaining( url: String, requestContaining: Map, statusCode: Int = 200, responseBody: Option = None, metadata: Map = mapOf(), responseHeaders: Map = mapOf(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = mockRequestContaining( url = url, method = ::patch, requestContaining = requestContaining, statusCode = statusCode, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders, urlPatternFn = urlPatternFn ) /** * Verifies that a request matching the provided criteria has been called. * * By default, the request must have been called exactly once. Use WireMock's count helpers * such as [moreThanOrExactly], [lessThan], or [exactly] to customize the expected count. */ suspend fun shouldHaveBeenCalled( method: RequestMethod, url: String, count: CountMatchingStrategy = exactly(1), requestBody: Option = None, requestContaining: Map = emptyMap(), headers: Map = emptyMap(), queryParams: Map = emptyMap(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = verification.shouldHaveBeenCalled( method = method, url = url, count = count, requestBody = requestBody, requestContaining = requestContaining, headers = headers, queryParams = queryParams, urlPatternFn = urlPatternFn ) /** * Verifies that a request matching the provided WireMock pattern has been called. * * By default, the request must have been called exactly once. */ suspend fun shouldHaveBeenCalled( count: CountMatchingStrategy = exactly(1), request: @WiremockDsl () -> RequestPatternBuilder ): WireMockSystem = verification.shouldHaveBeenCalled(count, request) /** * Verifies that no request matching the provided criteria has been called. */ suspend fun shouldNotHaveBeenCalled( method: RequestMethod, url: String, requestBody: Option = None, requestContaining: Map = emptyMap(), headers: Map = emptyMap(), queryParams: Map = emptyMap(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = verification.shouldNotHaveBeenCalled( method = method, url = url, requestBody = requestBody, requestContaining = requestContaining, headers = headers, queryParams = queryParams, urlPatternFn = urlPatternFn ) /** * Verifies that no request matching the provided WireMock pattern has been called. */ suspend fun shouldNotHaveBeenCalled( request: @WiremockDsl () -> RequestPatternBuilder ): WireMockSystem = verification.shouldNotHaveBeenCalled(request) /** * Returns requests from the current test matching the provided criteria. */ fun callsFor( method: RequestMethod, url: String, requestBody: Option = None, requestContaining: Map = emptyMap(), headers: Map = emptyMap(), queryParams: Map = emptyMap(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): List = verification.callsFor( method = method, url = url, requestBody = requestBody, requestContaining = requestContaining, headers = headers, queryParams = queryParams, urlPatternFn = urlPatternFn ) /** * Returns requests from the current test matching the provided WireMock pattern. */ fun callsFor( request: @WiremockDsl () -> RequestPatternBuilder ): List = verification.callsFor(request) private suspend fun mockRequest( methodName: String, url: String, method: (UrlPattern) -> MappingBuilder, statusCode: Int, requestBody: Option = None, responseBody: Option = None, metadata: Map = emptyMap(), responseHeaders: Map = emptyMap(), reportMetadata: Map = mapOf(STATUS_CODE to statusCode) ): WireMockSystem = mockRequest( action = WireMockReportActions.registerStub(methodName, url), request = method(urlEqualTo(url)), statusCode = statusCode, requestBody = requestBody, responseBody = responseBody, metadata = metadata, responseHeaders = responseHeaders, reportMetadata = reportMetadata ) private suspend fun mockRequest( action: String, request: MappingBuilder, statusCode: Int, requestBody: Option = None, responseBody: Option = None, metadata: Map = emptyMap(), responseHeaders: Map = emptyMap(), reportMetadata: Map = mapOf(STATUS_CODE to statusCode) ): WireMockSystem = registerStub( action = action, input = requestBody, metadata = reportMetadata ) { configureBodyAndMetadata(request, metadata, requestBody) request.willReturn(configureResponse(statusCode, responseBody, responseHeaders)) } private suspend fun mockRequestConfigure( methodName: String, url: String, urlPatternFn: (url: String) -> UrlPattern, method: (UrlPattern) -> MappingBuilder, configure: (MappingBuilder, StoveSerde) -> MappingBuilder ): WireMockSystem = registerStub(action = WireMockReportActions.registerCustomStub(methodName, url)) { val configuredRequest = configure(method(urlPatternFn(url)), serde) configuredRequest.withMetadata(enrichMetadataWithTestId(emptyMap())) configuredRequest } private suspend fun mockRequestContaining( url: String, method: (UrlPattern) -> MappingBuilder, requestContaining: Map, statusCode: Int, responseBody: Option, metadata: Map, responseHeaders: Map, urlPatternFn: (url: String) -> UrlPattern ): WireMockSystem { require(requestContaining.isNotEmpty()) { WireMockValidationMessages.REQUEST_CONTAINING_EMPTY } return registerStub( action = WireMockReportActions.registerPartialStub(url), input = requestContaining.some(), metadata = mapOf(STATUS_CODE to statusCode) ) { val mockRequest = method(urlPatternFn(url)) mockRequest.withMetadata(enrichMetadataWithTestId(metadata)) mockRequest.withHeader(CONTENT_TYPE, ContainsPattern(APPLICATION_JSON)) mockRequest.configureBodyContaining(requestContaining, serde) val mockResponse = configureResponse(statusCode, responseBody, responseHeaders) mockRequest.willReturn(mockResponse) } } /** * Validates that all registered stubs were matched by incoming requests. * * If any requests were received that didn't match a stub, this method throws * an [AssertionError] with details about the unmatched requests. * * This is typically called at the end of a test to ensure all expected * external service calls were properly mocked. * * @throws AssertionError if there are unmatched requests. */ override suspend fun validate() { val currentTestId = reporter.currentTestId() // Filter unmatched requests to only include those from the current test // by checking the X-Stove-Test-Id header val unmatched = wireMock.findAllUnmatchedRequests().filter { req -> req.getHeader(TraceContext.STOVE_TEST_ID_HEADER) == currentTestId } val passed = unmatched.isEmpty() if (!passed) { val problems = unmatched.joinToString("\n") { WireMockValidationMessages.unmatchedRequestDetails( url = "${it.method.value()} ${it.url}", bodyAsString = it.bodyAsString, queryParams = serde.serialize(it.queryParams).decodeToString() ) } val error = AssertionError( WireMockValidationMessages.unmatchedRequests(problems) ) reporter.record( ReportEntry.failure( system = reportSystemName, testId = reporter.currentTestId(), action = VALIDATE_ALL_REQUESTS_SHOULD_MATCH, error = error.message ?: WireMockValidationMessages.VALIDATION_FAILED, expected = WireMockValidationMessages.EXPECTED_NO_UNMATCHED_REQUESTS.some(), actual = WireMockValidationMessages.unmatchedRequestCount(unmatched.size).some() ) ) throw error } else { reporter.record( ReportEntry.success( system = reportSystemName, testId = reporter.currentTestId(), action = VALIDATE_ALL_REQUESTS_MATCHED ) ) } } /** * Closes the WireMock system and stops the server. */ override fun close(): Unit = runBlocking { Try { if (reportListenerRegistered) { stove.removeReportListener(reportListener) reportListenerRegistered = false } stop() callJournal.clearAll() }.recover { logger.warn("${WireMockValidationMessages.STOP_FAILED_PREFIX} ${it.message}") } } private fun clearCompletedTestJournals() { while (true) { val testId = completedTestIds.poll() ?: return callJournal.clear(testId) } } private suspend fun registerStub( action: String, input: Option = None, metadata: Map = emptyMap(), request: () -> MappingBuilder ): WireMockSystem { report(action = action, input = input, metadata = metadata) { registerStub(request()) } return this } private fun registerStub(request: MappingBuilder) { val stub = wireMock.stubFor(request.withId(UUID.randomUUID())) recordStub(stub) } private fun recordStub(stub: StubMapping) { stubLog.put(stub.id, stub) callJournal.recordStub(stub) } private fun enrichMetadataWithTestId(metadata: Map): Map = metadata + (STOVE_TEST_ID_KEY to reporter.currentTestId()) private fun configureBodyAndMetadata( request: MappingBuilder, metadata: Map, body: Option ) { request.withMetadata(enrichMetadataWithTestId(metadata)) body.map { request .withRequestBody( equalToJson( serde.serialize(it).decodeToString(), true, false ) ).withHeader(CONTENT_TYPE, ContainsPattern(APPLICATION_JSON)) } } private fun configureResponse( statusCode: Int, responseBody: Option, responseHeaders: Map ): ResponseDefinitionBuilder? { val mockResponse = aResponse() .withStatus(statusCode) .withHeader(CONTENT_TYPE, APPLICATION_JSON_UTF8) responseHeaders.forEach { mockResponse.withHeader(it.key, it.value) } responseBody.map { mockResponse.withBody(serde.serialize(it)) } return mockResponse } companion object { /** * Metadata key used to associate stubs with test IDs for filtering in snapshots. */ const val STOVE_TEST_ID_KEY = "stoveTestId" private const val LOCALHOST = "localhost" /** * Exposes the [WireMockServer] instance for the given [WireMockSystem]. * Use this for advanced WireMock operations not covered by the DSL. */ @Suppress("unused") fun WireMockSystem.server(): WireMockServer = wireMock } } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockVacuumCleaner.kt ================================================ package com.trendyol.stove.wiremock import com.github.benmanes.caffeine.cache.Cache import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.extension.* import com.github.tomakehurst.wiremock.stubbing.* import com.trendyol.stove.functional.* import wiremock.org.slf4j.* import java.util.* class WireMockVacuumCleaner( private val stubLog: Cache, private val afterStubRemoved: AfterStubRemoved ) : ServeEventListener { private lateinit var wireMock: WireMockServer private val logger: Logger = LoggerFactory.getLogger(javaClass) override fun getName(): String = WireMockExtensionNames.VACUUM_CLEANER fun wireMock(wireMockServer: WireMockServer) { this.wireMock = wireMockServer } override fun beforeResponseSent( serveEvent: ServeEvent, parameters: Parameters? ) { if (!serveEvent.wasMatched) { return } if (!stubLog.containsKey(serveEvent.stubMapping.id)) { return } Try { synchronized(wireMock) { val stubToBeRemoved = stubLog.getIfPresent(serveEvent.stubMapping.id) wireMock.removeStub(stubToBeRemoved) wireMock.removeServeEvent(serveEvent.id) stubLog.invalidate(serveEvent.stubMapping.id) afterStubRemoved(serveEvent, stubLog) } }.recover { throwable -> logger.warn(throwable.message) } } } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WireMockVerification.kt ================================================ package com.trendyol.stove.wiremock import arrow.core.* import com.github.tomakehurst.wiremock.client.* import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.http.RequestMethod import com.github.tomakehurst.wiremock.matching.* import com.github.tomakehurst.wiremock.verification.LoggedRequest import com.trendyol.stove.serialization.StoveSerde internal class WireMockVerification( private val system: WireMockSystem, private val callJournal: WireMockCallJournal, private val serde: StoveSerde ) { suspend fun shouldHaveBeenCalled( method: RequestMethod, url: String, count: CountMatchingStrategy = exactly(1), requestBody: Option = None, requestContaining: Map = emptyMap(), headers: Map = emptyMap(), queryParams: Map = emptyMap(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = shouldHaveBeenCalled(count) { requestPattern( method = method, url = url, requestBody = requestBody, requestContaining = requestContaining, headers = headers, queryParams = queryParams, urlPatternFn = urlPatternFn ) } suspend fun shouldHaveBeenCalled( count: CountMatchingStrategy = exactly(1), request: @WiremockDsl () -> RequestPatternBuilder ): WireMockSystem = verifyCalls( action = WireMockReportActions.VERIFY_REQUEST_WAS_CALLED, count = count, requestPattern = request().build() ) suspend fun shouldNotHaveBeenCalled( method: RequestMethod, url: String, requestBody: Option = None, requestContaining: Map = emptyMap(), headers: Map = emptyMap(), queryParams: Map = emptyMap(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): WireMockSystem = shouldHaveBeenCalled( method = method, url = url, count = exactly(0), requestBody = requestBody, requestContaining = requestContaining, headers = headers, queryParams = queryParams, urlPatternFn = urlPatternFn ) suspend fun shouldNotHaveBeenCalled( request: @WiremockDsl () -> RequestPatternBuilder ): WireMockSystem = verifyCalls( action = WireMockReportActions.VERIFY_REQUEST_WAS_NOT_CALLED, count = exactly(0), requestPattern = request().build() ) fun callsFor( method: RequestMethod, url: String, requestBody: Option = None, requestContaining: Map = emptyMap(), headers: Map = emptyMap(), queryParams: Map = emptyMap(), urlPatternFn: (url: String) -> UrlPattern = { urlEqualTo(it) } ): List = callsFor( requestPattern( method = method, url = url, requestBody = requestBody, requestContaining = requestContaining, headers = headers, queryParams = queryParams, urlPatternFn = urlPatternFn ).build() ) fun callsFor( request: @WiremockDsl () -> RequestPatternBuilder ): List = callsFor(request().build()) private suspend fun verifyCalls( action: String, count: CountMatchingStrategy, requestPattern: RequestPattern ): WireMockSystem { val actualCount = callsFor(requestPattern).size system.report( action = action, input = requestPattern.toString().some(), expected = count.toString().some(), actual = WireMockValidationMessages.requestCount(actualCount).some() ) { if (!count.match(actualCount)) { throw VerificationException(requestPattern, count, actualCount) } } return system } private fun callsFor(requestPattern: RequestPattern): List = callJournal.requests(system.reporter.currentTestId()) .filter { request -> requestPattern.match(request).isExactMatch } private fun requestPattern( method: RequestMethod, url: String, requestBody: Option, requestContaining: Map, headers: Map, queryParams: Map, urlPatternFn: (url: String) -> UrlPattern ): RequestPatternBuilder { val request = RequestPatternBuilder.newRequestPattern(method, urlPatternFn(url)) requestBody.map { request.withRequestBody( equalToJson( serde.serialize(it).decodeToString(), true, false ) ) } request.configureBodyContaining(requestContaining, serde) headers.forEach { (key, value) -> request.withHeader(key, equalTo(value)) } queryParams.forEach { (key, value) -> request.withQueryParam(key, equalTo(value)) } return request } } ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/WiremockDsl.kt ================================================ package com.trendyol.stove.wiremock @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class WiremockDsl ================================================ FILE: lib/stove-wiremock/src/main/kotlin/com/trendyol/stove/wiremock/stubbing.kt ================================================ package com.trendyol.stove.wiremock import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.* import com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED import com.github.tomakehurst.wiremock.stubbing.StubMapping import com.trendyol.stove.serialization.StoveSerde internal fun stubBehaviour( wireMockServer: WireMockServer, serde: StoveSerde, url: String, method: (String) -> MappingBuilder, metadata: Map = emptyMap(), recordStub: (StubMapping) -> Unit = {}, block: StubBehaviourBuilder.(StoveSerde) -> Unit ) { val builder = StubBehaviourBuilder(wireMockServer, url, method, metadata, recordStub) builder.block(serde) } class StubBehaviourBuilder( private val wireMockServer: WireMockServer, private val url: String, private val method: (String) -> MappingBuilder, private val metadata: Map = emptyMap() ) { private val scenarioName = WireMockBehaviourNames.scenarioName(url) private var previousState: String = STARTED private var stateCounter = 0 private var initializedCounter = 0 private var recordStub: (StubMapping) -> Unit = {} internal constructor( wireMockServer: WireMockServer, url: String, method: (String) -> MappingBuilder, metadata: Map = emptyMap(), recordStub: (StubMapping) -> Unit ) : this(wireMockServer, url, method, metadata) { this.recordStub = recordStub } fun initially(step: () -> ResponseDefinitionBuilder) { check(initializedCounter == 0) { WireMockBehaviourMessages.INITIALLY_ONCE } stateCounter++ val nextState = WireMockBehaviourNames.state(stateCounter) createStub(step(), previousState, nextState) previousState = nextState initializedCounter++ } fun then(step: () -> ResponseDefinitionBuilder) { check(previousState != STARTED) { WireMockBehaviourMessages.INITIALLY_BEFORE_THEN } stateCounter++ val nextState = WireMockBehaviourNames.state(stateCounter) createStub(step(), previousState, nextState) previousState = nextState } private fun createStub( response: ResponseDefinitionBuilder, whenState: String, setState: String ) { val stub = wireMockServer.stubFor( method(url) .inScenario(scenarioName) .whenScenarioStateIs(whenState) .willReturn(response) .willSetStateTo(setState) .withMetadata(metadata) ) recordStub(stub) } } ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/ExtensionsTest.kt ================================================ package com.trendyol.stove.wiremock import com.github.benmanes.caffeine.cache.Caffeine import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class ExtensionsTest : FunSpec({ test("containsKey should reflect cache contents") { val cache = Caffeine.newBuilder().build() cache.containsKey("missing") shouldBe false cache.put("key", "value") cache.containsKey("key") shouldBe true } }) ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/StoveConfig.kt ================================================ package com.trendyol.stove.wiremock import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.PortFinder import com.trendyol.stove.system.Stove import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension val WIREMOCK_PORT = PortFinder.findAvailablePort() val WIREMOCK_BASE_URL = "http://localhost:$WIREMOCK_PORT" class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { wiremock { WireMockSystemOptions( port = WIREMOCK_PORT, removeStubAfterRequestMatched = true ) } applicationUnderTest( object : ApplicationUnderTest { override suspend fun start(configurations: List) = Unit override suspend fun stop() = Unit } ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/WireMockDeletionTest.kt ================================================ package com.trendyol.stove.wiremock import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.matching.ContainsPattern import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import java.net.URI import java.net.http.* import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers class WireMockDeletionTest : FunSpec({ /* * Check [WireMockContext.removeStubAfterRequestMatched] */ test("Remove stub from wiremock when request is matched") { val reqBody = "{\"req\": 1}" val responseBody = "{\"res\": 1}" stove { wiremock { mockPostConfigure("/post-url") { req, _ -> req .withRequestBody(equalToJson(reqBody)) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody(responseBody) .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val client = HttpClient.newBuilder().build() val reqBuilder = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL/post-url")) .header("Content-Type", "application/json") val request = reqBuilder.POST(BodyPublishers.ofString(reqBody)).build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe responseBody val request2 = reqBuilder.POST(BodyPublishers.ofString(reqBody)).build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.statusCode() shouldBe 404 } /* * Check [WireMockContext.removeStubAfterRequestMatched] */ test("Removes the stub after request completes, and can be added again") { val reqBody = "{\"req\": 1}" val responseBody = "{\"res\": 1}" val url = "/post-url-2" stove { wiremock { mockPostConfigure(url) { req, _ -> req .withRequestBody(equalToJson(reqBody)) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody(responseBody) .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val client = HttpClient.newBuilder().build() val reqBuilder = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") val request = reqBuilder.POST(BodyPublishers.ofString(reqBody)).build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe responseBody stove { wiremock { mockPostConfigure(url) { req, _ -> req .withRequestBody(equalToJson(reqBody)) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody(responseBody) .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val request2 = reqBuilder.POST(BodyPublishers.ofString(reqBody)).build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.body() shouldBe responseBody } }) ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/WireMockExposedConfigurationTest.kt ================================================ package com.trendyol.stove.wiremock import io.kotest.core.spec.IsolationMode import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe /** * Tests for [WireMockExposedConfiguration] data class. * These tests are isolated and don't require a running Stove instance. */ class WireMockExposedConfigurationTest : FunSpec({ isolationMode = IsolationMode.InstancePerTest test("WireMockExposedConfiguration should have correct baseUrl format") { val config = WireMockExposedConfiguration(host = "localhost", port = 9090) config.baseUrl shouldBe "http://localhost:9090" } test("WireMockExposedConfiguration should handle different hosts") { val config = WireMockExposedConfiguration(host = "127.0.0.1", port = 8080) config.baseUrl shouldBe "http://127.0.0.1:8080" } test("WireMockExposedConfiguration should handle different ports") { val config = WireMockExposedConfiguration(host = "localhost", port = 0) config.baseUrl shouldBe "http://localhost:0" val config2 = WireMockExposedConfiguration(host = "localhost", port = 65535) config2.baseUrl shouldBe "http://localhost:65535" } test("WireMockSystemOptions default configureExposedConfiguration returns empty list") { val options = WireMockSystemOptions() val config = WireMockExposedConfiguration(host = "localhost", port = 9090) options.configureExposedConfiguration(config) shouldBe emptyList() } test("WireMockSystemOptions custom configureExposedConfiguration is called correctly") { val options = WireMockSystemOptions( port = 0, configureExposedConfiguration = { cfg -> listOf( "api.url=${cfg.baseUrl}", "api.port=${cfg.port}" ) } ) val config = WireMockExposedConfiguration(host = "localhost", port = 12345) val result = options.configureExposedConfiguration(config) result shouldBe listOf( "api.url=http://localhost:12345", "api.port=12345" ) } }) ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/WireMockOperationsTest.kt ================================================ package com.trendyol.stove.wiremock import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.matching.ContainsPattern import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import java.net.URI import java.net.http.* import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers class WireMockOperationsTest : FunSpec({ /* * Configures a POST request mock using [WireMockSystem.mockPostConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to use the default URL pattern for [WireMockSystem.mockPostConfigure]. */ test("Wiremock mockPostConfigure should mock urls with urlEqualTo(url) pattern in default") { val url = "/post-url" val client = HttpClient.newBuilder().build() val reqBuilder = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL/$url")) .header("Content-Type", "application/json") stove { wiremock { mockPostConfigure("/$url") { req, _ -> req .withRequestBody(equalTo("request2")) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("response2") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } mockPostConfigure("/$url") { req, _ -> req .withRequestBody(equalTo("request1")) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("response1") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val request2 = reqBuilder.POST(BodyPublishers.ofString("request2")).build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.body() shouldBe "response2" val request1 = reqBuilder.POST(BodyPublishers.ofString("request1")).build() val response1 = client.send(request1, BodyHandlers.ofString()) response1.body() shouldBe "response1" } /* * Configures a POST request mock using [WireMockSystem.mockPostConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to configure and override the default URL matcher for [WireMockSystem.mockPostConfigure]. */ test("Wiremock mockPostConfigure should accept overridden urlMatcher") { val url = "categories/createCategory" val client = HttpClient.newBuilder().build() val reqBuilder = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL/$url")) .header("Content-Type", "application/json") stove { wiremock { mockPostConfigure("/categories/.*", { urlPathMatching(it) }) { req, _ -> req .withRequestBody(equalTo("request2")) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("response2") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } mockPostConfigure("/categories/.*", { urlPathMatching(it) }) { req, _ -> req .withRequestBody(equalTo("request1")) .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("response1") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val request2 = reqBuilder.POST(BodyPublishers.ofString("request2")).build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.body() shouldBe "response2" val request1 = reqBuilder.POST(BodyPublishers.ofString("request1")).build() val response1 = client.send(request1, BodyHandlers.ofString()) response1.body() shouldBe "response1" } /* * Configures a POST request mock using [WireMockSystem.mockGetConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to use the default URL pattern for [WireMockSystem.mockGetConfigure]. */ test("Wiremock mockGetConfigure should mock urls with urlEqualTo(url) pattern in default") { val client = HttpClient.newBuilder().build() var id = 1 var active = true stove { wiremock { mockGetConfigure("/suppliers/1?active=true") { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("Supplier1Response") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } mockGetConfigure("/suppliers/2?active=false") { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("Supplier2Response") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/suppliers/$id?active=$active") val reqBuilder = HttpRequest .newBuilder(uri) .header("Content-Type", "application/json") val request2 = reqBuilder.GET().build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.body() shouldBe "Supplier1Response" id = 2 active = false val uri2 = URI.create("$WIREMOCK_BASE_URL/suppliers/$id?active=$active") val reqBuilder2 = HttpRequest .newBuilder(uri2) .header("Content-Type", "application/json") val request1 = reqBuilder2.GET().build() val response1 = client.send(request1, BodyHandlers.ofString()) response1.body() shouldBe "Supplier2Response" } /* * Configures a POST request mock using [WireMockSystem.mockGetConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to configure and override the default URL matcher for [WireMockSystem.mockGetConfigure]. */ test("Wiremock mockGetConfigure should accept overridden urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockGetConfigure("/suppliers/1.*", { urlPathMatching(it) }) { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .withQueryParam("active", matching("true|false")) .willReturn( aResponse() .withBody("Supplier1Response") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } mockGetConfigure("/suppliers/2.*", { urlPathMatching(it) }) { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .withQueryParam("active", matching("true|false")) .willReturn( aResponse() .withBody("Supplier2Response") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } var id = 1 var active = true val uri1 = URI.create("$WIREMOCK_BASE_URL/suppliers/$id?active=$active") val request1 = HttpRequest .newBuilder(uri1) .header("Content-Type", "application/json") .GET() .build() val response1 = client.send(request1, BodyHandlers.ofString()) response1.body() shouldBe "Supplier1Response" id = 2 active = false val uri2 = URI.create("$WIREMOCK_BASE_URL/suppliers/$id?active=$active") val request2 = HttpRequest .newBuilder(uri2) .header("Content-Type", "application/json") .GET() .build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.body() shouldBe "Supplier2Response" stove { wiremock { mockGetConfigure("/suppliers/2.*", { urlPathMatching(it) }) { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .withQueryParam("active", matching("true|false")) .willReturn( aResponse() .withBody("Supplier2Response") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } active = true val uri3 = URI.create("$WIREMOCK_BASE_URL/suppliers/$id?active=$active") val request3 = HttpRequest .newBuilder(uri3) .header("Content-Type", "application/json") .GET() .build() val response3 = client.send(request3, BodyHandlers.ofString()) response3.body() shouldBe "Supplier2Response" } /* * Configures a POST request mock using [WireMockSystem.mockPutConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to use the default URL pattern for [WireMockSystem.mockPutConfigure]. */ test("Wiremock mockPutConfigure should accept default urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockPutConfigure("/resources/1") { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("PutResource1") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/1") val reqBuilder = HttpRequest .newBuilder(uri) .header("Content-Type", "application/json") .PUT(BodyPublishers.ofString("{\"name\":\"test\"}")) val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe "PutResource1" } /* * Configures a POST request mock using [WireMockSystem.mockPutConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to configure and override the default URL matcher for [WireMockSystem.mockPutConfigure]. */ test("Wiremock mockPutConfigure should accept overridden urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockPutConfigure("/resources/.*", { urlPathMatching(it) }) { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("PutResourceMatched") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/123") val reqBuilder = HttpRequest .newBuilder(uri) .header("Content-Type", "application/json") .PUT(BodyPublishers.ofString("{\"name\":\"test\"}")) val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe "PutResourceMatched" } /* * Configures a POST request mock using [WireMockSystem.mockDeleteConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to use the default URL pattern for [WireMockSystem.mockDeleteConfigure]. */ test("Wiremock mockDeleteConfigure should accept default urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockDeleteConfigure("/resources/1") { req, _ -> req .withHeader("Authorization", equalTo("Bearer token")) .willReturn( aResponse().withStatus(204) ) } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/1") val reqBuilder = HttpRequest .newBuilder(uri) .header("Authorization", "Bearer token") .DELETE() val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 204 } } /* * Configures a POST request mock using [WireMockSystem.mockDeleteConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to configure and override the default URL matcher for [WireMockSystem.mockDeleteConfigure]. */ test("Wiremock mockDeleteConfigure should accept overridden urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockDeleteConfigure("/resources/.*", { urlPathMatching(it) }) { req, _ -> req .withHeader("Authorization", equalTo("Bearer token")) .willReturn( aResponse().withStatus(204) ) } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/123") val reqBuilder = HttpRequest .newBuilder(uri) .header("Authorization", "Bearer token") .DELETE() val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 204 } } /* * Configures a POST request mock using [WireMockSystem.mockPatchConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to use the default URL pattern for [WireMockSystem.mockPatchConfigure]. */ test("Wiremock mockPatchConfigure should accept default urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockPatchConfigure("/resources/1") { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("PatchResource1") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/1") val reqBuilder = HttpRequest .newBuilder(uri) .header("Content-Type", "application/json") .method("PATCH", BodyPublishers.ofString("{\"name\":\"updated\"}")) val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe "PatchResource1" } /* * Configures a POST request mock using [WireMockSystem.mockPatchConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to configure and override the default URL matcher for [WireMockSystem.mockPatchConfigure]. */ test("Wiremock mockPatchConfigure should accept overridden urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockPatchConfigure("/resources/.*", { (urlPathMatching(it)) }) { req, _ -> req .withHeader("Content-Type", ContainsPattern("application/json")) .willReturn( aResponse() .withBody("PatchResourceMatched") .withStatus(200) .withHeader("Content-Type", "application/json; charset=UTF-8") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/123") val reqBuilder = HttpRequest .newBuilder(uri) .header("Content-Type", "application/json") .method("PATCH", BodyPublishers.ofString("{\"name\":\"updated\"}")) val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe "PatchResourceMatched" } /* * Configures a POST request mock using [WireMockSystem.mockHeadConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to use the default URL pattern for [WireMockSystem.mockHeadConfigure]. */ test("Wiremock mockHeadConfigure should accept default urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockHeadConfigure("/resources/1") { req, _ -> req .withHeader("Authorization", equalTo("Bearer token")) .willReturn( aResponse() .withStatus(200) .withHeader("X-Custom-Header", "CustomValue") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/1") val reqBuilder = HttpRequest .newBuilder(uri) .header("Authorization", "Bearer token") .method("HEAD", BodyPublishers.noBody()) val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.headers().firstValue("X-Custom-Header").orElse("") shouldBe "CustomValue" } /* * Configures a POST request mock using [WireMockSystem.mockHeadConfigure]. * * @param urlMatcher A [UrlPattern] used to match the request URL. Defaults to [urlEqualTo] with the provided [url]. * * This test demonstrates how to configure and override the default URL matcher for [WireMockSystem.mockHeadConfigure]. */ test("Wiremock mockHeadConfigure should accept overridden urlMatcher") { val client = HttpClient.newBuilder().build() stove { wiremock { mockHeadConfigure("/resources/.*", { urlPathMatching(it) }) { req, _ -> req .withHeader("Authorization", equalTo("Bearer token")) .willReturn( aResponse() .withStatus(200) .withHeader("X-Overridden-Header", "OverriddenValue") ) } } } val uri = URI.create("$WIREMOCK_BASE_URL/resources/123") val reqBuilder = HttpRequest .newBuilder(uri) .header("Authorization", "Bearer token") .method("HEAD", BodyPublishers.noBody()) val request = reqBuilder.build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.headers().firstValue("X-Overridden-Header").orElse("") shouldBe "OverriddenValue" } }) ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/WireMockPartialMockingTest.kt ================================================ package com.trendyol.stove.wiremock import arrow.core.some import com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.intellij.lang.annotations.Language import java.net.URI import java.net.http.* import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers class WireMockPartialMockingTest : FunSpec({ val client = HttpClient.newBuilder().build() test("mockPostContaining should match requests containing specified fields") { val uniqueProductId = 12345 val url = "/orders" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf("productId" to uniqueProductId), statusCode = 201, responseBody = mapOf("orderId" to "order-123", "status" to "created").some() ) } } val requestBody = """{"productId": $uniqueProductId, "quantity": 5, "customerName": "John Doe"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 201 response.body() shouldBe """{"orderId":"order-123","status":"created"}""" } test("mockPostContaining should match requests with multiple containing fields (AND logic)") { val productId = 999 val customerId = "cust-abc" val url = "/orders/multi" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf( "productId" to productId, "customerId" to customerId ), statusCode = 200, responseBody = mapOf("matched" to true).some() ) } } val requestBody = """{"productId": $productId, "customerId": "$customerId", "extra": "ignored"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"matched":true}""" } test("mockPostContaining should NOT match when one of multiple required fields is missing (AND logic)") { val url = "/orders/and-logic-test" stove { wiremock { // Stub expects BOTH productId AND customerId to match mockPostContaining( url = url, requestContaining = mapOf( "productId" to 123, "customerId" to "cust-required" ), statusCode = 200, responseBody = mapOf("matched" to true).some() ) } } // Request only has productId, missing customerId - should NOT match val requestBody = """{"productId": 123, "extra": "data"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 404 // Not matched because customerId is missing } test("mockPostContaining should NOT match when field value is different (AND logic)") { val url = "/orders/and-logic-value-test" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf( "productId" to 123, "status" to "active" ), statusCode = 200, responseBody = mapOf("matched" to true).some() ) } } // Request has both fields but status has wrong value - should NOT match val requestBody = """{"productId": 123, "status": "inactive"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 404 // Not matched because status value is different } test("mockPutContaining should match PUT requests containing specified fields") { val userId = "user-456" val url = "/users/456" stove { wiremock { mockPutContaining( url = url, requestContaining = mapOf("userId" to userId), statusCode = 200, responseBody = mapOf("updated" to true).some() ) } } val requestBody = """{"userId": "$userId", "name": "Updated Name", "email": "test@example.com"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .PUT(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"updated":true}""" } test("mockPatchContaining should match PATCH requests containing specified fields") { val status = "active" val url = "/users/789/status" stove { wiremock { mockPatchContaining( url = url, requestContaining = mapOf("status" to status), statusCode = 200, responseBody = mapOf("status" to status).some() ) } } val requestBody = """{"status": "$status", "updatedBy": "admin", "timestamp": 1234567890}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .method("PATCH", BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"status":"active"}""" } test("mockPostContaining should work with URL pattern matching") { val transactionId = "txn-unique-123" stove { wiremock { mockPostContaining( url = "/payments/.*", requestContaining = mapOf("transactionId" to transactionId), statusCode = 200, responseBody = mapOf("processed" to true).some(), urlPatternFn = { urlPathMatching(it) } ) } } val requestBody = """{"transactionId": "$transactionId", "amount": 99.99}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL/payments/credit-card")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"processed":true}""" } test("mockPostContaining should support custom response headers") { val url = "/with-headers" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf("id" to 1), statusCode = 200, responseHeaders = mapOf("X-Custom-Header" to "CustomValue") ) } } val requestBody = """{"id": 1, "extra": "data"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.headers().firstValue("X-Custom-Header").orElse("") shouldBe "CustomValue" } test("mockPostContaining should match nested objects") { val url = "/nested-objects" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf("user" to mapOf("id" to 123)), statusCode = 200, responseBody = mapOf("success" to true).some() ) } } val requestBody = """{"user": {"id": 123, "name": "John"}, "action": "update"}""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"success":true}""" } test("mockPostContaining should match deeply nested objects with partial matching") { val url = "/deep-nested" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf( "order" to mapOf( "customer" to mapOf( "id" to "cust-deep-123" ) ) ), statusCode = 200, responseBody = mapOf("deepMatched" to true).some() ) } } // Request has extra fields at every level val requestBody = """{ "order": { "id": "order-1", "customer": { "id": "cust-deep-123", "name": "Deep Customer", "email": "deep@example.com" }, "items": [{"sku": "ABC"}] }, "timestamp": 1234567890 }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"deepMatched":true}""" } test("mockPostContaining should match arrays in nested objects") { val url = "/nested-arrays" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf( "data" to mapOf( "tags" to listOf("important", "urgent") ) ), statusCode = 200, responseBody = mapOf("arrayMatched" to true).some() ) } } val requestBody = """{ "data": { "tags": ["important", "urgent"], "other": "ignored" }, "metadata": {} }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"arrayMatched":true}""" } test("mockPutContaining should match deeply nested structures") { val url = "/deep-put" stove { wiremock { mockPutContaining( url = url, requestContaining = mapOf( "config" to mapOf( "settings" to mapOf( "enabled" to true, "level" to 5 ) ) ), statusCode = 200, responseBody = mapOf("configured" to true).some() ) } } val requestBody = """{ "config": { "name": "test-config", "settings": { "enabled": true, "level": 5, "extra": "data" } } }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .PUT(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"configured":true}""" } test("mockPatchContaining should match complex nested structures") { val url = "/complex-patch" stove { wiremock { mockPatchContaining( url = url, requestContaining = mapOf( "update" to mapOf( "type" to "partial", "fields" to mapOf("status" to "active") ) ), statusCode = 200, responseBody = mapOf("patched" to true).some() ) } } val requestBody = """{ "update": { "type": "partial", "fields": { "status": "active", "timestamp": 9999 }, "meta": {"source": "api"} } }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .method("PATCH", BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"patched":true}""" } test("mockPostContaining should match single key in deep nested JSON using dot notation") { val url = "/deep-single-key" val deepCustomerId = "deep-cust-xyz" stove { wiremock { // Using dot notation to match a single key deep in the JSON mockPostContaining( url = url, requestContaining = mapOf("order.customer.id" to deepCustomerId), statusCode = 200, responseBody = mapOf("deepKeyMatched" to true).some() ) } } val requestBody = """{ "order": { "id": "order-999", "customer": { "id": "$deepCustomerId", "name": "Deep User", "address": { "city": "Istanbul" } }, "items": [{"sku": "ITEM-1"}] }, "metadata": {"source": "test"} }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"deepKeyMatched":true}""" } test("mockPostContaining should match multiple single keys at different depths using dot notation") { val url = "/multi-deep-keys" stove { wiremock { mockPostContaining( url = url, requestContaining = mapOf( "order.customer.id" to "cust-multi-123", "order.payment.method" to "credit_card", "metadata.version" to 2 ), statusCode = 200, responseBody = mapOf("multiDeepMatched" to true).some() ) } } @Language("JSON") val requestBody = """{ "order": { "id": "order-multi", "customer": { "id": "cust-multi-123", "name": "Multi Test" }, "payment": { "method": "credit_card", "amount": 99.99 } }, "metadata": { "version": 2, "timestamp": 1234567890 } }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"multiDeepMatched":true}""" } test("mockPostContaining should match nested object at deep path using dot notation") { val url = "/deep-nested-object" stove { wiremock { // Match a nested object at a deep path mockPostContaining( url = url, requestContaining = mapOf( "data.config.settings" to mapOf("enabled" to true) ), statusCode = 200, responseBody = mapOf("deepObjectMatched" to true).some() ) } } @Language("JSON") val requestBody = """{ "data": { "config": { "name": "test", "settings": { "enabled": true, "level": 5, "extra": "ignored" } } } }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"deepObjectMatched":true}""" } test("mockPutContaining should match deep key with dot notation") { val url = "/deep-put-key" stove { wiremock { mockPutContaining( url = url, requestContaining = mapOf("user.profile.settings.theme" to "dark"), statusCode = 200, responseBody = mapOf("themeUpdated" to true).some() ) } } @Language("JSON") val requestBody = """{ "user": { "id": "user-1", "profile": { "name": "Test User", "settings": { "theme": "dark", "notifications": true } } } }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .PUT(BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"themeUpdated":true}""" } test("mockPatchContaining should match deep key with dot notation") { val url = "/deep-patch-key" stove { wiremock { mockPatchContaining( url = url, requestContaining = mapOf("document.section.paragraph.text" to "updated content"), statusCode = 200, responseBody = mapOf("textUpdated" to true).some() ) } } @Language("JSON") val requestBody = """{ "document": { "title": "My Doc", "section": { "id": 1, "paragraph": { "text": "updated content", "style": "normal" } } } }""" val request = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .method("PATCH", BodyPublishers.ofString(requestBody)) .build() val response = client.send(request, BodyHandlers.ofString()) response.statusCode() shouldBe 200 response.body() shouldBe """{"textUpdated":true}""" } }) ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/WireMockSystemTests.kt ================================================ package com.trendyol.stove.wiremock import arrow.core.some import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.* import java.net.URI import java.net.http.* import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers class WireMockSystemTests : FunSpec({ lateinit var wireMock: WireMockServer lateinit var client: HttpClient lateinit var reqBuilder: HttpRequest.Builder val url = "post-url" beforeSpec { wireMock = WireMockServer(0) wireMock.start() client = HttpClient.newBuilder().build() reqBuilder = HttpRequest.newBuilder(URI("http://localhost:${wireMock.port()}/$url")) } test("Single thread stubbing") { wireMock.stubFor( post("/$url") .withRequestBody(equalTo("request1")) .willReturn( aResponse() .withBody("response1") ) ) wireMock.stubFor( post("/$url") .withRequestBody(equalTo("request2")) .willReturn( aResponse() .withBody("response2") ) ) val request2 = reqBuilder.POST(BodyPublishers.ofString("request2")).build() val response2 = client.send(request2, BodyHandlers.ofString()) response2.body() shouldBe "response2" val request1 = reqBuilder.POST(BodyPublishers.ofString("request1")).build() val response1 = client.send(request1, BodyHandlers.ofString()) response1.body() shouldBe "response1" } test("Multi thread stubbing") { (1..20) .map { i -> async { wireMock.stubFor( post("/$url") .withRequestBody(equalTo("request$i")) .willReturn( aResponse() .withBody("response$i") ) ) } }.awaitAll() (1..20) .map { i -> async { val request = reqBuilder.POST(BodyPublishers.ofString("request$i")).build() val response = client.send(request, BodyHandlers.ofString()) response.body() shouldBe "response$i" } }.awaitAll() } context("Response Headers") { val reqBuilder = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL/headers")) .header("Content-Type", "application/json") val headers = mapOf("CustomHeaderKey" to "CustomHeaderValue") test("Stub get response with header") { val response = TestDto("get") stove { wiremock { mockGet("/headers", statusCode = 200, responseBody = response.some(), responseHeaders = headers) } } val request = reqBuilder.GET().build() val httpResponse = client.send(request, BodyHandlers.ofString()) httpResponse.body() shouldBe "{\"name\":\"get\"}" httpResponse.headers().firstValue("CustomHeaderKey").get() shouldBe "CustomHeaderValue" } test("Stub post response with header") { val response = TestDto("post") stove { wiremock { mockPost("/headers", statusCode = 200, responseBody = response.some(), responseHeaders = headers) } } val request = reqBuilder.POST(BodyPublishers.ofString("post-response-with-header")).build() val httpResponse = client.send(request, BodyHandlers.ofString()) httpResponse.body() shouldBe "{\"name\":\"post\"}" httpResponse.headers().firstValue("CustomHeaderKey").get() shouldBe "CustomHeaderValue" } test("Stub put response with header") { val response = TestDto("put") stove { wiremock { mockPut("/headers", statusCode = 200, responseBody = response.some(), responseHeaders = headers) } } val request = reqBuilder.PUT(BodyPublishers.ofString("put-response-with-header")).build() val httpResponse = client.send(request, BodyHandlers.ofString()) httpResponse.body() shouldBe "{\"name\":\"put\"}" httpResponse.headers().firstValue("CustomHeaderKey").get() shouldBe "CustomHeaderValue" } test("Stub patch response with header") { val response = TestDto("patch") stove { wiremock { mockPatch("/headers", statusCode = 200, responseBody = response.some(), responseHeaders = headers) } } val request = reqBuilder.method("PATCH", BodyPublishers.ofString("patch-response-with-header")).build() val httpResponse = client.send(request, BodyHandlers.ofString()) httpResponse.body() shouldBe "{\"name\":\"patch\"}" httpResponse.headers().firstValue("CustomHeaderKey").get() shouldBe "CustomHeaderValue" } } }) data class TestDto( val name: String ) ================================================ FILE: lib/stove-wiremock/src/test/kotlin/com/trendyol/stove/wiremock/WireMockVerificationTest.kt ================================================ package com.trendyol.stove.wiremock import arrow.core.some import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.http.RequestMethod import com.trendyol.stove.reporting.SystemSnapshot import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import com.trendyol.stove.tracing.TraceContext import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers class WireMockVerificationTest : FunSpec({ val client = HttpClient.newBuilder().build() fun request( url: String, body: String = "{}", headers: Map = emptyMap() ): HttpRequest { val builder = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(body)) headers.forEach { (key, value) -> builder.header(key, value) } return builder.build() } fun get(url: String): HttpRequest = HttpRequest .newBuilder(URI("$WIREMOCK_BASE_URL$url")) .GET() .build() @Suppress("UNCHECKED_CAST") fun SystemSnapshot.listState(name: String): List> = state[name] as List> test("shouldHaveBeenCalled should pass for exact request body after stub removal") { val url = "/verification/exact-body" val body = mapOf("orderId" to "order-1") stove { wiremock { mockPost(url = url, statusCode = 200, requestBody = body.some()) } } client.send(request(url, """{"orderId":"order-1"}"""), BodyHandlers.ofString()).statusCode() shouldBe 200 stove { wiremock { shouldHaveBeenCalled( method = RequestMethod.POST, url = url, requestBody = body.some() ) } } } test("shouldHaveBeenCalled should pass for partial nested request body") { val url = "/verification/partial-body" stove { wiremock { mockPost(url = url, statusCode = 200) } } val body = """{"order":{"customer":{"id":"customer-1","name":"Ada"}},"ignored":true}""" client.send(request(url, body), BodyHandlers.ofString()).statusCode() shouldBe 200 stove { wiremock { shouldHaveBeenCalled( method = RequestMethod.POST, url = url, requestContaining = mapOf("order.customer.id" to "customer-1") ) } } } test("shouldHaveBeenCalled should pass for headers query params and url pattern") { val url = "/verification/query" stove { wiremock { mockPostConfigure(url = url, urlPatternFn = { urlPathEqualTo(it) }) { req, _ -> req .withQueryParam("page", equalTo("1")) .willReturn(aResponse().withStatus(200)) } } } client .send( request("$url?page=1", headers = mapOf("X-Request-Id" to "req-1")), BodyHandlers.ofString() ).statusCode() shouldBe 200 stove { wiremock { shouldHaveBeenCalled( method = RequestMethod.POST, url = url, headers = mapOf("X-Request-Id" to "req-1"), queryParams = mapOf("page" to "1"), urlPatternFn = { urlPathEqualTo(it) } ) } } } test("shouldHaveBeenCalled should pass with advanced WireMock request pattern") { val url = "/verification/advanced" stove { wiremock { mockPost(url = url, statusCode = 200) } } client.send(request(url, headers = mapOf("X-Mode" to "advanced")), BodyHandlers.ofString()).statusCode() shouldBe 200 stove { wiremock { shouldHaveBeenCalled { postRequestedFor(urlEqualTo(url)) .withHeader("X-Mode", equalTo("advanced")) } } } } test("shouldHaveBeenCalled should fail when no matching call exists") { val error = shouldThrow { stove { wiremock { shouldHaveBeenCalled(method = RequestMethod.GET, url = "/verification/not-called") } } } error.message shouldContain "Expected exactly 1 requests" } test("shouldHaveBeenCalled should fail on duplicate calls by default") { val url = "/verification/duplicate" repeat(2) { stove { wiremock { mockGet(url = url, statusCode = 200) } } client.send(get(url), BodyHandlers.ofString()).statusCode() shouldBe 200 } val error = shouldThrow { stove { wiremock { shouldHaveBeenCalled(method = RequestMethod.GET, url = url) } } } error.message shouldContain "received 2" } test("shouldNotHaveBeenCalled should pass for zero calls and fail for matching calls") { val url = "/verification/not-called-negative" val calledUrl = "/verification/called-negative" stove { wiremock { mockGet(url = calledUrl, statusCode = 200) shouldNotHaveBeenCalled(method = RequestMethod.GET, url = url) } } client.send(get(calledUrl), BodyHandlers.ofString()).statusCode() shouldBe 200 val error = shouldThrow { stove { wiremock { shouldNotHaveBeenCalled(method = RequestMethod.GET, url = calledUrl) } } } error.message shouldContain "Expected exactly 0 requests" } test("callsFor should return only matching current test requests") { val targetUrl = "/verification/calls-for-target" val otherUrl = "/verification/calls-for-other" stove { wiremock { mockPost(url = targetUrl, statusCode = 200) mockPost(url = otherUrl, statusCode = 200) } } client.send(request(targetUrl, """{"type":"target"}"""), BodyHandlers.ofString()).statusCode() shouldBe 200 client.send(request(otherUrl, """{"type":"other"}"""), BodyHandlers.ofString()).statusCode() shouldBe 200 stove { wiremock { callsFor(method = RequestMethod.POST, url = targetUrl) shouldHaveSize 1 } } } test("shouldHaveBeenCalled should pass with a custom count strategy") { val url = "/verification/custom-count" repeat(2) { stove { wiremock { mockGet(url = url, statusCode = 200) } } client.send(get(url), BodyHandlers.ofString()).statusCode() shouldBe 200 } stove { wiremock { shouldHaveBeenCalled( method = RequestMethod.GET, url = url, count = moreThanOrExactly(2) ) } } } test("snapshot should include registered received and served state after stub removal") { val url = "/verification/snapshot-served" stove { wiremock { mockPost( url = url, statusCode = 201, responseBody = mapOf("created" to true).some(), responseHeaders = mapOf("X-Served-By" to "stove") ) } } client.send(request(url, """{"orderId":"snapshot-1"}"""), BodyHandlers.ofString()).statusCode() shouldBe 201 lateinit var snapshot: SystemSnapshot stove { wiremock { snapshot = snapshot() } } val registeredStubs = snapshot.listState("registeredStubs") registeredStubs shouldHaveSize 1 registeredStubs.first()["active"] shouldBe false registeredStubs.first()["method"] shouldBe "POST" registeredStubs.first()["url"] shouldBe url registeredStubs.first()["status"] shouldBe 201 val activeStubs = snapshot.listState("activeStubs") activeStubs shouldHaveSize 0 val receivedRequests = snapshot.listState("receivedRequests") receivedRequests shouldHaveSize 1 receivedRequests.first()["method"] shouldBe "POST" receivedRequests.first()["url"] shouldBe url receivedRequests.first()["body"].toString() shouldContain "snapshot-1" val servedRequests = snapshot.listState("servedRequests") servedRequests shouldHaveSize 1 val servedResponse = servedRequests.first()["response"] as Map<*, *> servedResponse["status"] shouldBe 201 servedResponse["body"].toString() shouldContain "created" snapshot.summary shouldContain "Registered stubs (this test): 1 (active: 0)" snapshot.summary shouldContain "Received requests (this test): 1" snapshot.summary shouldContain "Served requests (this test): 1 (matched: 1)" } test("snapshot should include unmatched requests scoped by Stove test header") { val testId = Stove.reporter().currentTestId() val url = "/verification/snapshot-unmatched" client .send( request(url, headers = mapOf(TraceContext.STOVE_TEST_ID_HEADER to testId)), BodyHandlers.ofString() ).statusCode() shouldBe 404 lateinit var snapshot: SystemSnapshot stove { wiremock { snapshot = snapshot() } } val unmatchedRequests = snapshot.listState("unmatchedRequests") unmatchedRequests shouldHaveSize 1 unmatchedRequests.first()["matched"] shouldBe false unmatchedRequests.first()["method"] shouldBe "POST" unmatchedRequests.first()["url"] shouldBe url val servedRequests = snapshot.listState("servedRequests") servedRequests shouldHaveSize 1 val servedResponse = servedRequests.first()["response"] as Map<*, *> servedResponse["status"] shouldBe 404 snapshot.summary shouldContain "Unmatched requests: 1" } }) ================================================ FILE: lib/stove-wiremock/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.wiremock.StoveConfig ================================================ FILE: lint.sh ================================================ #!/bin/sh # # Lint & format all projects in the Stove monorepo. # # ./lint.sh --check Check only (git hooks, CI) # ./lint.sh --format Auto-fix everything # ./lint.sh Same as --check # # Pass project names to scope the run (default: all changed projects, or all if --all): # # ./lint.sh --format jvm spa # ./lint.sh --check rust recipes # ./lint.sh --format --all # # Projects: jvm, rust, spa, recipes, go set -e # Ensure cargo is in PATH (not always inherited by subshells) if [ -d "$HOME/.cargo/bin" ]; then export PATH="$HOME/.cargo/bin:$PATH" fi REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" CLI_DIR="$REPO_ROOT/tools/stove-cli" SPA_DIR="$CLI_DIR/spa" RECIPES_DIR="$REPO_ROOT/recipes/jvm" # ── Parse args ──────────────────────────────────────────────────────── MODE="check" RUN_ALL=false PROJECTS="" for arg in "$@"; do case "$arg" in --check) MODE="check" ;; --format) MODE="format" ;; --all) RUN_ALL=true ;; jvm|rust|spa|recipes|go) PROJECTS="$PROJECTS $arg" ;; *) echo "Usage: $0 [--check|--format] [--all] [jvm] [rust] [spa] [recipes]" exit 1 ;; esac done # ── Detect changed projects when no explicit selection ──────────────── detect_changed() { # In a git hook context, use cached diff; otherwise use working tree diff if git diff --cached --name-only 2>/dev/null | grep -q .; then DIFF_CMD="git diff --cached --name-only" else DIFF_CMD="git diff --name-only HEAD" fi CHANGED=$($DIFF_CMD 2>/dev/null || true) if echo "$CHANGED" | grep -qE '\.(kt|kts|java)$'; then PROJECTS="$PROJECTS jvm" fi if echo "$CHANGED" | grep -qE "^tools/stove-cli/.*\.(rs|toml)$"; then PROJECTS="$PROJECTS rust" fi if echo "$CHANGED" | grep -qE "^tools/stove-cli/spa/src/.*\.(ts|tsx|js|jsx|css)$"; then PROJECTS="$PROJECTS spa" fi if echo "$CHANGED" | grep -qE "^recipes/"; then PROJECTS="$PROJECTS recipes" fi if echo "$CHANGED" | grep -qE '\.go$'; then PROJECTS="$PROJECTS go" fi } if [ -z "$PROJECTS" ]; then if [ "$RUN_ALL" = true ]; then PROJECTS="jvm rust spa recipes go" else detect_changed if [ -z "$PROJECTS" ]; then echo "No changes detected. Use --all to lint everything." exit 0 fi fi fi # ── Helpers ─────────────────────────────────────────────────────────── EXIT_CODE=0 run() { echo " \$ $*" if ! "$@"; then EXIT_CODE=1 fi } section() { echo "" echo "── $1 ──" } # ── JVM (Kotlin / Java) ────────────────────────────────────────────── lint_jvm() { section "JVM (Kotlin / Java)" if [ "$MODE" = "format" ]; then run "$REPO_ROOT/gradlew" -p "$REPO_ROOT" --no-daemon spotlessApply detekt apiDump else run "$REPO_ROOT/gradlew" -p "$REPO_ROOT" --no-daemon spotlessCheck detekt apiCheck fi } # ── Rust ────────────────────────────────────────────────────────────── lint_rust() { section "Rust" if [ "$MODE" = "format" ]; then (cd "$CLI_DIR" && run cargo fmt) else (cd "$CLI_DIR" && run cargo fmt -- --check) fi (cd "$CLI_DIR" && SKIP_SPA_BUILD=1 run cargo clippy -- -D warnings) } # ── SPA (TypeScript / React) ───────────────────────────────────────── lint_spa() { section "SPA (TypeScript / React)" if [ ! -d "$SPA_DIR/node_modules" ]; then (cd "$SPA_DIR" && run npm install) fi if [ "$MODE" = "format" ]; then (cd "$SPA_DIR" && run npx biome check --write src) else (cd "$SPA_DIR" && run npx tsc -b) (cd "$SPA_DIR" && run npx biome check src) fi } # ── Go ─────────────────────────────────────────────────────────────── lint_go() { section "Go" GO_DIRS="$REPO_ROOT/go/stove-kafka $REPO_ROOT/recipes/process/golang/go-showcase" for dir in $GO_DIRS; do if [ -d "$dir" ]; then if [ "$MODE" = "format" ]; then run gofmt -w "$dir" else if [ -n "$(gofmt -l "$dir")" ]; then echo "gofmt: files need formatting in $dir:" gofmt -l "$dir" EXIT_CODE=1 fi fi (cd "$dir" && run go vet ./...) fi done } # ── Recipes (Kotlin / Java / Scala) ────────────────────────────────── lint_recipes() { section "Recipes (Kotlin / Java / Scala)" if [ "$MODE" = "format" ]; then run "$REPO_ROOT/gradlew" -p "$RECIPES_DIR" --no-daemon spotlessApply else run "$REPO_ROOT/gradlew" -p "$RECIPES_DIR" --no-daemon spotlessCheck fi } # ── Run selected projects concurrently ──────────────────────────────── echo "Mode: $MODE" PIDS="" for proj in $PROJECTS; do ( case "$proj" in jvm) lint_jvm ;; rust) lint_rust ;; spa) lint_spa ;; recipes) lint_recipes ;; go) lint_go ;; esac exit $EXIT_CODE ) & PIDS="$PIDS $!" done EXIT_CODE=0 for pid in $PIDS; do if ! wait "$pid"; then EXIT_CODE=1 fi done echo "" if [ $EXIT_CODE -ne 0 ]; then echo "Some checks failed. Run './lint.sh --format' to auto-fix." exit 1 else echo "All checks passed." fi ================================================ FILE: mkdocs.yml ================================================ site_name: "Stove" site_description: "End-to-end testing framework for the JVM" repo_url: https://github.com/trendyol/stove repo_name: trendyol/stove edit_uri: edit/main/docs/ extra_css: - css/custom.css extra_javascript: - assets/rough-notation.iife.js - js/rough-notation-mkdocs.js plugins: - search - awesome-pages: collapse_single_pages: true nav: - Home: index.md - Getting Started: getting-started.md - Supported Frameworks: - Overview: frameworks/index.md - Spring Boot: frameworks/spring-boot.md - Ktor: frameworks/ktor.md - Micronaut: frameworks/micronaut.md - Quarkus: frameworks/quarkus.md - Other Languages & Stacks: - Overview: other-languages/index.md - Go: - Overview: other-languages/go.md - Process Mode: other-languages/go-process.md - Container Mode: other-languages/go-container.md - Components: - Overview: Components/index.md - Couchbase: Components/01-couchbase.md - Kafka: Components/02-kafka.md - Elasticsearch: Components/03-elasticsearch.md - WireMock: Components/04-wiremock.md - HTTP Client: Components/05-http.md - PostgreSQL: Components/06-postgresql.md - MongoDB: Components/07-mongodb.md - MSSQL: Components/08-mssql.md - Redis: Components/09-redis.md - Bridge: Components/10-bridge.md - Provided Instances: Components/11-provided-instances.md - gRPC: Components/12-grpc.md - Reporting: Components/13-reporting.md - gRPC Mocking: Components/14-grpc-mock.md - Tracing: Components/15-tracing.md - MySQL: Components/16-mysql.md - Cassandra: Components/17-cassandra.md - Dashboard: Components/18-dashboard.md - MCP: Components/21-mcp.md - Container AUT: Components/22-container.md - Provided Application: Components/19-provided-application.md - Multiple Systems: Components/20-multiple-systems.md - Writing Custom Systems: writing-custom-systems.md - Best Practices: best-practices.md - Troubleshooting: troubleshooting.md - Blog: - "Polyglot Stove in 0.24.0": blog/polyglot-0.24.0.md - "Stove Dashboard in 0.23.0": blog/dashboard-0.23.0.md - "Execution Tracing in 0.21.0": blog/tracing-0.21.0.md - Release Notes: - "0.24.0": release-notes/0.24.0.md - "0.23.0": release-notes/0.23.0.md - 0.22.2: release-notes/0.22.2.md - 0.21.2: release-notes/0.21.2.md - 0.21.0: release-notes/0.21.0.md - 0.20.0: release-notes/0.20.0.md - 0.19.0: release-notes/0.19.0.md - 0.15.0: release-notes/0.15.0.md theme: name: material logo: assets/logo.png favicon: assets/logo.png features: # Navigation - navigation.instant - navigation.instant.progress - navigation.tracking - navigation.tabs - navigation.sections - navigation.indexes - navigation.top - navigation.footer - navigation.path # Search - search.highlight - search.share - search.suggest # Content - content.code.copy - content.code.annotate - content.tabs.link - content.tooltips # Header - header.autohide # Table of contents - toc.follow palette: - scheme: default primary: teal accent: amber toggle: icon: material/weather-sunny name: Switch to dark mode - scheme: slate primary: teal accent: amber toggle: icon: material/weather-night name: Switch to light mode font: text: Inter code: JetBrains Mono icon: repo: fontawesome/brands/github markdown_extensions: - admonition - pymdownx.details - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences: custom_fences: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true slugify: !!python/object/apply:pymdownx.slugs.slugify kwds: case: lower - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg - tables - attr_list - md_in_html - def_list - toc: permalink: true extra: social: - icon: fontawesome/brands/github link: https://github.com/trendyol/stove - icon: fontawesome/regular/building link: https://trendyol.github.io/ ================================================ FILE: plugins/stove-tracing-gradle-plugin/build.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") plugins { `kotlin-dsl` `java-gradle-plugin` alias(libs.plugins.maven.publish) } gradlePlugin { plugins { create("stoveTracing") { id = "com.trendyol.stove.tracing" implementationClass = "com.trendyol.stove.gradle.StoveTracingPlugin" } } } tasks.test { useJUnitPlatform() } dependencies { testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.framework.engine) testImplementation(libs.kotest.assertions.core) } ================================================ FILE: plugins/stove-tracing-gradle-plugin/gradle/libs.versions.toml ================================================ [versions] kotest = "6.1.11" [libraries] kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } [plugins] maven-publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" } ================================================ FILE: plugins/stove-tracing-gradle-plugin/gradle.properties ================================================ projectDescription=Gradle plugin that configures OpenTelemetry Java Agent for Stove test tracing ================================================ FILE: plugins/stove-tracing-gradle-plugin/settings.gradle.kts ================================================ rootProject.name = "stove-tracing-gradle-plugin" dependencyResolutionManagement { repositories { mavenCentral() gradlePluginPortal() } } ================================================ FILE: plugins/stove-tracing-gradle-plugin/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingExtension.kt ================================================ package com.trendyol.stove.gradle import com.trendyol.stove.gradle.internal.TracingDefaults import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import javax.inject.Inject /** * Configuration DSL for the Stove Tracing Gradle plugin. * * Example usage in build.gradle.kts: * ```kotlin * stoveTracing { * serviceName.set("my-service") * testTaskNames.set(listOf("integrationTest")) * } * ``` */ abstract class StoveTracingExtension @Inject constructor(objects: ObjectFactory) { /** The service name to use in traces. This should match your application's service name. */ val serviceName: Property = objects.property(String::class.java) .convention(TracingDefaults.DEFAULT_SERVICE_NAME) /** Whether tracing is enabled. Set false to disable tracing without removing configuration. */ val enabled: Property = objects.property(Boolean::class.java) .convention(true) /** * The OTLP protocol to use. * Currently only "grpc" is supported. */ val protocol: Property = objects.property(String::class.java) .convention(TracingDefaults.DEFAULT_PROTOCOL) /** The batch span processor schedule delay in milliseconds. Lower = faster export. */ val bspScheduleDelay: Property = objects.property(Int::class.java) .convention(TracingDefaults.DEFAULT_BSP_SCHEDULE_DELAY) /** The maximum batch size for span export. 1 = immediate export per span. */ val bspMaxBatchSize: Property = objects.property(Int::class.java) .convention(TracingDefaults.DEFAULT_BSP_MAX_BATCH_SIZE) /** Whether to capture HTTP headers in spans. */ val captureHttpHeaders: Property = objects.property(Boolean::class.java) .convention(true) /** Whether to enable experimental HTTP telemetry features. */ val captureExperimentalTelemetry: Property = objects.property(Boolean::class.java) .convention(true) /** List of instrumentation modules to disable. Example: listOf("jdbc", "hibernate") */ val disabledInstrumentations: ListProperty = objects.listProperty(String::class.java) .convention(emptyList()) /** List of additional instrumentation modules to enable. */ val additionalInstrumentations: ListProperty = objects.listProperty(String::class.java) .convention(emptyList()) /** List of custom annotation class names to instrument. */ val customAnnotations: ListProperty = objects.listProperty(String::class.java) .convention(emptyList()) /** The OpenTelemetry Java Agent version to use. */ val otelAgentVersion: Property = objects.property(String::class.java) .convention(TracingDefaults.DEFAULT_OTEL_AGENT_VERSION) /** * List of test task names to configure. If empty, applies to all test tasks. * Example: listOf("integrationTest") to only apply to the integrationTest task. */ val testTaskNames: ListProperty = objects.listProperty(String::class.java) .convention(emptyList()) } ================================================ FILE: plugins/stove-tracing-gradle-plugin/src/main/kotlin/com/trendyol/stove/gradle/StoveTracingPlugin.kt ================================================ package com.trendyol.stove.gradle import com.trendyol.stove.gradle.internal.TestTaskConfigurator import org.gradle.api.Plugin import org.gradle.api.Project /** * Gradle plugin that configures the OpenTelemetry Java Agent for Stove test tracing. * * When a test fails, Stove can display the execution trace showing exactly * what happened during the test -- HTTP calls, Kafka messages, database queries, etc. * * Usage in build.gradle.kts: * ```kotlin * plugins { * id("com.trendyol.stove.tracing") * } * * stoveTracing { * serviceName.set("my-service") * } * ``` */ class StoveTracingPlugin : Plugin { override fun apply(project: Project) { val extension = project.extensions.create("stoveTracing", StoveTracingExtension::class.java) TestTaskConfigurator.configure(project, extension) } } ================================================ FILE: plugins/stove-tracing-gradle-plugin/src/main/kotlin/com/trendyol/stove/gradle/internal/JvmArgsBuilder.kt ================================================ package com.trendyol.stove.gradle.internal /** * Serializable snapshot of tracing config for Gradle configuration cache compatibility. * All objects captured in task actions must be serializable. */ internal data class ResolvedTracingConfig( val protocol: String, val serviceName: String, val bspScheduleDelay: Int, val bspMaxBatchSize: Int, val captureHttpHeaders: Boolean, val captureExperimentalTelemetry: Boolean, val customAnnotations: List, val disabledInstrumentations: List, val additionalInstrumentations: List, ) : java.io.Serializable { companion object { private const val serialVersionUID: Long = 1L } } internal object JvmArgsBuilder { fun build(agentPath: String, config: ResolvedTracingConfig, port: Int): List = buildList { add("-javaagent:$agentPath") addAll(coreExportArgs(config, port)) add("-Dotel.propagators=tracecontext,baggage") addAll(testOptimizationArgs(config)) if (config.captureHttpHeaders) { addAll(httpHeaderCaptureArgs()) } if (config.captureExperimentalTelemetry) { addAll(experimentalTelemetryArgs()) } if (config.customAnnotations.isNotEmpty()) { add("-Dotel.instrumentation.annotations.methods=${config.customAnnotations.joinToString(",")}") } addAll(instrumentationControlArgs(config)) } private fun coreExportArgs(config: ResolvedTracingConfig, port: Int): List = buildList { val endpoint = "http://localhost:$port" add("-Dotel.traces.exporter=otlp") add("-Dotel.exporter.otlp.protocol=${config.protocol}") add("-Dotel.exporter.otlp.endpoint=$endpoint") add("-Dotel.metrics.exporter=none") add("-Dotel.logs.exporter=none") add("-Dotel.service.name=${config.serviceName}") add("-Dotel.resource.attributes=service.name=${config.serviceName},deployment.environment=test") if (config.protocol == "grpc") { add("-Dotel.instrumentation.grpc.enabled=false") } } private fun testOptimizationArgs(config: ResolvedTracingConfig): List = listOf( "-Dotel.traces.sampler=always_on", "-Dotel.bsp.schedule.delay=${config.bspScheduleDelay}", "-Dotel.bsp.max.export.batch.size=${config.bspMaxBatchSize}", ) private fun httpHeaderCaptureArgs(): List = listOf( "-Dotel.instrumentation.http.client.capture-request-headers=content-type,accept,x-stove-test-id", "-Dotel.instrumentation.http.client.capture-response-headers=content-type", "-Dotel.instrumentation.http.server.capture-request-headers=content-type,accept,user-agent,x-stove-test-id", "-Dotel.instrumentation.http.server.capture-response-headers=content-type", ) private fun experimentalTelemetryArgs(): List = listOf( "-Dotel.instrumentation.http.client.emit-experimental-telemetry=true", "-Dotel.instrumentation.http.server.emit-experimental-telemetry=true", "-Dotel.instrumentation.servlet.experimental.capture-request-parameters=*", ) private fun instrumentationControlArgs(config: ResolvedTracingConfig): List = buildList { if (config.disabledInstrumentations.isNotEmpty()) { add("-Dotel.instrumentation.common.default-enabled=true") addAll(config.disabledInstrumentations.map { "-Dotel.instrumentation.$it.enabled=false" }) } addAll(config.additionalInstrumentations.map { "-Dotel.instrumentation.$it.enabled=true" }) } } ================================================ FILE: plugins/stove-tracing-gradle-plugin/src/main/kotlin/com/trendyol/stove/gradle/internal/TestTaskConfigurator.kt ================================================ package com.trendyol.stove.gradle.internal import com.trendyol.stove.gradle.StoveTracingExtension import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.tasks.testing.Test import java.net.ServerSocket internal object TestTaskConfigurator { fun configure(project: Project, extension: StoveTracingExtension) { val otelAgentConfig = project.configurations.create("otelAgent") { isTransitive = false isCanBeResolved = true isCanBeConsumed = false description = "OpenTelemetry Java Agent for Stove test tracing" } project.afterEvaluate { if (!extension.enabled.get()) { logger.info("Stove tracing is disabled, skipping configuration") return@afterEvaluate } validateProtocol(extension.protocol.get()) dependencies.add( "otelAgent", "io.opentelemetry.javaagent:opentelemetry-javaagent:${extension.otelAgentVersion.get()}" ) val testTasks = resolveTestTasks(extension) testTasks.forEach { testTask -> configureTestTask(testTask, otelAgentConfig, extension) } logConfiguration(extension, testTasks) } } private fun validateProtocol(protocol: String) { require(protocol == TracingDefaults.SUPPORTED_PROTOCOL) { "Unsupported OTLP protocol '$protocol'. Stove tracing receiver currently supports only " + "'${TracingDefaults.SUPPORTED_PROTOCOL}'." } } private fun Project.resolveTestTasks(extension: StoveTracingExtension): List { val taskNames = extension.testTaskNames.get() return if (taskNames.isEmpty()) { tasks.withType(Test::class.java).toList() } else { taskNames.mapNotNull { taskName -> tasks.findByName(taskName) as? Test } } } private fun configureTestTask( testTask: Test, otelAgentConfig: Configuration, extension: StoveTracingExtension, ) { val resolvedAgentPath: String? = otelAgentConfig.resolve().firstOrNull()?.absolutePath val tracingConfig = ResolvedTracingConfig( protocol = extension.protocol.get(), serviceName = extension.serviceName.get(), bspScheduleDelay = extension.bspScheduleDelay.get(), bspMaxBatchSize = extension.bspMaxBatchSize.get(), captureHttpHeaders = extension.captureHttpHeaders.get(), captureExperimentalTelemetry = extension.captureExperimentalTelemetry.get(), customAnnotations = extension.customAnnotations.get(), disabledInstrumentations = extension.disabledInstrumentations.get(), additionalInstrumentations = extension.additionalInstrumentations.get(), ) testTask.doFirst { if (resolvedAgentPath == null) { testTask.logger.warn("No OTel agent JAR found in otelAgent configuration") return@doFirst } val port = findAvailablePort() testTask.environment(TracingDefaults.STOVE_TRACING_PORT_ENV, port.toString()) val jvmArgs = JvmArgsBuilder.build(resolvedAgentPath, tracingConfig, port) testTask.jvmArgs(jvmArgs) testTask.logger.info( "Stove tracing: Attached OTel agent on port {} with {} JVM arguments", port, jvmArgs.size, ) } } private fun Project.logConfiguration(extension: StoveTracingExtension, testTasks: List) { val taskNames = extension.testTaskNames.get() val taskInfo = if (taskNames.isEmpty()) { "all test tasks" } else { "tasks: ${testTasks.joinToString(", ") { it.name }}" } logger.info( "Stove tracing configured for service '${extension.serviceName.get()}' " + "with dynamic port assignment on $taskInfo" ) } private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } } ================================================ FILE: plugins/stove-tracing-gradle-plugin/src/main/kotlin/com/trendyol/stove/gradle/internal/TracingDefaults.kt ================================================ package com.trendyol.stove.gradle.internal internal object TracingDefaults { const val DEFAULT_BSP_SCHEDULE_DELAY = 100 const val DEFAULT_BSP_MAX_BATCH_SIZE = 1 const val DEFAULT_OTEL_AGENT_VERSION = "2.24.0" const val DEFAULT_PROTOCOL = "grpc" const val SUPPORTED_PROTOCOL = DEFAULT_PROTOCOL const val DEFAULT_SERVICE_NAME = "stove-traced-app" const val STOVE_TRACING_PORT_ENV = "STOVE_TRACING_PORT" } ================================================ FILE: plugins/stove-tracing-gradle-plugin/src/test/kotlin/com/trendyol/stove/gradle/StoveTracingPluginFunctionalTest.kt ================================================ package com.trendyol.stove.gradle import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.string.shouldContain import org.gradle.testkit.runner.GradleRunner import java.io.File class StoveTracingPluginFunctionalTest : FunSpec({ lateinit var projectDir: File beforeEach { projectDir = File.createTempFile("stove-plugin-test", "").apply { delete() mkdirs() } projectDir.resolve("settings.gradle.kts").writeText("") } afterEach { projectDir.deleteRecursively() } test("plugin can be applied and extension is configurable") { projectDir.resolve("build.gradle.kts").writeText( """ plugins { java id("com.trendyol.stove.tracing") } repositories { mavenCentral() } stoveTracing { serviceName.set("test-service") enabled.set(true) testTaskNames.set(listOf("test")) } """.trimIndent() ) projectDir.resolve("src/test/java").mkdirs() val result = GradleRunner.create() .forwardOutput() .withPluginClasspath() .withArguments("tasks", "--all") .withProjectDir(projectDir) .build() result.output shouldContain "test" } test("plugin registers stoveTracing extension with defaults") { projectDir.resolve("build.gradle.kts").writeText( """ plugins { java id("com.trendyol.stove.tracing") } repositories { mavenCentral() } tasks.register("printConfig") { doLast { val ext = project.extensions.getByType(${StoveTracingExtension::class.qualifiedName}::class.java) println("serviceName=${'$'}{ext.serviceName.get()}") println("enabled=${'$'}{ext.enabled.get()}") println("protocol=${'$'}{ext.protocol.get()}") println("otelAgentVersion=${'$'}{ext.otelAgentVersion.get()}") } } """.trimIndent() ) val result = GradleRunner.create() .forwardOutput() .withPluginClasspath() .withArguments("printConfig") .withProjectDir(projectDir) .build() result.output shouldContain "serviceName=stove-traced-app" result.output shouldContain "enabled=true" result.output shouldContain "protocol=grpc" result.output shouldContain "otelAgentVersion=2.24.0" } test("plugin is disabled when enabled is set to false") { projectDir.resolve("build.gradle.kts").writeText( """ plugins { java id("com.trendyol.stove.tracing") } repositories { mavenCentral() } stoveTracing { serviceName.set("test-service") enabled.set(false) } """.trimIndent() ) val result = GradleRunner.create() .forwardOutput() .withPluginClasspath() .withArguments("tasks", "--info") .withProjectDir(projectDir) .build() result.output shouldContain "Stove tracing is disabled" } }) ================================================ FILE: pre-commit.sh ================================================ #!/bin/sh # # Git pre-commit hook — runs lint checks on changed projects. # Delegates to lint.sh which auto-detects changed files. REPO_ROOT="$(git rev-parse --show-toplevel)" exec "$REPO_ROOT/lint.sh" --check ================================================ FILE: recipes/jvm/.editorconfig ================================================ root = true [*] insert_final_newline = true ktlint_standard_package-name = disabled ktlint_standard_filename = disabled ktlint_standard_no-wildcard-imports = disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_string-template-indent = disabled ktlint_standard_function-signature = disabled [*.java] indent_style = space max_line_length = 140 indent_size = 2 [{*.kt,*.kts}] indent_style = space max_line_length = 140 indent_size = 2 ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_continuation_indent_size = 2 ij_kotlin_allow_trailing_comma = false ij_kotlin_allow_trailing_comma_on_call_site = false ij_kotlin_name_count_to_use_star_import = 2 ij_kotlin_name_count_to_use_star_import_for_members = 2 [{**/test/**.kt,**/test-e2e/**.kt,**/test-int/**.kt}] max_line_length = 240 ktlint_standard_no-consecutive-comments = disabled ================================================ FILE: recipes/jvm/.gitattributes ================================================ # # https://help.github.com/articles/dealing-with-line-endings/ # # Linux start script should use lf /gradlew text eol=lf # These are Windows script files and should use crlf *.bat text eol=crlf ================================================ FILE: recipes/jvm/.gitignore ================================================ .DS_Store /site /.idea .idea/shelf /confluence/target /dependencies/repo /android.tests.dependencies /dependencies/android.tests.dependencies /dist /local /gh-pages /ideaSDK /clionSDK /android-studio/sdk out/ /tmp /intellij workspace.xml *.versionsBackup /idea/testData/debugger/tinyApp/classes* /jps-plugin/testData/kannotator /js/js.translator/testData/out/ /js/js.translator/testData/out-min/ /js/js.translator/testData/out-pir/ .gradle/ build/ !**/src/**/build !**/test/**/build *.iml !**/testData/**/*.iml .idea/remote-targets.xml .idea/libraries/Gradle*.xml .idea/libraries/Maven*.xml .idea/artifacts/PILL_*.xml .idea/artifacts/KotlinPlugin.xml .idea/modules .idea/runConfigurations/JPS_*.xml .idea/runConfigurations/PILL_*.xml .idea/runConfigurations/_FP_*.xml .idea/runConfigurations/_MT_*.xml .idea/libraries .idea/modules.xml .idea/gradle.xml .idea/compiler.xml .idea/inspectionProfiles/profiles_settings.xml .idea/.name .idea/artifacts/dist_auto_* .idea/artifacts/dist.xml .idea/artifacts/ideaPlugin.xml .idea/artifacts/kotlinc.xml .idea/artifacts/kotlin_compiler_jar.xml .idea/artifacts/kotlin_plugin_jar.xml .idea/artifacts/kotlin_jps_plugin_jar.xml .idea/artifacts/kotlin_daemon_client_jar.xml .idea/artifacts/kotlin_imports_dumper_compiler_plugin_jar.xml .idea/artifacts/kotlin_main_kts_jar.xml .idea/artifacts/kotlin_compiler_client_embeddable_jar.xml .idea/artifacts/kotlin_reflect_jar.xml .idea/artifacts/kotlin_stdlib_js_ir_* .idea/artifacts/kotlin_test_js_ir_* .idea/artifacts/kotlin_stdlib_wasm_* .idea/artifacts/kotlinx_atomicfu_runtime_* .idea/artifacts/kotlinx_cli_jvm_* .idea/jarRepositories.xml .idea/csv-plugin.xml .idea/libraries-with-intellij-classes.xml .idea/misc.xml .idea/** node_modules/ .rpt2_cache/ libraries/tools/kotlin-test-js-runner/lib/ local.properties buildSrcTmp/ distTmp/ outTmp/ /test.output /kotlin-native/dist kotlin-ide/ **/bin/**/* # Ignore Gradle project-specific cache directory .gradle # Ignore Gradle build output directory build ================================================ FILE: recipes/jvm/build.gradle.kts ================================================ import org.gradle.plugins.ide.idea.model.IdeaModel import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { kotlin("jvm").version(libs.versions.kotlin) alias(libs.plugins.spotless) alias(libs.plugins.testLogger) alias(libs.plugins.detekt) idea java } subprojects { apply { plugin(rootProject.libs.plugins.spotless.get().pluginId) plugin(rootProject.libs.plugins.testLogger.get().pluginId) plugin(rootProject.libs.plugins.detekt.get().pluginId) plugin("idea") plugin("java") plugin("kotlin") } detekt { buildUponDefaultConfig = true parallel = true config.from(rootProject.file("detekt.yml")) } dependencies { testImplementation(rootProject.libs.kotest.framework.engine) testImplementation(rootProject.libs.kotest.assertions.core) testImplementation(rootProject.libs.kotest.runner.junit5) detektPlugins(rootProject.libs.detekt.formatting) } spotless { java { target("src/**/*.java") palantirJavaFormat("2.86.0").style("GOOGLE").formatJavadoc(true) targetExcludeIfContentContains("generated") targetExclude("build/**", "**/build/**", "**/generated/**") targetExcludeIfContentContainsRegex(".*generated.*") } scala { scalafmt("3.10.6") } kotlin { target("src/**/*.kt") ktlint(libs.versions.ktlint.get()) .setEditorConfigPath(rootProject.layout.projectDirectory.file(".editorconfig")) targetExclude("build/**", "**/build/**", "**/generated/**") targetExcludeIfContentContains("generated") targetExcludeIfContentContainsRegex(".*generated.*") } } the().apply { module { isDownloadSources = true isDownloadJavadoc = true } } tasks { test { dependsOn(spotlessApply) useJUnitPlatform() testlogger { setTheme("mocha") showStandardStreams = true showExceptions = true showCauses = true } reports { junitXml.required.set(true) } jvmArgs("--add-opens", "java.base/java.util=ALL-UNNAMED") } kotlin { jvmToolchain(21) } java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } withType { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors = true freeCompilerArgs.addAll( "-Xjsr305=strict", "-Xcontext-parameters", "-Xsuppress-version-warnings" ) } } } } ================================================ FILE: recipes/jvm/buildSrc/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { gradlePluginPortal() } ================================================ FILE: recipes/jvm/buildSrc/settings.gradle.kts ================================================ rootProject.name = "buildSrc" dependencyResolutionManagement { versionCatalogs { create("libs", { from(files("../gradle/libs.versions.toml")) }) } } ================================================ FILE: recipes/jvm/buildSrc/src/main/kotlin/TestFolders.kt ================================================ object TestFolders { const val integration = "test-int" const val e2e = "test-e2e" const val shared = "test-shared" } val runningOnCI get() = System.getenv("CI") == "true" val runningLocally get() = !runningOnCI ================================================ FILE: recipes/jvm/detekt.yml ================================================ build: maxIssues: 0 excludeCorrectable: false config: validation: true warningsAsErrors: true excludes: '' processors: active: true exclude: - 'DetektProgressListener' console-reports: active: true exclude: - 'ProjectStatisticsReport' - 'ComplexityReport' - 'NotificationReport' - 'FindingsReport' - 'FileBasedFindingsReport' output-reports: active: false formatting: Indentation: active: false indentSize: 2 autoCorrect: true NoWildcardImports: active: false MaximumLineLength: active: true maxLineLength: 140 excludes: [ '**/test/**', '**/test-e2e/**' ] ArgumentListWrapping: maxLineLength: 140 autoCorrect: true active: true indentSize: 2 Filename: active: false comments: active: true AbsentOrWrongFileLicense: active: false licenseTemplateFile: 'license.template' licenseTemplateIsRegex: false CommentOverPrivateFunction: active: false CommentOverPrivateProperty: active: false DeprecatedBlockTag: active: false EndOfSentenceFormat: active: false endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' KDocReferencesNonPublicProperty: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] OutdatedDocumentation: active: false matchTypeParameters: true matchDeclarationsOrder: true allowParamOnConstructorProperties: false UndocumentedPublicClass: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true UndocumentedPublicFunction: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UndocumentedPublicProperty: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] complexity: active: true ComplexCondition: active: true threshold: 4 ComplexInterface: active: false threshold: 10 includeStaticDeclarations: false includePrivateDeclarations: false CyclomaticComplexMethod: active: true threshold: 15 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false ignoreNestingFunctions: false nestingFunctions: - 'also' - 'apply' - 'forEach' - 'isNotNull' - 'ifNull' - 'let' - 'run' - 'use' - 'with' LabeledExpression: active: false ignoredLabels: [ ] LargeClass: active: true threshold: 600 LongMethod: active: true threshold: 60 LongParameterList: active: true functionThreshold: 20 constructorThreshold: 20 ignoreDefaultParameters: false ignoreDataClasses: true ignoreAnnotatedParameter: [ ] MethodOverloading: active: false threshold: 6 NamedArguments: active: false threshold: 3 ignoreArgumentsMatchingNames: false NestedBlockDepth: active: true threshold: 4 NestedScopeFunctions: active: false threshold: 1 functions: - 'kotlin.apply' - 'kotlin.run' - 'kotlin.with' - 'kotlin.let' - 'kotlin.also' ReplaceSafeCallChainWithRun: active: false StringLiteralDuplication: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] thresholdInFiles: 20 thresholdInClasses: 20 thresholdInInterfaces: 11 thresholdInObjects: 11 thresholdInEnums: 11 ignoreDeprecated: false ignorePrivate: false ignoreOverridden: false coroutines: active: true GlobalCoroutineUsage: active: false InjectDispatcher: active: false dispatcherNames: - 'IO' - 'Default' - 'Unconfined' RedundantSuspendModifier: active: false SleepInsteadOfDelay: active: true SuspendFunWithCoroutineScopeReceiver: active: false SuspendFunWithFlowReturnType: active: true empty-blocks: active: true EmptyCatchBlock: active: true allowedExceptionNameRegex: '_|(ignore|expected).*' EmptyClassBlock: active: true EmptyDefaultConstructor: active: true EmptyDoWhileBlock: active: true EmptyElseBlock: active: true EmptyFinallyBlock: active: true EmptyForBlock: active: true EmptyFunctionBlock: active: true ignoreOverridden: false EmptyIfBlock: active: true EmptyInitBlock: active: true EmptyKtFile: active: true EmptySecondaryConstructor: active: true EmptyTryBlock: active: true EmptyWhenBlock: active: true EmptyWhileBlock: active: true exceptions: active: true ExceptionRaisedInUnexpectedLocation: active: true methodNames: - 'equals' - 'finalize' - 'hashCode' - 'toString' InstanceOfCheckForException: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] NotImplementedDeclaration: active: false ObjectExtendsThrowable: active: false PrintStackTrace: active: true RethrowCaughtException: active: true ReturnFromFinally: active: true ignoreLabeled: false SwallowedException: active: true ignoredExceptionTypes: - 'InterruptedException' - 'MalformedURLException' - 'NumberFormatException' - 'ParseException' allowedExceptionNameRegex: '_|(ignore|expected).*' ThrowingExceptionFromFinally: active: true ThrowingExceptionInMain: active: false ThrowingExceptionsWithoutMessageOrCause: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptions: - 'ArrayIndexOutOfBoundsException' - 'Exception' - 'IllegalArgumentException' - 'IllegalMonitorStateException' - 'IllegalStateException' - 'IndexOutOfBoundsException' - 'NullPointerException' - 'RuntimeException' - 'Throwable' ThrowingNewInstanceOfSameException: active: true TooGenericExceptionCaught: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptionNames: - 'ArrayIndexOutOfBoundsException' - 'Error' - 'Exception' - 'IllegalMonitorStateException' - 'IndexOutOfBoundsException' - 'NullPointerException' - 'RuntimeException' - 'Throwable' allowedExceptionNameRegex: '_|(ignore|expected).*' TooGenericExceptionThrown: active: true exceptionNames: - 'Error' - 'Exception' - 'RuntimeException' - 'Throwable' naming: active: true BooleanPropertyNaming: active: false allowedPattern: '^(is|has|are)' ClassNaming: active: true classPattern: '[A-Z][a-zA-Z0-9]*' EnumNaming: active: true enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false forbiddenName: [ ] FunctionMaxLength: active: false maximumFunctionNameLength: 30 FunctionMinLength: active: false minimumFunctionNameLength: 3 InvalidPackageDeclaration: active: true rootPackage: '' requireRootInDeclaration: false LambdaParameterNaming: active: false parameterPattern: '[a-z][A-Za-z0-9]*|_' MatchingDeclarationName: active: false mustBeFirst: true MemberNameEqualsClassName: active: true ignoreOverridden: true NoNameShadowing: active: true NonBooleanPropertyPrefixedWithIs: active: false ObjectPropertyNaming: active: true constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true constantPattern: '[A-Z][_A-Z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: active: false maximumVariableNameLength: 64 VariableMinLength: active: false minimumVariableNameLength: 1 ConstructorParameterNaming: active: false parameterPattern: '[a-z][A-Za-z0-9]*|_' performance: active: true ArrayPrimitive: active: true CouldBeSequence: active: false threshold: 3 ForEachOnRange: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] SpreadOperator: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/otel/**', ] UnnecessaryTemporaryInstantiation: active: true potential-bugs: active: true AvoidReferentialEquality: active: true forbiddenTypePatterns: - 'kotlin.String' CastToNullableType: active: false Deprecation: active: false DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: active: true mutableTypes: - 'kotlin.collections.MutableList' - 'kotlin.collections.MutableMap' - 'kotlin.collections.MutableSet' - 'java.util.ArrayList' - 'java.util.LinkedHashSet' - 'java.util.HashSet' - 'java.util.LinkedHashMap' - 'java.util.HashMap' ElseCaseInsteadOfExhaustiveWhen: active: false EqualsAlwaysReturnsTrueOrFalse: active: true EqualsWithHashCodeExist: active: true ExitOutsideMain: active: false ExplicitGarbageCollectionCall: active: true HasPlatformType: active: true IgnoredReturnValue: active: true restrictToConfig: true returnValueAnnotations: - '*.CheckResult' - '*.CheckReturnValue' ignoreReturnValueAnnotations: - '*.CanIgnoreReturnValue' ignoreFunctionCall: [ ] ImplicitDefaultLocale: active: true ImplicitUnitReturnType: active: false allowExplicitReturnType: true InvalidRange: active: true IteratorHasNextCallsNextMethod: active: true IteratorNotThrowingNoSuchElementException: active: true LateinitUsage: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: active: true MissingPackageDeclaration: active: false excludes: [ '**/*.kts' ] NullCheckOnMutableProperty: active: false NullableToStringCall: active: false UnconditionalJumpStatementInLoop: active: false UnnecessaryNotNullOperator: active: true UnnecessarySafeCall: active: true UnreachableCatchBlock: active: true UnreachableCode: active: true UnsafeCallOnNullableType: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UnsafeCast: active: true UnusedUnaryOperator: active: true UselessPostfixExpression: active: true WrongEqualsTypeParameter: active: true style: active: true CanBeNonNullable: active: false CascadingCallWrapping: active: false includeElvis: true ClassOrdering: active: false CollapsibleIfStatements: active: false DataClassContainsFunctions: active: false conversionFunctionPrefix: - 'to' DataClassShouldBeImmutable: active: false DestructuringDeclarationWithTooManyEntries: active: true maxDestructuringEntries: 6 EqualsNullCall: active: true EqualsOnSignatureLine: active: false ExplicitCollectionElementAccessMethod: active: false ExplicitItLambdaParameter: active: true ExpressionBodySyntax: active: false includeLineWrapping: false ForbiddenComment: active: false comments: - 'FIXME:' - 'STOPSHIP:' - 'TODO:' ForbiddenImport: active: false imports: [ ] forbiddenPatterns: '' ForbiddenMethodCall: active: false methods: - 'kotlin.io.print' - 'kotlin.io.println' ForbiddenSuppress: active: false rules: [ ] ForbiddenVoid: active: true ignoreOverridden: false ignoreUsageInGenerics: false FunctionOnlyReturningConstant: active: true ignoreOverridableFunction: true ignoreActualFunction: true excludedFunctions: - '' LoopWithTooManyJumpStatements: active: true maxJumpCount: 1 MagicNumber: active: false excludes: [ '**/test/**', '**/test-e2e/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/domain/**', '**/core/**', '**/*.kts' ] ignoreNumbers: - '-1' - '0' - '1' - '2' ignoreHashCodeFunction: true ignorePropertyDeclaration: false ignoreLocalVariableDeclaration: false ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true BracesOnIfStatements: active: false MandatoryBracesLoops: active: false MaxChainedCallsOnSameLine: active: false maxChainedCalls: 5 MaxLineLength: active: true maxLineLength: 140 excludePackageStatements: true excludeImportStatements: true excludeCommentStatements: false excludes: - '**/test/**' - '**/test-e2e/**' - '**/test-integration/**' MayBeConst: active: true ModifierOrder: active: true MultilineLambdaItParameter: active: false NestedClassesVisibility: active: true NewLineAtEndOfFile: active: true NoTabs: active: false NullableBooleanCheck: active: false ObjectLiteralToLambda: active: true OptionalAbstractKeyword: active: true OptionalUnit: active: false BracesOnWhenStatements: active: false PreferToOverPairSyntax: active: false ProtectedMemberInFinalClass: active: true RedundantExplicitType: active: false RedundantHigherOrderMapUsage: active: true RedundantVisibilityModifierRule: active: false ReturnCount: active: true max: 5 excludedFunctions: - 'equals' excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false SafeCast: active: true SerialVersionUIDInSerializableClass: active: true SpacingBetweenPackageAndImports: active: false ThrowsCount: active: true max: 2 excludeGuardClauses: false TrailingWhitespace: active: false UnderscoresInNumericLiterals: active: false acceptableLength: 4 allowNonStandardGrouping: false UnnecessaryAbstractClass: active: true UnnecessaryAnnotationUseSiteTarget: active: false UnnecessaryApply: active: true UnnecessaryBackticks: active: false UnnecessaryFilter: active: true UnnecessaryInheritance: active: true UnnecessaryInnerClass: active: false UnnecessaryLet: active: false UnnecessaryParentheses: active: false UntilInsteadOfRangeTo: active: false UnusedImports: active: false UnusedPrivateClass: active: true UnusedPrivateMember: active: true allowedNames: '(_|ignored|expected|serialVersionUID)' UseAnyOrNoneInsteadOfFind: active: true UseArrayLiteralsInAnnotations: active: true UseCheckNotNull: active: true UseCheckOrError: active: true UseDataClass: active: false allowVars: false UseEmptyCounterpart: active: false UseIfEmptyOrIfBlank: active: false UseIfInsteadOfWhen: active: false UseIsNullOrEmpty: active: true UseOrEmpty: active: true UseRequire: active: true UseRequireNotNull: active: true UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: active: true VarCouldBeVal: active: true ignoreLateinitVar: false WildcardImport: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] excludeImports: - 'java.util.*' ================================================ FILE: recipes/jvm/gradle/libs.versions.toml ================================================ [versions] kotlin = "2.3.21" kotlinx = "1.10.2" scala2x = "2.13.18" quarkus = "3.35.2" ktlint = "1.8.0" # Spring-Boot spring-boot = "3.5.14" spring-dependency-management = "1.1.7" spring-kafka = "3.3.15" # arrow arrow = "2.2.2.1" # Jackson jackson = "2.21" # Kafka kafka = "4.2.0" kafka-kotlin = "0.4.1" # Logging slf4j = "2.0.17" kotlinLogging = "8.0.02" # Ktor ktor = "3.4.3" koin = "4.2.1" # mongo mongodb = "5.7.0" # Tooling spotless = "8.4.0" detekt = "1.23.8" lombok = "1.18.46" # Misc hoplite = "2.9.0" kediatr = "4.3.0" # OpenTelemetry opentelemetry = "1.62.0" opentelemetry-instrumentation = "2.27.0" # gRPC grpc = "1.81.0" grpc-kotlin = "1.5.0" protobuf = "4.34.1" protobuf-plugin = "0.10.0" # db-scheduler db-scheduler = "16.8.1" # Testing stove = "1.0.0.529-SNAPSHOT" kotest = "6.1.11" exposed = "1.2.0" postgresql = "42.7.11" flyway = "12.6.0" [libraries] # Kotlin kotlinx-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version.ref = "kotlinx" } kotlinx-reactive = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactive", version.ref = "kotlinx" } kotlinx-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } kotlinx-jdk8 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8", version.ref = "kotlinx" } # Arrow arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } # Spring spring-boot-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot" } spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } spring-boot-annotationProcessor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" } spring-boot-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka" } spring-boot-data-r2dbc = { module = "org.springframework.boot:spring-boot-starter-data-r2dbc", version.ref = "spring-boot" } spring-boot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "spring-boot" } # db-scheduler db-scheduler-spring-boot-starter = { module = "com.github.kagkarlsson:db-scheduler-spring-boot-starter", version.ref = "db-scheduler" } # Quarkus quarkus = { module = "io.quarkus:quarkus-bom", version.ref = "quarkus" } quarkus-rest = { module = "io.quarkus:quarkus-rest", version.ref = "quarkus" } quarkus-arc = { module = "io.quarkus.arc:arc", version.ref = "quarkus" } quarkus-kotlin = { module = "io.quarkus:quarkus-kotlin", version.ref = "quarkus" } kafka = { module = "org.apache.kafka:kafka-clients", version.ref = "kafka" } kafkaKotlin = { module = "io.github.nomisrev:kotlin-kafka", version.ref = "kafka-kotlin" } # Jackson jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } ktor-server-config-yml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" } ktor-server-content-negotiation-jvm = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } ktor-serialization-jackson-json = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" } ktor-server-netty-jvm = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } ktor-server-statuspages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } ktor-server-callLogging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-autoHeadResponse = { module = "io.ktor:ktor-server-auto-head-response", version.ref = "ktor" } ktor-server-cachingHeaders = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor" } ktor-server-callId = { module = "io.ktor:ktor-server-call-id-jvm", version.ref = "ktor" } ktor-server-conditionalHeaders = { module = "io.ktor:ktor-server-conditional-headers", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" } ktor-server-defaultHeaders = { module = "io.ktor:ktor-server-default-headers", version.ref = "ktor" } ktor-swagger-ui = { module = "io.github.smiley4:ktor-swagger-ui", version = "5.7.0" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-plugins-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } koin = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } kotlinFpUtil = { module = "it.czerwinski:kotlin-util", version = "2.1.0" } mongodb-bson-kotlin = { module = "org.mongodb:bson-kotlin", version.ref = "mongodb" } mongodb-kotlin-coroutine = { module = "org.mongodb:mongodb-driver-kotlin-coroutine", version.ref = "mongodb" } kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlinLogging" } logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.32" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } hoplite = { module = "com.sksamuel.hoplite:hoplite-core", version.ref = "hoplite" } hoplite-yaml = { module = "com.sksamuel.hoplite:hoplite-yaml", version.ref = "hoplite" } ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } # kediatR kediatr-koin = { module = "com.trendyol:kediatr-koin-starter", version.ref = "kediatr" } # Tooling lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } # Testing kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" } testcontainers-kafka = { module = "org.testcontainers:kafka", version = "1.21.4" } exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", version.ref = "exposed" } exposed-json = { module = "org.jetbrains.exposed:exposed-json", version.ref = "exposed" } exposed-javaTime = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" } flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" } postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } postgresql-r2dbc = { module = "org.postgresql:r2dbc-postgresql", version = "1.1.1.RELEASE" } r2dbc-pool = { module = "io.r2dbc:r2dbc-pool", version = "1.0.2.RELEASE" } # OpenTelemetry opentelemetry-extension-kotlin = { module = "io.opentelemetry:opentelemetry-extension-kotlin", version.ref = "opentelemetry" } opentelemetry-instrumentation-annotations = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations", version.ref = "opentelemetry-instrumentation" } # gRPC grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } grpc-netty = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc" } grpc-kotlin-stub = { module = "io.grpc:grpc-kotlin-stub", version.ref = "grpc-kotlin" } protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } grpc-protoc-gen-java = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "grpc" } grpc-protoc-gen-kotlin = { module = "io.grpc:protoc-gen-grpc-kotlin", version.ref = "grpc-kotlin" } # Stove stove-bom = { module = "com.trendyol:stove-bom", version.ref = "stove" } # Scala scala2-library = { module = "org.scala-lang:scala-library", version.ref = "scala2x" } [plugins] protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } spring-plugin = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependencyManagement = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } testLogger = { id = "com.adarshr.test-logger", version = "4.0.0" } quarkus = { id = "io.quarkus", version.ref = "quarkus" } kotest = { id = "io.kotest", version.ref = "kotest" } ================================================ FILE: recipes/jvm/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: recipes/jvm/gradle.properties ================================================ org.gradle.parallel=false org.gradle.caching=true org.gradle.configuration-cache=true ================================================ FILE: recipes/jvm/gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: recipes/jvm/gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: recipes/jvm/java-recipes/build.gradle.kts ================================================ plugins { java kotlin("jvm") version libs.versions.kotlin idea } subprojects { apply { plugin("java") plugin("kotlin") plugin("idea") } val libs = rootProject.libs sourceSets { @Suppress("LocalVariableName", "ktlint:standard:property-naming") val `test-e2e` by creating { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output } val testE2eImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) } idea { module { testSources.from(sourceSets[TestFolders.e2e].allSource.sourceDirectories) testResources.from(sourceSets[TestFolders.e2e].resources.sourceDirectories) isDownloadJavadoc = true isDownloadSources = true } } dependencies { compileOnly(libs.lombok) annotationProcessor(libs.lombok) } dependencies { testCompileOnly(libs.lombok) testAnnotationProcessor(libs.lombok) } tasks.register("e2eTest") { description = "Runs e2e tests." group = "verification" testClassesDirs = sourceSets[TestFolders.e2e].output.classesDirs classpath = sourceSets[TestFolders.e2e].runtimeClasspath useJUnitPlatform() reports { junitXml.required.set(true) html.required.set(true) } } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/build.gradle.kts ================================================ plugins { alias(libs.plugins.quarkus) id("com.trendyol.stove.tracing") version libs.versions.stove.get() id("org.jetbrains.kotlin.plugin.allopen") version libs.versions.kotlin java } allOpen { annotation("jakarta.ws.rs.Path") annotation("jakarta.enterprise.context.ApplicationScoped") annotation("jakarta.persistence.Entity") annotation("io.quarkus.test.junit.QuarkusTest") } kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 javaParameters = true } } tasks.e2eTest { enabled = runningLocally } dependencies { implementation(enforcedPlatform(libs.quarkus)) implementation(libs.quarkus.rest) implementation(libs.quarkus.arc) implementation(libs.quarkus.kotlin) implementation(libs.logback.classic) implementation(libs.slf4j.api) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.core) } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stoveQuarkus) testImplementation(stoveLibs.stoveCouchbase) testImplementation(stoveLibs.stoveExtensionsKotest) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveTracing) testImplementation(stoveLibs.stoveWiremock) testImplementation(stoveLibs.stoveKafka) } stoveTracing { serviceName.set("quarkus-basic-recipe") testTaskNames.set(listOf("e2eTest")) otelAgentVersion.set(libs.opentelemetry.instrumentation.annotations.get().version!!) } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/EnglishGreetingService.java ================================================ package com.trendyol.stove.recipes.quarkus; import jakarta.enterprise.context.ApplicationScoped; @ApplicationScoped public class EnglishGreetingService implements GreetingService { @Override public String greet(String name) { return "Hello, " + name + "!"; } @Override public String getLanguage() { return "English"; } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/GreetingResource.java ================================================ package com.trendyol.stove.recipes.quarkus; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; @Path("/hello") public class GreetingResource { @Inject HelloService helloService; // Inject all GreetingService implementations to ensure they're registered @Inject Instance greetingServices; // Inject repository to prevent dead code elimination @Inject ItemRepository itemRepository; @GET @Produces(MediaType.TEXT_PLAIN) public String hello() { return helloService.hello(); } @GET @Path("/greetings") @Produces(MediaType.TEXT_PLAIN) public String greetings() { StringBuilder sb = new StringBuilder(); for (GreetingService gs : greetingServices) { sb.append(gs.getLanguage()).append(": ").append(gs.greet("World")).append("\n"); } return sb.toString(); } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/GreetingService.java ================================================ package com.trendyol.stove.recipes.quarkus; /** Interface for greeting services - demonstrates multiple implementations pattern. */ public interface GreetingService { String greet(String name); String getLanguage(); } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/HelloService.java ================================================ package com.trendyol.stove.recipes.quarkus; /** * Interface for HelloService - using interfaces is a CDI best practice and enables type-safe * testing through dynamic proxies. */ public interface HelloService { String hello(); } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/HelloServiceImpl.java ================================================ package com.trendyol.stove.recipes.quarkus; import jakarta.inject.Singleton; @Singleton public class HelloServiceImpl implements HelloService { @Override public String hello() { return "Hello from Quarkus Service"; } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/InMemoryItemRepository.java ================================================ package com.trendyol.stove.recipes.quarkus; import jakarta.enterprise.context.ApplicationScoped; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @ApplicationScoped public class InMemoryItemRepository implements ItemRepository { private final Map items = new ConcurrentHashMap<>(); @Override public void add(String id, String name) { items.put(id, name); } @Override public void addItem(Item item) { items.put(item.getId(), item.getName()); } @Override public String getById(String id) { return items.get(id); } @Override public Item getItemById(String id) { String name = items.get(id); return name != null ? new Item(id, name) : null; } @Override public List getAllIds() { return new ArrayList<>(items.keySet()); } @Override public void clear() { items.clear(); } @Override public int count() { return items.size(); } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/Item.java ================================================ package com.trendyol.stove.recipes.quarkus; /** Simple item class to demonstrate classloader limitations. */ public class Item { private final String id; private final String name; public Item(String id, String name) { this.id = id; this.name = name; } public String getId() { return id; } public String getName() { return name; } @Override public String toString() { return "Item{id='" + id + "', name='" + name + "'}"; } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/ItemRepository.java ================================================ package com.trendyol.stove.recipes.quarkus; import java.util.List; /** Simple repository interface for testing cross-classloader interactions. */ public interface ItemRepository { void add(String id, String name); void addItem(Item item); // Takes complex object - will fail across classloaders! String getById(String id); Item getItemById(String id); // Returns complex object List getAllIds(); void clear(); int count(); } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/QuarkusMainApp.java ================================================ package com.trendyol.stove.recipes.quarkus; import io.quarkus.runtime.Quarkus; import io.quarkus.runtime.annotations.QuarkusMain; @QuarkusMain public class QuarkusMainApp { public static void main(String[] args) { Quarkus.run(args); } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/SpanishGreetingService.java ================================================ package com.trendyol.stove.recipes.quarkus; import jakarta.enterprise.context.ApplicationScoped; @ApplicationScoped public class SpanishGreetingService implements GreetingService { @Override public String greet(String name) { return "¡Hola, " + name + "!"; } @Override public String getLanguage() { return "Spanish"; } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/StoveStartupSignal.java ================================================ package com.trendyol.stove.recipes.quarkus; import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; @ApplicationScoped public class StoveStartupSignal { public static final String READY_PROPERTY = "stove.quarkus.ready"; void onStart(@Observes StartupEvent event) { System.setProperty(READY_PROPERTY, "true"); } void onStop(@Observes ShutdownEvent event) { System.clearProperty(READY_PROPERTY); } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/java/com/trendyol/stove/recipes/quarkus/TurkishGreetingService.java ================================================ package com.trendyol.stove.recipes.quarkus; import jakarta.enterprise.context.ApplicationScoped; @ApplicationScoped public class TurkishGreetingService implements GreetingService { @Override public String greet(String name) { return "Merhaba, " + name + "!"; } @Override public String getLanguage() { return "Turkish"; } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/main/resources/application.properties ================================================ quarkus.profile=prod quarkus.config.profile.parent=prod quarkus.live-reload.enabled=false quarkus.http.port=8040 quarkus.virtual-threads.enabled=true quarkus.banner.enabled=false quarkus.devservices.enabled=false quarkus.naming.enable-jndi=true ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/test-e2e/kotlin/com/trendyol/stove/recipes/quarkus/e2e/setup/README.md ================================================ # Quarkus Recipe Setup This package provides Stove integration for the Quarkus recipe without relying on Quarkus bean bridging. ## What It Does - keeps the public `quarkus(runner, withParameters)` DSL unchanged - starts Quarkus by calling the provided `main` runner on a background thread - waits for an explicit Quarkus startup signal before tests run - shuts Quarkus down cleanly after the project - remains compatible with Stove tracing and failure reporting ## Important Files | File | Purpose | |------|---------| | `StoveConfig.kt` | Kotest project config and Stove systems | | `QuarkusSystem.kt` | `quarkus()` DSL and direct-main launcher | | `IndexTests.kt` | HTTP smoke test and tracing smoke test | ## Usage ```kotlin quarkus( runner = { params -> QuarkusMainApp.main(params) }, withParameters = listOf( "quarkus.http.port=8040" ) ) ``` The DSL shape stays the same and the recipe invokes `QuarkusMainApp.main(...)` from a dedicated launcher thread. Quarkus readiness is based on an application startup signal, so the launcher can work for both HTTP apps and worker-only apps. ## Tracing If `stove-tracing` is present and the `e2eTest` task is configured with the OpenTelemetry Java agent, the recipe can collect spans for Quarkus request flow. The recipe includes a tracing smoke test that verifies spans are emitted for a real HTTP request. ## Non-Goals - No `using` bridge support for Quarkus beans - No cross-classloader bean access - No Quarkus-specific DI adapter in this recipe ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/test-e2e/kotlin/com/trendyol/stove/recipes/quarkus/e2e/setup/StoveConfig.kt ================================================ package com.trendyol.stove.recipes.quarkus.e2e.setup import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.* import com.trendyol.stove.quarkus.quarkus import com.trendyol.stove.recipes.quarkus.QuarkusMainApp import com.trendyol.stove.system.* import com.trendyol.stove.tracing.tracing import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension /** * Kotest project configuration that sets up Stove TestSystem for Quarkus e2e tests. */ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { tracing { enableSpanReceiver() } httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8040" ) } quarkus( runner = { params -> QuarkusMainApp.main(params) }, withParameters = listOf( "quarkus.http.port=8040" ) ) }.run() } override suspend fun afterProject() { Stove.stop() } } ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/test-e2e/kotlin/com/trendyol/stove/recipes/quarkus/e2e/tests/IndexTests.kt ================================================ package com.trendyol.stove.recipes.quarkus.e2e.tests import com.trendyol.stove.http.http import com.trendyol.stove.system.stove import com.trendyol.stove.tracing.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class IndexTests : FunSpec({ test("Index page should return 200") { stove { http { get( "/hello", headers = mapOf( "Content-Type" to "text/plain", "Accept" to "text/plain" ) ) { actual -> actual shouldBe "Hello from Quarkus Service" } } } } test("tracing should capture quarkus request flow") { stove { http { get( "/hello", headers = mapOf( "Content-Type" to "text/plain", "Accept" to "text/plain" ) ) { actual -> actual shouldBe "Hello from Quarkus Service" } } tracing { val spans = waitForSpans(expectedCount = 2, timeoutMs = 10_000) spans.isNotEmpty() shouldBe true spanCountShouldBeAtLeast(2) spans.any { span -> span.operationName.contains("/hello") || span.attributes.values.any { value -> value.contains("/hello") } } shouldBe true } } } }) ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/test-e2e/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.recipes.quarkus.e2e.setup.StoveConfig ================================================ FILE: recipes/jvm/java-recipes/quarkus-basic-recipe/src/test-e2e/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/build.gradle.kts ================================================ plugins { alias(libs.plugins.spring.boot) alias(libs.plugins.spring.plugin) alias(libs.plugins.spring.dependencyManagement) } dependencies { implementation(libs.spring.boot.webflux) implementation(libs.spring.boot.autoconfigure) implementation(libs.spring.boot.kafka) implementation(libs.spring.boot.data.r2dbc) implementation(libs.postgresql.r2dbc) implementation(libs.postgresql) implementation(projects.shared.application) implementation(rootProject.projects.shared.domain) annotationProcessor(libs.spring.boot.annotationProcessor) } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stovePostgres) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveWiremock) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveSpring) testImplementation(libs.testcontainers.kafka) } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/ExampleSpringBootApp.java ================================================ package com.trendyol.stove.examples.java.spring; import java.util.function.Consumer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.kafka.annotation.EnableKafka; @SpringBootApplication @EnableKafka public class ExampleSpringBootApp { public static void main(String[] args) { run(args, application -> {}); } public static ConfigurableApplicationContext run( String[] args, Consumer applicationConsumer) { SpringApplication application = new SpringApplication(ExampleSpringBootApp.class); applicationConsumer.accept(application); return application.run(args); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/external/category/CategoryApiSpringConfiguration.java ================================================ package com.trendyol.stove.examples.java.spring.application.external.category; import com.trendyol.stove.recipes.shared.application.category.CategoryApiConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "external-apis.category") public class CategoryApiSpringConfiguration extends CategoryApiConfiguration {} ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/external/category/CategoryHttpApi.java ================================================ package com.trendyol.stove.examples.java.spring.application.external.category; import com.trendyol.stove.recipes.shared.application.BusinessException; import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse; import reactor.core.publisher.Mono; public interface CategoryHttpApi { Mono getCategoryById(int id) throws BusinessException; } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/external/category/CategoryHttpApiConfiguration.java ================================================ package com.trendyol.stove.examples.java.spring.application.external.category; import com.fasterxml.jackson.databind.ObjectMapper; import com.trendyol.stove.recipes.shared.application.ExternalApiConfiguration; import io.netty.channel.ChannelOption; import io.netty.handler.timeout.ReadTimeoutHandler; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; @Configuration @EnableConfigurationProperties(CategoryApiSpringConfiguration.class) public class CategoryHttpApiConfiguration { @Bean public CategoryHttpApi categoryHttpApi( CategoryApiSpringConfiguration categoryApiConfiguration, ObjectMapper objectMapper) { return new CategoryHttpApiImpl(webClient(categoryApiConfiguration, objectMapper)); } private WebClient webClient( ExternalApiConfiguration categoryApiConfiguration, ObjectMapper objectMapper) { var client = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, categoryApiConfiguration.getTimeout()) .doOnConnected(connection -> connection.addHandlerLast( new ReadTimeoutHandler(categoryApiConfiguration.getTimeout()))); return WebClient.builder() .baseUrl(categoryApiConfiguration.getUrl()) .clientConnector(new ReactorClientHttpConnector(client)) .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON)) .codecs(configurer -> { configurer .defaultCodecs() .jackson2JsonEncoder( new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON)); configurer .defaultCodecs() .jackson2JsonDecoder( new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON)); }) .build(); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/external/category/CategoryHttpApiImpl.java ================================================ package com.trendyol.stove.examples.java.spring.application.external.category; import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; public class CategoryHttpApiImpl implements CategoryHttpApi { private final WebClient categoryWebClient; public CategoryHttpApiImpl(WebClient categoryWebClient) { this.categoryWebClient = categoryWebClient; } @Override public Mono getCategoryById(int id) { return categoryWebClient .get() .uri("/categories/{id}", id) .retrieve() .bodyToMono(CategoryApiResponse.class); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/product/command/ProductApplicationService.java ================================================ package com.trendyol.stove.examples.java.spring.application.product.command; import com.trendyol.stove.examples.domain.product.Product; import com.trendyol.stove.examples.java.spring.application.external.category.CategoryHttpApi; import com.trendyol.stove.examples.java.spring.domain.ProductReactiveRepository; import com.trendyol.stove.recipes.shared.application.BusinessException; import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @Component public class ProductApplicationService { private final ProductReactiveRepository productRepository; private final CategoryHttpApi categoryHttpApi; public ProductApplicationService( ProductReactiveRepository productRepository, CategoryHttpApi categoryHttpApi) { this.productRepository = productRepository; this.categoryHttpApi = categoryHttpApi; } public Mono create(String name, double price, int categoryId) throws BusinessException { return categoryHttpApi .getCategoryById(categoryId) .filter(CategoryApiResponse::isActive) .switchIfEmpty(Mono.error(new BusinessException("Category is not active"))) .flatMap(categoryApiResponse -> { var product = Product.create(name, price, categoryApiResponse.id()); return productRepository.save(product); }); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/application/product/messaging/ProductEventHandlerListener.java ================================================ package com.trendyol.stove.examples.java.spring.application.product.messaging; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @Component public class ProductEventHandlerListener { @KafkaListener(topics = {"${kafka.topics.product.name}"}) public void listen(ConsumerRecord event) { System.out.println("Received event: " + event); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/domain/ProductReactiveRepository.java ================================================ package com.trendyol.stove.examples.java.spring.domain; import com.trendyol.stove.examples.domain.product.Product; import reactor.core.publisher.Mono; public interface ProductReactiveRepository { Mono findById(String id); Mono save(Product product); } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/http/ControllerAdvice.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.http; import com.trendyol.stove.recipes.shared.application.BusinessException; import com.trendyol.stove.recipes.shared.application.ErrorResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class ControllerAdvice { private final Logger logger = LoggerFactory.getLogger(ControllerAdvice.class); @ExceptionHandler(BusinessException.class) public ResponseEntity handleException(BusinessException e) { logger.error("Business exception occurred", e); return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ErrorResponse(e.getMessage(), "409")); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { logger.error("Exception occurred", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse(e.getMessage(), "500")); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaBeanConfiguration.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Duration; import java.util.Map; import java.util.Properties; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; import org.slf4j.Logger; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.listener.DefaultErrorHandler; import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; import org.springframework.kafka.support.serializer.JsonDeserializer; import org.springframework.kafka.support.serializer.JsonSerializer; import org.springframework.util.backoff.FixedBackOff; @Configuration public class KafkaBeanConfiguration { private final Logger logger = org.slf4j.LoggerFactory.getLogger(KafkaBeanConfiguration.class); @Bean @ConfigurationProperties(prefix = "kafka") public KafkaConfiguration kafkaConfiguration() { return new KafkaConfiguration(); } @Bean public TopicResolver topicResolver(KafkaConfiguration kafkaConfiguration) { return new TopicResolver(kafkaConfiguration); } @Bean public Properties consumerProperties(KafkaConfiguration kafkaConfiguration) { Properties properties = new Properties(); properties.put( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaConfiguration.getBootstrapServers()); properties.put(ConsumerConfig.GROUP_ID_CONFIG, kafkaConfiguration.getGroupId()); properties.put(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG, (int) Duration.ofSeconds(kafkaConfiguration.getRequestTimeoutSeconds()).toMillis()); properties.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, (int) Duration.ofSeconds(kafkaConfiguration.getHeartbeatIntervalSeconds()).toMillis()); properties.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, (int) Duration.ofSeconds(kafkaConfiguration.getSessionTimeoutSeconds()).toMillis()); properties.put( ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG, kafkaConfiguration.isAutoCreateTopics()); properties.put( ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, kafkaConfiguration.getAutoOffsetReset()); properties.put( ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); properties.put( ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class.getName()); properties.put( ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName()); properties.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); properties.put(JsonDeserializer.VALUE_DEFAULT_TYPE, Object.class.getName()); properties.put( ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, kafkaConfiguration.flattenInterceptorClasses()); logger.info("Kafka consumer properties: {}", properties); return properties; } @Bean public Properties producerProperties(KafkaConfiguration kafkaConfiguration) { Properties properties = new Properties(); properties.put( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaConfiguration.getBootstrapServers()); properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class.getName()); properties.put( ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, kafkaConfiguration.flattenInterceptorClasses()); return properties; } @Bean public KafkaTemplate kafkaTemplate(KafkaConfiguration kafkaConfiguration) { return new KafkaTemplate<>(new org.springframework.kafka.core.DefaultKafkaProducerFactory<>( toMap(producerProperties(kafkaConfiguration)))); } @Bean public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( KafkaConfiguration kafkaConfiguration, ObjectMapper objectMapper) { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setCommonErrorHandler(new DefaultErrorHandler(new FixedBackOff(10, 1))); factory.setRecordMessageConverter( new org.springframework.kafka.support.converter.JsonMessageConverter(objectMapper)); factory.setConsumerFactory(new org.springframework.kafka.core.DefaultKafkaConsumerFactory<>( toMap(consumerProperties(kafkaConfiguration)))); return factory; } private Map toMap(Properties properties) { return properties.entrySet().stream() .collect( java.util.stream.Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue)); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaConfiguration.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka; import java.util.Map; import lombok.Data; public @Data class KafkaConfiguration { String bootstrapServers; String groupId; long requestTimeoutSeconds = 30; long heartbeatIntervalSeconds = 3; long sessionTimeoutSeconds = 10; boolean autoCreateTopics = true; String autoOffsetReset = "earliest"; String[] interceptorClasses; Map topics; public String flattenInterceptorClasses() { return String.join(",", interceptorClasses); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/KafkaDomainEventPublisher.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka; import com.trendyol.stove.examples.domain.ddd.AggregateRoot; import com.trendyol.stove.examples.domain.ddd.EventPublisher; import java.util.stream.Stream; import org.apache.kafka.clients.producer.ProducerRecord; import org.slf4j.Logger; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @Component public class KafkaDomainEventPublisher implements EventPublisher { private final KafkaTemplate template; private final TopicResolver topicResolver; private final Logger logger = org.slf4j.LoggerFactory.getLogger(KafkaDomainEventPublisher.class); public KafkaDomainEventPublisher( KafkaTemplate template, TopicResolver topicResolver) { this.template = template; this.topicResolver = topicResolver; } @Override public void publishFor(AggregateRoot aggregateRoot) { mapEventsToProducerRecords(aggregateRoot).forEach(template::send); } private Stream> mapEventsToProducerRecords( AggregateRoot aggregateRoot) { return aggregateRoot.domainEvents().stream().map(event -> { var topic = topicResolver.resolve(aggregateRoot.getAggregateName()); logger.info("Publishing event {} to topic {}", event, topic.getName()); return new ProducerRecord<>(topic.getName(), aggregateRoot.getIdAsString(), event); }); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/Topic.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka; import lombok.Data; public @Data class Topic { String name; String retry; String deadLetter; } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/kafka/TopicResolver.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.kafka; public class TopicResolver { private final KafkaConfiguration kafkaConfiguration; public TopicResolver(KafkaConfiguration kafkaConfiguration) { this.kafkaConfiguration = kafkaConfiguration; } public Topic resolve(String aggregateName) { return kafkaConfiguration.getTopics().get(aggregateName); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/postgres/PostgresConfiguration.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.postgres; import io.r2dbc.spi.ConnectionFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.r2dbc.core.DatabaseClient; @Configuration public class PostgresConfiguration { @Bean public DatabaseClient databaseClient(ConnectionFactory connectionFactory) { return DatabaseClient.create(connectionFactory); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/boilerplate/serialization/JacksonConfiguration.java ================================================ package com.trendyol.stove.examples.java.spring.infra.boilerplate.serialization; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @Configuration public class JacksonConfiguration { public static ObjectMapper defaultObjectMapper() { return JsonMapper.builder() .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE) .findAndAddModules() .build() .findAndRegisterModules(); } @Bean @Primary public ObjectMapper objectMapper() { return defaultObjectMapper(); } @Bean public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { return builder -> builder.configure(defaultObjectMapper()); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/index/IndexController.java ================================================ package com.trendyol.stove.examples.java.spring.infra.components.index; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/") public class IndexController { @RequestMapping public String index() { return "Hello, World!"; } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/api/ProductController.java ================================================ package com.trendyol.stove.examples.java.spring.infra.components.product.api; import com.trendyol.stove.examples.java.spring.application.product.command.ProductApplicationService; import com.trendyol.stove.recipes.shared.application.BusinessException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; @RestController @RequestMapping("/products") public class ProductController { private final ProductApplicationService productService; public ProductController(ProductApplicationService productService) { this.productService = productService; } @PostMapping public Mono> createProduct(@RequestBody ProductCreateRequest request) throws BusinessException { return productService .create(request.getName(), request.getPrice(), request.getCategoryId()) .onErrorContinue((throwable, o) -> ResponseEntity.badRequest().build()) .then(Mono.fromCallable(() -> ResponseEntity.ok().build())); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/api/ProductCreateRequest.java ================================================ package com.trendyol.stove.examples.java.spring.infra.components.product.api; import lombok.Data; @Data public class ProductCreateRequest { String name; double price; int categoryId; public ProductCreateRequest(String name, double price, int categoryId) { this.name = name; this.price = price; this.categoryId = categoryId; } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/java/com/trendyol/stove/examples/java/spring/infra/components/product/persistency/JdbcProductRepository.java ================================================ package com.trendyol.stove.examples.java.spring.infra.components.product.persistency; import com.trendyol.stove.examples.domain.ddd.EventPublisher; import com.trendyol.stove.examples.domain.product.Product; import com.trendyol.stove.examples.java.spring.domain.ProductReactiveRepository; import java.time.Instant; import org.springframework.r2dbc.core.DatabaseClient; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @Component public class JdbcProductRepository implements ProductReactiveRepository { private final DatabaseClient databaseClient; private final EventPublisher eventPublisher; public JdbcProductRepository(DatabaseClient databaseClient, EventPublisher eventPublisher) { this.databaseClient = databaseClient; this.eventPublisher = eventPublisher; } @Override public Mono findById(String id) { return databaseClient .sql("SELECT * FROM products WHERE id = :id") .bind("id", id) .map(row -> { String productId = row.get("id", String.class); String name = row.get("name", String.class); Double price = row.get("price", Double.class); Integer categoryId = row.get("category_id", Integer.class); Long version = row.get("version", Long.class); return Product.fromPersistency( productId, name, price, categoryId, version != null ? version : 0L); }) .one(); } public Mono save(Product product) { return databaseClient .sql(""" INSERT INTO products (id, name, price, category_id, created_date, version) VALUES (:id, :name, :price, :categoryId, :createdDate, :version) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, category_id = EXCLUDED.category_id, version = EXCLUDED.version """) .bind("id", product.getIdAsString()) .bind("name", product.getName()) .bind("price", product.getPrice()) .bind("categoryId", product.getCategoryId()) .bind("createdDate", Instant.now()) .bind("version", product.getVersion()) .fetch() .rowsUpdated() .doOnSuccess(result -> eventPublisher.publishFor(product)) .then(); } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/main/resources/application.yml ================================================ server: port: 8080 spring: r2dbc: url: r2dbc:postgresql://localhost:5432/stove username: postgres password: postgres kafka: bootstrap-servers: localhost:9092 group-id: stove-java-spring-boot heartbeat-interval-seconds: 2 request-timeout-seconds: 30 session-timeout-seconds: 10 auto-create-topics: true auto-offset-reset: earliest interceptor-classes: [ ] topics: product: name: ${kafka.group-id}.product retry: ${kafka.topic.product}.retry dead-letter: ${kafka.topic.product}.error external-apis: category: url: http://localhost:9091 timeout: 30 ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/example/java/spring/e2e/setup/CreateProductsTableMigration.kt ================================================ package com.trendyol.stove.example.java.spring.e2e.setup import com.trendyol.stove.database.migrations.DatabaseMigration import com.trendyol.stove.postgres.PostgresSqlMigrationContext import org.slf4j.Logger import org.slf4j.LoggerFactory class CreateProductsTableMigration : DatabaseMigration { private val logger: Logger = LoggerFactory.getLogger(CreateProductsTableMigration::class.java) override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { logger.info("Creating products table") connection.operations.execute( """ DROP TABLE IF EXISTS products; CREATE TABLE IF NOT EXISTS products ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, price DOUBLE PRECISION NOT NULL, category_id INTEGER NOT NULL, created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, version BIGINT NOT NULL DEFAULT 0 ); """.trimIndent() ) logger.info("Products table created") } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/example/java/spring/e2e/setup/Stove.kt ================================================ package com.trendyol.stove.example.java.spring.e2e.setup import com.trendyol.stove.examples.java.spring.ExampleSpringBootApp import com.trendyol.stove.examples.java.spring.infra.boilerplate.serialization.JacksonConfiguration import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.postgres.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.* import com.trendyol.stove.system.Stove import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.ktor.serialization.jackson.* import org.springframework.kafka.support.serializer.JsonSerializer class Stove : AbstractProjectConfig() { init { stoveKafkaBridgePortDefault = "50052" System.setProperty(STOVE_KAFKA_BRIDGE_PORT, stoveKafkaBridgePortDefault) } override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080", contentConverter = JacksonConverter(JacksonConfiguration.defaultObjectMapper()) ) } bridge() wiremock { WireMockSystemOptions( port = 9091, serde = StoveSerde.jackson.anyByteArraySerde(JacksonConfiguration.defaultObjectMapper()) ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "spring.r2dbc.url=r2dbc:postgresql://${cfg.host}:${cfg.port}/stove", "spring.r2dbc.username=${cfg.username}", "spring.r2dbc.password=${cfg.password}" ) } ).migrations { register() } } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(JacksonConfiguration.defaultObjectMapper()), valueSerializer = JsonSerializer(JacksonConfiguration.defaultObjectMapper()), containerOptions = KafkaContainerOptions(tag = "8.0.3") { withStartupAttempts(3) }, configureExposedConfiguration = { listOf( "kafka.bootstrap-servers=${it.bootstrapServers}", "kafka.interceptor-classes=${it.interceptorClass}" ) } ) } springBoot( runner = { parameters -> ExampleSpringBootApp.run(parameters) { } }, withParameters = listOf() ) }.run() } override suspend fun afterProject() { Stove.stop() } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/example/java/spring/e2e/setup/TestData.kt ================================================ package com.trendyol.stove.example.java.spring.e2e.setup object TestData { object Random { fun positiveInt() = kotlin.random.Random.nextInt(1, Int.MAX_VALUE) } } ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/example/java/spring/e2e/tests/IndexTests.kt ================================================ package com.trendyol.stove.example.java.spring.e2e.tests import com.trendyol.stove.http.http import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class IndexTests : FunSpec({ test("Index page should be accessible") { stove { http { get("/") { actual -> actual shouldBe "Hello, World!" } } } } }) ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/example/java/spring/e2e/tests/product/CreateTests.kt ================================================ package com.trendyol.stove.example.java.spring.e2e.tests.product import arrow.core.some import com.trendyol.stove.example.java.spring.e2e.setup.TestData import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.domain.product.events.ProductCreatedEvent import com.trendyol.stove.examples.java.spring.domain.ProductReactiveRepository import com.trendyol.stove.examples.java.spring.infra.components.product.api.ProductCreateRequest import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse import com.trendyol.stove.system.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.springframework.http.HttpStatus import java.util.* import kotlin.time.Duration.Companion.seconds class CreateTests : FunSpec({ test("product can be created with valid category") { stove { val productName = TestData.Random.positiveInt().toString() val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) val categoryApiResponse = CategoryApiResponse( TestData.Random.positiveInt(), "category-name", true ) wiremock { mockGet( url = "/categories/${categoryApiResponse.id}", statusCode = 200, responseBody = categoryApiResponse.some() ) } http { val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) postAndExpectBody("/products", body = req.some()) { actual -> actual.status shouldBe 200 } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = '$productId'", mapper = { row -> Product.fromPersistency( row.string("id"), row.string("name"), row.double("price"), row.int("category_id"), row.long("version") ) } ) { products -> products.size shouldBe 1 products.first().id shouldBe productId.toString() products.first().name shouldBe productName products.first().price shouldBe 100.0 } } using { val product = findById(productId.toString()).toFuture().get() product.name shouldBe productName product.price shouldBe 100.0 product.categoryId shouldBe categoryApiResponse.id } kafka { shouldBePublished(10.seconds) { actual.price == 100.0 && actual.name == productName } shouldBeConsumed { actual.price == 100.0 && actual.name == productName } } } } test("when category is not active, product creation should fail") { stove { val productName = TestData.Random.positiveInt().toString() val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) val categoryApiResponse = CategoryApiResponse( TestData.Random.positiveInt(), "category-name", false ) wiremock { mockGet( url = "/categories/${categoryApiResponse.id}", statusCode = 200 ) } http { val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) postAndExpectBody("/products", body = req.some()) { actual -> actual.status shouldBe HttpStatus.CONFLICT.value() } } postgresql { shouldQuery( "SELECT * FROM products WHERE id = '$productId'", mapper = { row -> Product.fromPersistency( row.string("id"), row.string("name"), row.double("price"), row.int("category_id"), row.long("version") ) } ) { products -> products.size shouldBe 0 } } } } }) ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.example.java.spring.e2e.setup.Stove ================================================ FILE: recipes/jvm/java-recipes/spring-boot-postgres-recipe/src/test-e2e/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: recipes/jvm/kotlin-recipes/build.gradle.kts ================================================ plugins { kotlin("jvm") version libs.versions.kotlin idea } subprojects { apply { plugin("kotlin") plugin("idea") } dependencies { implementation(rootProject.projects.shared.domain) } sourceSets { create(TestFolders.e2e) { kotlin { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output srcDirs("src/test-e2e/kotlin") } } } val testE2eImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) idea { module { testSources.from(sourceSets[TestFolders.e2e].allSource.sourceDirectories) testResources.from(sourceSets[TestFolders.e2e].resources.sourceDirectories) isDownloadJavadoc = true isDownloadSources = true } } tasks.register("e2eTest") { description = "Runs e2e tests." group = "verification" testClassesDirs = sourceSets[TestFolders.e2e].output.classesDirs classpath = sourceSets[TestFolders.e2e].runtimeClasspath useJUnitPlatform() reports { junitXml.required.set(true) html.required.set(true) } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/build.gradle.kts ================================================ dependencies { implementation(projects.shared.domain) implementation(projects.shared.application) implementation(libs.ktor.server.core.jvm) implementation(libs.ktor.server.netty.jvm) implementation(libs.ktor.server.content.negotiation.jvm) implementation(libs.ktor.server.statuspages) implementation(libs.ktor.server.callLogging) implementation(libs.ktor.server.callId) implementation(libs.ktor.server.conditionalHeaders) implementation(libs.ktor.server.cors) implementation(libs.ktor.server.defaultHeaders) implementation(libs.ktor.server.cachingHeaders) implementation(libs.ktor.server.autoHeadResponse) implementation(libs.ktor.server.config.yml) implementation(libs.ktor.swagger.ui) implementation(libs.ktor.serialization.jackson.json) implementation(libs.koin) implementation(libs.koin.ktor) implementation(libs.slf4j.api) implementation(libs.arrow.core) implementation(libs.hoplite) implementation(libs.hoplite.yaml) implementation(libs.logback.classic) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.plugins.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.kotlinFpUtil) implementation(libs.kotlin.logging.jvm) implementation(libs.kediatr.koin) implementation(libs.mongodb.kotlin.coroutine) implementation(libs.mongodb.bson.kotlin) implementation(libs.kafkaKotlin) } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stoveMongodb) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveWiremock) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveKtor) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/ExampleStoveKtorApp.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.* import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.http.registerHttpClient import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.* import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kediatr.registerKediatR import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.mongo.configureMongo import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.* import com.trendyol.stove.examples.kotlin.ktor.infra.components.external.registerCategoryExternalHttpApi import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api.productApi import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.registerProductComponents import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.server.application.* import io.ktor.server.plugins.autohead.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.plugin.Koin val logger = KotlinLogging.logger("Stove Ktor Recipe") object ExampleStoveKtorApp { @JvmStatic fun main(args: Array) { run(args) } fun run(args: Array, wait: Boolean = true, configure: org.koin.core.module.Module = module { }): Application { val config = loadConfiguration(args) logger.info { "Starting Ktor application with config: $config" } return startKtorApplication(config, wait) { appModule(config, configure) } } } fun Application.appModule( config: RecipeAppConfig, overrides: org.koin.core.module.Module = module { } ) { install(Koin) { allowOverride(true) modules( module { single { config } single { config.externalApis.category } } ) registerAppDeps() registerHttpClient() registerKafka(config.kafka) modules(overrides) } configureRouting() configureExceptionHandling() configureContentNegotiation() configureConsumerEngine() } fun KoinApplication.registerAppDeps() { configureMongo() configureJackson() registerKediatR() registerProductComponents() registerCategoryExternalHttpApi() } fun Application.configureRouting() { install(AutoHeadResponse) routing { route("/") { get { call.respondText("Hello, World!") } } productApi() } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/RecipeAppConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application import com.trendyol.stove.examples.kotlin.ktor.application.external.CategoryApiConfiguration import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.Topic /** * Represents the main configuration */ data class RecipeAppConfig( val server: ServerConfig, val kafka: KafkaConfiguration, val mongo: MongoConfiguration, val externalApis: ExternalApisConfig ) data class ExternalApisConfig( val category: CategoryApiConfiguration ) /** * Represents the configuration of the checker. */ data class ServerConfig( /** * Port of the server. */ val port: Int = 8080, /** * Host of the server. */ val host: String = "", val name: String ) data class MongoConfiguration( val uri: String, val database: String ) data class KafkaConfiguration( val bootstrapServers: String, val groupId: String, val requestTimeoutSeconds: Long = 30, val heartbeatIntervalSeconds: Long = 3, val sessionTimeoutSeconds: Long = 10, val autoCreateTopics: Boolean = true, val autoOffsetReset: String = "earliest", val interceptorClasses: List, val topics: Map ) { fun flattenInterceptorClasses(): String = interceptorClasses.joinToString(",") } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApi.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.external import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse interface CategoryHttpApi { suspend fun getCategory(id: Int): CategoryApiResponse } data class CategoryApiConfiguration( val url: String, val timeout: Long ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApiImpl.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.external import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.http.* import kotlin.time.Duration.Companion.seconds class CategoryHttpApiImpl( private val httpClient: HttpClient, private val categoryApiConfiguration: CategoryApiConfiguration ) : CategoryHttpApi { override suspend fun getCategory(id: Int): CategoryApiResponse = httpClient .get("${categoryApiConfiguration.url}/categories/$id") { accept(ContentType.Application.Json) timeout { requestTimeoutMillis = categoryApiConfiguration.timeout.seconds.inWholeMilliseconds connectTimeoutMillis = categoryApiConfiguration.timeout.seconds.inWholeMilliseconds socketTimeoutMillis = categoryApiConfiguration.timeout.seconds.inWholeMilliseconds } }.body() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/ProductCommandHandler.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.product.command import com.trendyol.kediatr.* import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.kotlin.ktor.application.external.CategoryHttpApi import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.recipes.shared.application.BusinessException import io.github.oshai.kotlinlogging.KotlinLogging data class CreateProductCommand( val name: String, val price: Double, val categoryId: Int ) : Request.Unit class ProductCommandHandler( private val productRepository: ProductRepository, private val categoryHttpApi: CategoryHttpApi ) : RequestHandler.Unit { private val logger = KotlinLogging.logger { } override suspend fun handle(request: CreateProductCommand) { val category = categoryHttpApi.getCategory(request.categoryId) if (!category.isActive) { throw BusinessException("Category is not active") } productRepository.save(Product.create(request.name, request.price, request.categoryId)) logger.info { "Product saved: $request" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/handling.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.product.command import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.registerProductCommandHandling() { modules( module { single { ProductCommandHandler(get(), get()) } } ) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/domain/product/ProductRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.domain.product import arrow.core.Option import com.trendyol.stove.examples.domain.product.Product interface ProductRepository { suspend fun save(product: Product) suspend fun findById(id: String): Option } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/http/http.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.http import com.fasterxml.jackson.databind.ObjectMapper import io.github.oshai.kotlinlogging.* import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.jackson.* import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.registerHttpClient() { modules(module { single { createHttpClient(get()) } }) } private fun createHttpClient( objectMapper: ObjectMapper ): HttpClient = HttpClient(CIO) { install(Logging) { logger = object : Logger { private val logger: KLogger = KotlinLogging.logger("StoveHttpClient") override fun log(message: String) { logger.info { message } } } } install(ContentNegotiation) { register(ContentType.Application.Json, JacksonConverter(objectMapper)) } val logger = KotlinLogging.logger("StoveHttpClient") install(HttpTimeout) {} install(HttpRequestRetry) { maxRetries = 1 retryOnServerErrors() retryOnException(retryOnTimeout = true) exponentialDelay() modifyRequest { request -> logger.warn(cause) { "Retrying request: ${request.url}" } request.headers.append("X-Retry-Count", retryCount.toString()) } } defaultRequest { header(HttpHeaders.ContentType, ContentType.Application.Json) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/ConsumerEngine.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka class ConsumerEngine( private val supervisors: List> ) { fun start() { supervisors.forEach { it.start() } } fun stop() { supervisors.forEach { it.cancel() } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/ConsumerSupervisor.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import io.github.nomisRev.kafka.receiver.KafkaReceiver import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.* import kotlinx.coroutines.flow.flattenMerge import org.apache.kafka.clients.consumer.ConsumerRecord abstract class ConsumerSupervisor( private val kafkaReceiver: KafkaReceiver, private val maxConcurrency: Int ) { private val logger = KotlinLogging.logger("ConsumerSupervisor[${javaClass.simpleName}]") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) abstract val topics: List fun start() = scope.launch { logger.info { "Receiving records from topics: $topics" } subscribe() } @OptIn(ExperimentalCoroutinesApi::class) @Suppress("TooGenericExceptionCaught") private suspend fun subscribe() { kafkaReceiver .receiveAutoAck(topics) .flattenMerge(maxConcurrency) .collect { try { consume(it) } catch (e: Exception) { handleError(e, it) } } } abstract suspend fun consume(record: ConsumerRecord) protected open fun handleError(e: Exception, record: ConsumerRecord) { logger.error(e) { "Error while processing record: $record" } } fun cancel() { logger.info { "Cancelling consumer supervisor" } scope.cancel() } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/KafkaDomainEventPublisher.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.trendyol.stove.examples.domain.ddd.* import io.github.nomisRev.kafka.publisher.KafkaPublisher import kotlinx.coroutines.runBlocking import org.apache.kafka.clients.producer.ProducerRecord import org.slf4j.* class KafkaDomainEventPublisher( private val publisher: KafkaPublisher, private val topicResolver: TopicResolver ) : EventPublisher { private val logger: Logger = LoggerFactory.getLogger(KafkaDomainEventPublisher::class.java) override fun publishFor(aggregateRoot: AggregateRoot) = runBlocking { mapEventsToProducerRecords(aggregateRoot) .forEach { record -> publisher.publishScope { offer(record) } } } private fun mapEventsToProducerRecords( aggregateRoot: AggregateRoot ): List> = aggregateRoot .domainEvents() .map { event -> val topic: Topic = topicResolver(aggregateRoot.aggregateName) logger.info("Publishing event {} to topic {}", event, topic.name) ProducerRecord( topic.name, aggregateRoot.idAsString, event ) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/SerDe.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.fasterxml.jackson.module.kotlin.readValue import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.JacksonConfiguration import org.apache.kafka.common.serialization.* private val kafkaObjectMapperRef = JacksonConfiguration.default @Suppress("UNCHECKED_CAST") class StoveKafkaValueDeserializer : Deserializer { override fun deserialize( topic: String, data: ByteArray ): T = kafkaObjectMapperRef.readValue(data) as T } class StoveKafkaValueSerializer : Serializer { override fun serialize( topic: String, data: T ): ByteArray = kafkaObjectMapperRef.writeValueAsBytes(data) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/Topic.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka data class Topic( val name: String, val retry: String, val deadLetter: String, val maxRetry: Int = 1, val concurrency: Int = 1 ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/TopicResolver.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.trendyol.stove.examples.kotlin.ktor.application.* class TopicResolver( private val kafkaConfiguration: KafkaConfiguration ) { operator fun invoke(aggregateName: String): Topic = kafkaConfiguration.topics.getValue(aggregateName) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/kafka.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.mongodb.kotlin.client.coroutine.MongoClient import com.trendyol.stove.examples.domain.ddd.EventPublisher import com.trendyol.stove.examples.kotlin.ktor.application.KafkaConfiguration import io.github.nomisRev.kafka.publisher.* import io.github.nomisRev.kafka.receiver.* import io.ktor.server.application.* import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.koin.core.KoinApplication import org.koin.dsl.* import org.koin.ktor.ext.get import java.util.* import kotlin.time.Duration.Companion.seconds fun KoinApplication.registerKafka(kafkaConfiguration: KafkaConfiguration) { modules( module { single { kafkaConfiguration } single { kafkaPublisher(get()) } single { kafkaReceiver(get()) } single { ConsumerEngine(getAll()) } single { KafkaDomainEventPublisher(get(), get()) }.bind() single { TopicResolver(get()) } } ) } fun Application.configureConsumerEngine() { this.monitor.subscribe(ApplicationStarted) { val consumerEngine = get() consumerEngine.start() } this.monitor.subscribe(ApplicationStopPreparing) { val consumerEngine = get() consumerEngine.stop() get().close() } } private fun kafkaPublisher( kafkaConfiguration: KafkaConfiguration ): KafkaPublisher = KafkaPublisher( PublisherSettings( bootstrapServers = kafkaConfiguration.bootstrapServers, valueSerializer = StoveKafkaValueSerializer(), keySerializer = StringSerializer(), properties = Properties().apply { putAll( mapOf( ProducerConfig.INTERCEPTOR_CLASSES_CONFIG to kafkaConfiguration.flattenInterceptorClasses() ) ) } ) ) private fun kafkaReceiver( kafkaConfiguration: KafkaConfiguration ): KafkaReceiver = KafkaReceiver( ReceiverSettings( bootstrapServers = kafkaConfiguration.bootstrapServers, keyDeserializer = StringDeserializer(), valueDeserializer = StoveKafkaValueDeserializer(), groupId = kafkaConfiguration.groupId, autoOffsetReset = kafkaConfiguration.autoOffsetReset(), commitStrategy = CommitStrategy.ByTime((kafkaConfiguration.heartbeatIntervalSeconds + 1).seconds), pollTimeout = 2.seconds, properties = Properties().apply { putAll( mapOf( ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG to kafkaConfiguration.autoCreateTopics.toString(), ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to kafkaConfiguration.heartbeatIntervalSeconds.seconds.inWholeMilliseconds .toInt(), ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG to kafkaConfiguration.flattenInterceptorClasses() ) ) } ) ) private fun KafkaConfiguration.autoOffsetReset(): AutoOffsetReset = when (autoOffsetReset) { "earliest" -> AutoOffsetReset.Earliest "latest" -> AutoOffsetReset.Latest else -> throw IllegalArgumentException("Unknown auto offset reset value: $autoOffsetReset") } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kediatr/kediatr.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kediatr import com.trendyol.kediatr.* import com.trendyol.kediatr.koin.KediatRKoin import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.registerKediatR() { modules( module { single { KediatRKoin.getMediator() } single { LoggingPipelineBehaviour() } } ) } class LoggingPipelineBehaviour : PipelineBehavior { override suspend fun handle( request: TRequest, next: suspend (TRequest) -> TResponse ): TResponse { println("Handling request: $request") val response = next(request) println("Handled request: $request") return response } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/mongo/mongo.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.mongo import com.mongodb.* import com.mongodb.kotlin.client.coroutine.* import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import org.bson.UuidRepresentation import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.configureMongo() { modules(createMongoModule()) } private fun createMongoModule() = module { single { createMongoClient(get()) } single { createMongoDatabase(get(), get()) } } private fun createMongoClient(recipeAppConfig: RecipeAppConfig): MongoClient = MongoClient.create( MongoClientSettings .builder() .uuidRepresentation(UuidRepresentation.STANDARD) .applyConnectionString(ConnectionString(recipeAppConfig.mongo.uri)) .readConcern(ReadConcern.MAJORITY) .build() ) private fun createMongoDatabase(mongoClient: MongoClient, recipeAppConfig: RecipeAppConfig): MongoDatabase = mongoClient.getDatabase( recipeAppConfig.mongo.database ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/serialization/JacksonConfiguration.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.json.JsonMapper import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* import org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.ext.inject object JacksonConfiguration { val default: ObjectMapper = JsonMapper .builder() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE) .findAndAddModules() .build() .findAndRegisterModules() } fun KoinApplication.configureJackson() { modules(module { single { JacksonConfiguration.default } }) } fun Application.configureContentNegotiation() { val mapper: ObjectMapper by inject() install(ContentNegotiation) { register(ContentType.Application.Json, JacksonConverter(mapper)) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/util.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate import com.sksamuel.hoplite.* import com.sksamuel.hoplite.env.Environment import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import com.trendyol.stove.recipes.shared.application.BusinessException import io.github.oshai.kotlinlogging.* import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import org.slf4j.LoggerFactory @OptIn(ExperimentalHoplite::class) inline fun loadConfiguration(args: Array = arrayOf()): T = ConfigLoaderBuilder .default() .addEnvironmentSource() .addCommandLineSource(args) .withExplicitSealedTypes() .withEnvironment(AppEnv.toEnv()) .apply { when (AppEnv.current()) { AppEnv.Local -> { addResourceSource("/application.yaml", optional = true) } AppEnv.Prod -> { addResourceSource("/application-prod.yaml", optional = true) addResourceSource("/application.yaml", optional = true) } else -> { addResourceSource("/application.yaml", optional = true) } } }.build() .loadConfigOrThrow() enum class AppEnv( val env: String ) { Unspecified(""), Local(Environment.local.name), Prod(Environment.prod.name) ; companion object { fun current(): AppEnv = when (System.getenv("ENVIRONMENT")) { Unspecified.env -> Unspecified Local.env -> Local Prod.env -> Prod else -> Local } fun toEnv(): Environment = when (current()) { Local -> Environment.local Prod -> Environment.prod else -> Environment.local } } fun isLocal(): Boolean = this === Local fun isProd(): Boolean = this === Prod } fun startKtorApplication(config: RecipeAppConfig, wait: Boolean = true, configure: Application.() -> Unit): Application { val loggerName = configure.javaClass.name .split('$') .first() val server = embeddedServer( Netty, module = { this.configure() }, configure = { connector { port = config.server.port host = config.server.host } }, environment = applicationEnvironment { log = LoggerFactory.getLogger(loggerName) } ) return server.start(wait = wait).application } fun Application.configureExceptionHandling(logging: KLogger = KotlinLogging.logger {}) { install(StatusPages) { exception { call, reason -> when (reason) { is BusinessException -> { logging.warn(reason) { "A business exception occurred ${call.request.uri}" } call.respond(HttpStatusCode.Conflict, reason.message.toString()) } else -> { logging.error(reason) { "An unexpected error occurred ${call.request.uri}" } call.respond(HttpStatusCode.InternalServerError, "An unexpected error occurred, please try again later") } } } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/external/category.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.external import com.trendyol.stove.examples.kotlin.ktor.application.external.* import org.koin.core.KoinApplication import org.koin.dsl.bind fun KoinApplication.registerCategoryExternalHttpApi() { modules(createCategoryExternalApi()) } private fun createCategoryExternalApi() = org.koin.dsl.module { single { CategoryHttpApiImpl(get(), get()) }.bind() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/api/routing.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api import com.trendyol.kediatr.Mediator import com.trendyol.stove.examples.kotlin.ktor.application.product.command.CreateProductCommand import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.ktor.ext.get fun Routing.productApi() { post("/products") { val mediator = call.get() val req = call.receive() mediator.send(CreateProductCommand(req.name, req.price, req.categoryId)) call.respond("Product created") } } data class ProductCreateRequest( var name: String, var price: Double, var categoryId: Int ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/defs.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product import com.trendyol.stove.examples.kotlin.ktor.application.product.command.registerProductCommandHandling import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.ConsumerSupervisor import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.messaging.ProductAggregateRootEventsConsumer import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency.MongoProductRepository import org.koin.core.KoinApplication import org.koin.dsl.* fun KoinApplication.registerProductComponents() { modules( module { single { MongoProductRepository(get(), get(), get()) }.bind() single { ProductAggregateRootEventsConsumer(get(), get()) }.bind>() } ) registerProductCommandHandling() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/messaging/ProductAggregateRootEventsConsumer.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.messaging import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.* import io.github.nomisRev.kafka.receiver.KafkaReceiver import io.github.oshai.kotlinlogging.KotlinLogging import org.apache.kafka.clients.consumer.ConsumerRecord class ProductAggregateRootEventsConsumer( topicResolver: TopicResolver, kafkaReceiver: KafkaReceiver, topic: Topic = topicResolver("product") ) : ConsumerSupervisor(kafkaReceiver, topic.concurrency) { private val logger = KotlinLogging.logger { } override val topics: List = listOf(topic.name, topic.retry) override suspend fun consume(record: ConsumerRecord) { logger.info { "consumed record: $record" } } override fun handleError(e: Exception, record: ConsumerRecord) { logger.error(e) { "Error while processing record: $record" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/MongoProductRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency import arrow.core.* import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.convertValue import com.mongodb.client.model.Filters import com.mongodb.kotlin.client.coroutine.MongoDatabase import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.KafkaDomainEventPublisher import kotlinx.coroutines.flow.firstOrNull import org.bson.Document import org.bson.json.JsonWriterSettings import org.bson.types.ObjectId class MongoProductRepository( mongo: MongoDatabase, private val objectMapper: ObjectMapper, private val eventPublisher: KafkaDomainEventPublisher ) : ProductRepository { private val collection = mongo.getCollection(PRODUCT_COLLECTION) override suspend fun save(product: Product) { val doc = Document(objectMapper.convertValue>(product)) doc[RESERVED_ID] = ObjectId.get() collection.insertOne(doc) eventPublisher.publishFor(product) } override suspend fun findById(id: String): Option = collection .find(Filters.eq("id", id)) .firstOrNull() ?.let { objectMapper.convertValue(it, Product::class.java) } .toOption() companion object { private const val RESERVED_ID = "_id" const val PRODUCT_COLLECTION = "products" } } object MongoJsonWriterSettings { val default: JsonWriterSettings = JsonWriterSettings .builder() .objectIdConverter { value, writer -> writer.writeString(value.toHexString()) } .build() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/main/resources/application.yaml ================================================ server: port: 8081 host: "localhost" name: "test" mongo: database: stove-kotlin-ktor uri: localhost:27017 kafka: bootstrap-servers: localhost:9092 group-id: stove-kotlin-ktor heartbeat-interval-seconds: 2 request-timeout-seconds: 30 session-timeout-seconds: 10 auto-create-topics: true auto-offset-reset: earliest interceptor-classes: [ ] topics: product: name: stove-kotlin-ktor.product retry: stove-kotlin-ktor.retry dead-letter: stove-kotlin-ktor.error concurrency: 2 maxRetry: 1 external-apis: category: url: http://localhost:9090 timeout: 30 ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/StoveConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.setup import com.trendyol.stove.examples.kotlin.ktor.ExampleStoveKtorApp import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.JacksonConfiguration import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency.MongoProductRepository.Companion.PRODUCT_COLLECTION import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.ktor.* import com.trendyol.stove.mongodb.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.Stove import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.ktor.serialization.jackson.* import org.koin.dsl.module private const val DATABASE = "stove-kotlin-ktor" class StoveConfig : AbstractProjectConfig() { override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8081", contentConverter = JacksonConverter(JacksonConfiguration.default) ) } bridge() wiremock { WireMockSystemOptions( port = 9090 ) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(JacksonConfiguration.default), containerOptions = KafkaContainerOptions(tag = "8.0.3"), configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptor-classes=${cfg.interceptorClass}" ) } ) } mongodb { MongodbSystemOptions( databaseOptions = DatabaseOptions(DatabaseOptions.DefaultDatabase(DATABASE, collection = PRODUCT_COLLECTION)), container = MongoContainerOptions(), configureExposedConfiguration = { cfg -> listOf( "mongo.database=$DATABASE", "mongo.uri=${cfg.connectionString}/?retryWrites=true&w=majority" ) } ) } ktor( runner = { parameters -> ExampleStoveKtorApp.run( parameters, wait = false, module { } ) }, withParameters = listOf( "server.name=${Thread.currentThread().name}" ) ) }.run() } override suspend fun afterProject() { Stove.stop() } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestData.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.setup object TestData { object Random { fun positiveInt() = kotlin.random.Random.nextInt(1, Int.MAX_VALUE) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/IndexTests.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests import com.trendyol.stove.http.http import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class IndexTests : FunSpec({ test("Index page should return 200") { stove { http { getResponse("/") { actual -> actual.status shouldBe 200 actual.body() shouldBe "Hello, World!" } } } } }) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/configuration/ConfigurationTests.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests.configuration import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import com.trendyol.stove.system.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe class ConfigurationTests : FunSpec({ test("configuration can be changed from app") { stove { using { this.server.name shouldNotBe "test" } } } }) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/product/CreateTests.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests.product import arrow.core.* import com.mongodb.client.model.Filters import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.domain.product.events.ProductCreatedEvent import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.examples.kotlin.ktor.e2e.setup.TestData import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api.ProductCreateRequest import com.trendyol.stove.functional.get import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.mongodb.mongodb import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse import com.trendyol.stove.system.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import java.util.* import kotlin.time.Duration.Companion.seconds class CreateTests : FunSpec({ test("product can be created with valid category") { stove { val productName = TestData.Random.positiveInt().toString() val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) val categoryApiResponse = CategoryApiResponse( TestData.Random.positiveInt(), "category-name", true ) wiremock { mockGet( url = "/categories/${categoryApiResponse.id}", statusCode = 200, responseBody = categoryApiResponse.some() ) } http { val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) postAndExpectBody("/products", body = req.some()) { actual -> actual.status shouldBe 200 } } mongodb { shouldQuery(Filters.eq("id", productId.toString()).toBsonDocument().toJson()) { actual -> actual.size shouldBe 1 actual[0].name shouldBe productName actual[0].price shouldBe 100.0 } } using { val product = findById(productId.toString()).get() product.name shouldBe productName product.price shouldBe 100.0 product.categoryId shouldBe categoryApiResponse.id } kafka { shouldBePublished(10.seconds) { actual.price == 100.0 && actual.name == productName } shouldBeConsumed(10.seconds) { actual.price == 100.0 && actual.name == productName } } } } test("when category is not active, product creation should fail") { stove { val productName = TestData.Random.positiveInt().toString() val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) val categoryApiResponse = CategoryApiResponse( TestData.Random.positiveInt(), "category-name", false ) wiremock { mockGet( url = "/categories/${categoryApiResponse.id}", statusCode = 200, responseBody = categoryApiResponse.some() ) } http { val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) postAndExpectBody("/products", body = req.some()) { actual -> actual.status shouldBe 409 } } mongodb { shouldQuery(Filters.eq("id", productId.toString()).toBsonDocument().toJson()) { actual -> actual.size shouldBe 0 } } using { findById(productId.toString()) shouldBe None } } } }) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.examples.kotlin.ktor.e2e.setup.StoveConfig ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-mongo-recipe/src/test-e2e/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/build.gradle.kts ================================================ dependencies { implementation(projects.shared.domain) implementation(projects.shared.application) implementation(libs.ktor.server.core.jvm) implementation(libs.ktor.server.netty.jvm) implementation(libs.ktor.server.content.negotiation.jvm) implementation(libs.ktor.server.statuspages) implementation(libs.ktor.server.callLogging) implementation(libs.ktor.server.callId) implementation(libs.ktor.server.conditionalHeaders) implementation(libs.ktor.server.cors) implementation(libs.ktor.server.defaultHeaders) implementation(libs.ktor.server.cachingHeaders) implementation(libs.ktor.server.autoHeadResponse) implementation(libs.ktor.server.config.yml) implementation(libs.ktor.swagger.ui) implementation(libs.ktor.serialization.jackson.json) implementation(libs.koin) implementation(libs.koin.ktor) implementation(libs.slf4j.api) implementation(libs.arrow.core) implementation(libs.hoplite) implementation(libs.hoplite.yaml) implementation(libs.logback.classic) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.plugins.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.kotlinFpUtil) implementation(libs.kotlin.logging.jvm) implementation(libs.kediatr.koin) implementation(libs.exposed.core) implementation(libs.exposed.r2dbc) implementation(libs.exposed.json) implementation(libs.exposed.javaTime) implementation(libs.postgresql.r2dbc) implementation(libs.postgresql) implementation(libs.r2dbc.pool) implementation(libs.flyway.core) implementation(libs.flyway.database.postgresql) implementation(libs.kafkaKotlin) } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stovePostgres) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveWiremock) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveKtor) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/ExampleStoveKtorApp.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.* import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.http.registerHttpClient import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.* import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kediatr.registerKediatR import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.* import com.trendyol.stove.examples.kotlin.ktor.infra.components.external.registerCategoryExternalHttpApi import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api.productApi import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.registerProductComponents import com.trendyol.stove.examples.kotlin.ktor.infra.postgres.* import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.server.application.* import io.ktor.server.plugins.autohead.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.plugin.Koin val logger = KotlinLogging.logger("Stove Ktor Recipe") object ExampleStoveKtorApp { @JvmStatic fun main(args: Array) { run(args) } fun run(args: Array, wait: Boolean = true, configure: org.koin.core.module.Module = module { }): Application { val config = loadConfiguration(args) logger.info { "Starting Ktor application with config: $config" } return startKtorApplication(config, wait) { appModule(config, configure) } } } fun Application.appModule( config: RecipeAppConfig, overrides: org.koin.core.module.Module = module { } ) { install(Koin) { allowOverride(true) modules( module { single { config } single { config.externalApis.category } single { config.db } } ) registerAppDeps() registerHttpClient() registerKafka(config.kafka) modules(overrides) } configureRouting() configureExceptionHandling() configureContentNegotiation() configureConsumerEngine() configureFlyway() } fun KoinApplication.registerAppDeps() { configurePostgres() configureJackson() registerKediatR() registerProductComponents() registerCategoryExternalHttpApi() } fun Application.configureRouting() { install(AutoHeadResponse) routing { route("/") { get { call.respondText("Hello, World!") } } productApi() } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/RecipeAppConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application import com.trendyol.stove.examples.kotlin.ktor.application.external.CategoryApiConfiguration import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.Topic import com.trendyol.stove.examples.kotlin.ktor.infra.postgres.R2dbcProperties /** * Represents the main configuration */ data class RecipeAppConfig( val server: ServerConfig, val kafka: KafkaConfiguration, val db: R2dbcProperties, val externalApis: ExternalApisConfig ) data class ExternalApisConfig( val category: CategoryApiConfiguration ) /** * Represents the configuration of the checker. */ data class ServerConfig( /** * Port of the server. */ val port: Int = 8082, /** * Host of the server. */ val host: String = "", val name: String ) data class KafkaConfiguration( val bootstrapServers: String, val groupId: String, val requestTimeoutSeconds: Long = 30, val heartbeatIntervalSeconds: Long = 3, val sessionTimeoutSeconds: Long = 10, val autoCreateTopics: Boolean = true, val autoOffsetReset: String = "earliest", val interceptorClasses: List, val topics: Map ) { fun flattenInterceptorClasses(): String = interceptorClasses.joinToString(",") } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApi.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.external import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse interface CategoryHttpApi { suspend fun getCategory(id: Int): CategoryApiResponse } data class CategoryApiConfiguration( val url: String, val timeout: Long ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/external/CategoryHttpApiImpl.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.external import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.http.* import kotlin.time.Duration.Companion.seconds class CategoryHttpApiImpl( private val httpClient: HttpClient, private val categoryApiConfiguration: CategoryApiConfiguration ) : CategoryHttpApi { override suspend fun getCategory(id: Int): CategoryApiResponse = httpClient .get("${categoryApiConfiguration.url}/categories/$id") { accept(ContentType.Application.Json) timeout { requestTimeoutMillis = categoryApiConfiguration.timeout.seconds.inWholeMilliseconds connectTimeoutMillis = categoryApiConfiguration.timeout.seconds.inWholeMilliseconds socketTimeoutMillis = categoryApiConfiguration.timeout.seconds.inWholeMilliseconds } }.body() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/ProductCommandHandler.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.product.command import com.trendyol.kediatr.* import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.kotlin.ktor.application.external.CategoryHttpApi import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.recipes.shared.application.BusinessException import io.github.oshai.kotlinlogging.KotlinLogging data class CreateProductCommand( val name: String, val price: Double, val categoryId: Int ) : Request.Unit class ProductCommandHandler( private val productRepository: ProductRepository, private val categoryHttpApi: CategoryHttpApi ) : RequestHandler.Unit { private val logger = KotlinLogging.logger { } override suspend fun handle(request: CreateProductCommand) { val category = categoryHttpApi.getCategory(request.categoryId) if (!category.isActive) { throw BusinessException("Category is not active") } productRepository.save(Product.create(request.name, request.price, request.categoryId)) logger.info { "Product saved: $request" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/application/product/command/handling.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.application.product.command import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.registerProductCommandHandling() { modules( module { single { ProductCommandHandler(get(), get()) } } ) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/domain/product/ProductRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.domain.product import arrow.core.Option import com.trendyol.stove.examples.domain.product.Product interface ProductRepository { suspend fun save(product: Product) suspend fun findById(id: String): Option } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/http/http.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.http import com.fasterxml.jackson.databind.ObjectMapper import io.github.oshai.kotlinlogging.* import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.jackson.* import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.registerHttpClient() { modules(module { single { createHttpClient(get()) } }) } private fun createHttpClient( objectMapper: ObjectMapper ): HttpClient = HttpClient(CIO) { install(Logging) { logger = object : Logger { private val logger: KLogger = KotlinLogging.logger("StoveHttpClient") override fun log(message: String) { logger.info { message } } } } install(ContentNegotiation) { register(ContentType.Application.Json, JacksonConverter(objectMapper)) } val logger = KotlinLogging.logger("StoveHttpClient") install(HttpTimeout) {} install(HttpRequestRetry) { maxRetries = 1 retryOnServerErrors() retryOnException(retryOnTimeout = true) exponentialDelay() modifyRequest { request -> logger.warn(cause) { "Retrying request: ${request.url}" } request.headers.append("X-Retry-Count", retryCount.toString()) } } defaultRequest { header(HttpHeaders.ContentType, ContentType.Application.Json) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/ConsumerEngine.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka class ConsumerEngine( private val supervisors: List> ) { fun start() { supervisors.forEach { it.start() } } fun stop() { supervisors.forEach { it.cancel() } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/ConsumerSupervisor.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import io.github.nomisRev.kafka.receiver.KafkaReceiver import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.* import kotlinx.coroutines.flow.flattenMerge import org.apache.kafka.clients.consumer.ConsumerRecord abstract class ConsumerSupervisor( private val kafkaReceiver: KafkaReceiver, private val maxConcurrency: Int ) { private val logger = KotlinLogging.logger("ConsumerSupervisor[${javaClass.simpleName}]") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) abstract val topics: List fun start() = scope.launch { logger.info { "Receiving records from topics: $topics" } subscribe() } @OptIn(ExperimentalCoroutinesApi::class) @Suppress("TooGenericExceptionCaught") private suspend fun subscribe() { kafkaReceiver .receiveAutoAck(topics) .flattenMerge(maxConcurrency) .collect { try { consume(it) } catch (e: Exception) { handleError(e, it) } } } abstract suspend fun consume(record: ConsumerRecord) protected open fun handleError(e: Exception, record: ConsumerRecord) { logger.error(e) { "Error while processing record: $record" } } fun cancel() { logger.info { "Cancelling consumer supervisor" } scope.cancel() } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/KafkaDomainEventPublisher.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.trendyol.stove.examples.domain.ddd.* import io.github.nomisRev.kafka.publisher.KafkaPublisher import kotlinx.coroutines.runBlocking import org.apache.kafka.clients.producer.ProducerRecord import org.slf4j.* class KafkaDomainEventPublisher( private val publisher: KafkaPublisher, private val topicResolver: TopicResolver ) : EventPublisher { private val logger: Logger = LoggerFactory.getLogger(KafkaDomainEventPublisher::class.java) override fun publishFor(aggregateRoot: AggregateRoot) = runBlocking { mapEventsToProducerRecords(aggregateRoot) .forEach { record -> publisher.publishScope { offer(record) } } } private fun mapEventsToProducerRecords( aggregateRoot: AggregateRoot ): List> = aggregateRoot .domainEvents() .map { event -> val topic: Topic = topicResolver(aggregateRoot.aggregateName) logger.info("Publishing event {} to topic {}", event, topic.name) ProducerRecord( topic.name, aggregateRoot.idAsString, event ) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/SerDe.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.fasterxml.jackson.module.kotlin.readValue import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.JacksonConfiguration import org.apache.kafka.common.serialization.* private val kafkaObjectMapperRef = JacksonConfiguration.default @Suppress("UNCHECKED_CAST") class StoveKafkaValueDeserializer : Deserializer { override fun deserialize( topic: String, data: ByteArray ): T = kafkaObjectMapperRef.readValue(data) as T } class StoveKafkaValueSerializer : Serializer { override fun serialize( topic: String, data: T ): ByteArray = kafkaObjectMapperRef.writeValueAsBytes(data) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/Topic.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka data class Topic( val name: String, val retry: String, val deadLetter: String, val maxRetry: Int = 1, val concurrency: Int = 1 ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/TopicResolver.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.trendyol.stove.examples.kotlin.ktor.application.* class TopicResolver( private val kafkaConfiguration: KafkaConfiguration ) { operator fun invoke(aggregateName: String): Topic = kafkaConfiguration.topics.getValue(aggregateName) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kafka/kafka.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka import com.trendyol.stove.examples.domain.ddd.EventPublisher import com.trendyol.stove.examples.kotlin.ktor.application.KafkaConfiguration import io.github.nomisRev.kafka.publisher.* import io.github.nomisRev.kafka.receiver.* import io.ktor.server.application.* import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.flywaydb.core.Flyway import org.koin.core.KoinApplication import org.koin.dsl.* import org.koin.ktor.ext.get import java.util.* import kotlin.time.Duration.Companion.seconds fun KoinApplication.registerKafka(kafkaConfiguration: KafkaConfiguration) { modules( module { single { kafkaConfiguration } single { kafkaPublisher(get()) } single { kafkaReceiver(get()) } single { ConsumerEngine(getAll()) } single { KafkaDomainEventPublisher(get(), get()) }.bind() single { TopicResolver(get()) } } ) } fun Application.configureConsumerEngine() { this.monitor.subscribe(ApplicationStarted) { val consumerEngine = get() consumerEngine.start() val flyway = get() flyway.migrate() } this.monitor.subscribe(ApplicationStopPreparing) { val consumerEngine = get() consumerEngine.stop() } } private fun kafkaPublisher( kafkaConfiguration: KafkaConfiguration ): KafkaPublisher = KafkaPublisher( PublisherSettings( bootstrapServers = kafkaConfiguration.bootstrapServers, valueSerializer = StoveKafkaValueSerializer(), keySerializer = StringSerializer(), properties = Properties().apply { putAll( mapOf( ProducerConfig.INTERCEPTOR_CLASSES_CONFIG to kafkaConfiguration.flattenInterceptorClasses() ) ) } ) ) private fun kafkaReceiver( kafkaConfiguration: KafkaConfiguration ): KafkaReceiver = KafkaReceiver( ReceiverSettings( bootstrapServers = kafkaConfiguration.bootstrapServers, keyDeserializer = StringDeserializer(), valueDeserializer = StoveKafkaValueDeserializer(), groupId = kafkaConfiguration.groupId, autoOffsetReset = kafkaConfiguration.autoOffsetReset(), commitStrategy = CommitStrategy.ByTime((kafkaConfiguration.heartbeatIntervalSeconds + 1).seconds), pollTimeout = 2.seconds, properties = Properties().apply { putAll( mapOf( ConsumerConfig.ALLOW_AUTO_CREATE_TOPICS_CONFIG to kafkaConfiguration.autoCreateTopics.toString(), ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to kafkaConfiguration.heartbeatIntervalSeconds.seconds.inWholeMilliseconds .toInt(), ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG to kafkaConfiguration.flattenInterceptorClasses() ) ) } ) ) private fun KafkaConfiguration.autoOffsetReset(): AutoOffsetReset = when (autoOffsetReset) { "earliest" -> AutoOffsetReset.Earliest "latest" -> AutoOffsetReset.Latest else -> throw IllegalArgumentException("Unknown auto offset reset value: $autoOffsetReset") } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/kediatr/kediatr.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kediatr import com.trendyol.kediatr.* import com.trendyol.kediatr.koin.KediatRKoin import org.koin.core.KoinApplication import org.koin.dsl.module fun KoinApplication.registerKediatR() { modules( module { single { KediatRKoin.getMediator() } single { LoggingPipelineBehaviour() } } ) } class LoggingPipelineBehaviour : PipelineBehavior { override suspend fun handle( request: TRequest, next: suspend (TRequest) -> TResponse ): TResponse { println("Handling request: $request") val response = next(request) println("Handled request: $request") return response } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/serialization/JacksonConfiguration.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.json.JsonMapper import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* import org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.ext.inject object JacksonConfiguration { val default: ObjectMapper = JsonMapper .builder() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE) .findAndAddModules() .build() .findAndRegisterModules() } fun KoinApplication.configureJackson() { modules(module { single { JacksonConfiguration.default } }) } fun Application.configureContentNegotiation() { val mapper: ObjectMapper by inject() install(ContentNegotiation) { register(ContentType.Application.Json, JacksonConverter(mapper)) } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/boilerplate/util.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate import com.sksamuel.hoplite.* import com.sksamuel.hoplite.env.Environment import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import com.trendyol.stove.recipes.shared.application.BusinessException import io.github.oshai.kotlinlogging.* import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import org.slf4j.LoggerFactory @OptIn(ExperimentalHoplite::class) inline fun loadConfiguration(args: Array = arrayOf()): T = ConfigLoaderBuilder .default() .addEnvironmentSource() .addCommandLineSource(args) .withExplicitSealedTypes() .withEnvironment(AppEnv.toEnv()) .apply { when (AppEnv.current()) { AppEnv.Local -> { addResourceSource("/application.yaml", optional = true) } AppEnv.Prod -> { addResourceSource("/application-prod.yaml", optional = true) addResourceSource("/application.yaml", optional = true) } else -> { addResourceSource("/application.yaml", optional = true) } } }.build() .loadConfigOrThrow() enum class AppEnv( val env: String ) { Unspecified(""), Local(Environment.local.name), Prod(Environment.prod.name) ; companion object { fun current(): AppEnv = when (System.getenv("ENVIRONMENT")) { Unspecified.env -> Unspecified Local.env -> Local Prod.env -> Prod else -> Local } fun toEnv(): Environment = when (current()) { Local -> Environment.local Prod -> Environment.prod else -> Environment.local } } fun isLocal(): Boolean = this === Local fun isProd(): Boolean = this === Prod } fun startKtorApplication(config: RecipeAppConfig, wait: Boolean = true, configure: Application.() -> Unit): Application { val loggerName = configure.javaClass.name .split('$') .first() val server = embeddedServer( Netty, module = { this.configure() }, configure = { connector { port = config.server.port host = config.server.host } }, environment = applicationEnvironment { log = LoggerFactory.getLogger(loggerName) } ) return server.start(wait = wait).application } fun Application.configureExceptionHandling(logging: KLogger = KotlinLogging.logger {}) { install(StatusPages) { exception { call, reason -> when (reason) { is BusinessException -> { logging.warn(reason) { "A business exception occurred ${call.request.uri}" } call.respond(HttpStatusCode.Conflict, reason.message.toString()) } else -> { logging.error(reason) { "An unexpected error occurred ${call.request.uri}" } call.respond(HttpStatusCode.InternalServerError, "An unexpected error occurred, please try again later") } } } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/external/category.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.external import com.trendyol.stove.examples.kotlin.ktor.application.external.* import org.koin.core.KoinApplication import org.koin.dsl.bind fun KoinApplication.registerCategoryExternalHttpApi() { modules(createCategoryExternalApi()) } private fun createCategoryExternalApi() = org.koin.dsl.module { single { CategoryHttpApiImpl(get(), get()) }.bind() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/api/routing.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api import com.trendyol.kediatr.Mediator import com.trendyol.stove.examples.kotlin.ktor.application.product.command.CreateProductCommand import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.ktor.ext.get fun Routing.productApi() { post("/products") { val mediator = call.get() val req = call.receive() mediator.send(CreateProductCommand(req.name, req.price, req.categoryId)) call.respond("Product created") } } data class ProductCreateRequest( var name: String, var price: Double, var categoryId: Int ) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/defs.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product import com.trendyol.stove.examples.kotlin.ktor.application.product.command.registerProductCommandHandling import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.ConsumerSupervisor import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.messaging.ProductAggregateRootEventsConsumer import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency.PostgresProductRepository import org.koin.core.KoinApplication import org.koin.dsl.* fun KoinApplication.registerProductComponents() { modules( module { single { PostgresProductRepository(get()) }.bind() single { ProductAggregateRootEventsConsumer(get(), get()) }.bind>() } ) registerProductCommandHandling() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/messaging/ProductAggregateRootEventsConsumer.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.messaging import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.* import io.github.nomisRev.kafka.receiver.KafkaReceiver import io.github.oshai.kotlinlogging.KotlinLogging import org.apache.kafka.clients.consumer.ConsumerRecord class ProductAggregateRootEventsConsumer( topicResolver: TopicResolver, kafkaReceiver: KafkaReceiver, topic: Topic = topicResolver("product") ) : ConsumerSupervisor(kafkaReceiver, topic.concurrency) { private val logger = KotlinLogging.logger { } override val topics: List = listOf(topic.name, topic.retry) override suspend fun consume(record: ConsumerRecord) { logger.info { "consumed record: $record" } } override fun handleError(e: Exception, record: ConsumerRecord) { logger.error(e) { "Error while processing record: $record" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/PostgresProductRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency import arrow.core.* import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.kafka.KafkaDomainEventPublisher import kotlinx.coroutines.flow.* import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.r2dbc.* import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction class PostgresProductRepository( private val eventPublisher: KafkaDomainEventPublisher ) : ProductRepository { override suspend fun save(product: Product) = suspendTransaction { if (product.isNew) { saveInternal(product) } else { update(product) } eventPublisher.publishFor(product) } private suspend fun saveInternal(product: Product) { ProductTable.insert { it[id] = product.id it[name] = product.name it[price] = product.price it[categoryId] = product.categoryId } } private suspend fun update(product: Product) { val updatedRows = ProductTable.update({ ProductTable.id eq product.id }) { it[name] = product.name it[price] = product.price it[categoryId] = product.categoryId it[version] = product.version } if (updatedRows == 0) { error("Product with id ${product.id} was updated concurrently.") } } override suspend fun findById( id: String ): Option = suspendTransaction { ProductTable .selectAll() .where { ProductTable.id eq id } .map { Product.fromPersistency( it[ProductTable.id], it[ProductTable.name], it[ProductTable.price], it[ProductTable.categoryId], it[ProductTable.version] ) }.firstOrNull() .toOption() } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/components/product/persistency/Product.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency import org.jetbrains.exposed.v1.core.Table /** * [com.trendyol.stove.examples.domain.product.Product] */ object ProductTable : Table("products") { val id = text("id") val name = text("name") val price = double("price") val categoryId = integer("category_id") val version = long("version") override val primaryKey = PrimaryKey(id) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/postgres/flyway.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.postgres import io.ktor.server.application.* import org.flywaydb.core.Flyway import org.koin.ktor.ext.get fun Application.configureFlyway() { this.monitor.subscribe(ApplicationStarted) { val logger = environment.log val options = get() if (options.flyway.enabled) { logger.info("Flyway enabled, starting migration...") val flyway = get() flyway.migrate() } else { logger.info("Flyway disabled, skipping migration...") } } } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/kotlin/com/trendyol/stove/examples/kotlin/ktor/infra/postgres/postgres.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.infra.postgres import org.flywaydb.core.Flyway import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase import org.koin.core.KoinApplication import org.koin.dsl.module import org.postgresql.ds.PGSimpleDataSource data class R2dbcProperties( val url: String, val username: String, val password: String, val flyway: Flyway, val driverClassName: String = "postgresql" ) { fun jdbcUrl(): String = url.replace("r2dbc:", "jdbc:") data class Flyway( val enabled: Boolean, val logLevel: String = "INFO", val table: String = "flyway_schema_history", val locations: String = "classpath:db/migration" ) } fun KoinApplication.configurePostgres() { modules(postgresModule()) } private fun postgresModule() = module { single(createdAtStart = true) { exposedDatabase(get()) } single { flyway(get()) } } fun exposedDatabase( postgresDbConfiguration: R2dbcProperties ): R2dbcDatabase = R2dbcDatabase.connect( url = postgresDbConfiguration.url, driver = postgresDbConfiguration.driverClassName, user = postgresDbConfiguration.username, password = postgresDbConfiguration.password ) fun flyway( r2dbcProperties: R2dbcProperties ): Flyway { val dataSource = PGSimpleDataSource().apply { setURL(r2dbcProperties.jdbcUrl()) user = r2dbcProperties.username password = r2dbcProperties.password } return Flyway .configure() .dataSource(dataSource) .locations(r2dbcProperties.flyway.locations) .baselineOnMigrate(true) .baselineVersion("0") .loggers("slf4j") .load() } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/resources/application.yaml ================================================ server: port: 8082 host: "localhost" name: "test" db: url: r2dbc:postgresql://localhost:5432/stove-kotlin-ktor password: password user: admin flyway: enabled: true locations: classpath:db/migration table: flyway_schema_history kafka: bootstrap-servers: localhost:9092 group-id: stove-kotlin-ktor heartbeat-interval-seconds: 2 request-timeout-seconds: 30 session-timeout-seconds: 10 auto-create-topics: true auto-offset-reset: earliest interceptor-classes: [ ] topics: product: name: stove-kotlin-ktor.product retry: stove-kotlin-ktor.retry dead-letter: stove-kotlin-ktor.error concurrency: 2 maxRetry: 1 external-apis: category: url: http://localhost:9095 timeout: 30 ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/main/resources/db/migration/V1__init.sql ================================================ create table products ( id text primary key, name varchar(100) not null, price numeric(10, 2) not null, category_id integer not null, version bigint not null default 0 ); ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/StoveConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.setup import com.trendyol.stove.examples.kotlin.ktor.ExampleStoveKtorApp import com.trendyol.stove.examples.kotlin.ktor.infra.boilerplate.serialization.JacksonConfiguration import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.ktor.* import com.trendyol.stove.postgres.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.Stove import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.ktor.serialization.jackson.* import org.koin.dsl.module private const val DATABASE = "stove-kotlin-ktor" class StoveConfig : AbstractProjectConfig() { init { stoveKafkaBridgePortDefault = "50054" } override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8082", contentConverter = JacksonConverter(JacksonConfiguration.default) ) } bridge() wiremock { WireMockSystemOptions( port = 9095 ) } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(JacksonConfiguration.default), containerOptions = KafkaContainerOptions(tag = "8.0.3"), configureExposedConfiguration = { cfg -> listOf( "kafka.bootstrapServers=${cfg.bootstrapServers}", "kafka.interceptor-classes=${cfg.interceptorClass}" ) } ) } postgresql { PostgresqlOptions( databaseName = DATABASE, configureExposedConfiguration = { cfg -> listOf( "db.url=${toR2dbcUrl(cfg.jdbcUrl)}", "db.username=${cfg.username}", "db.password=${cfg.password}", "db.flyway.enabled=true", "db.flyway.logLevel=INFO" ) } ) } ktor( runner = { parameters -> ExampleStoveKtorApp.run( parameters, wait = false, module { } ) }, withParameters = listOf( "server.name=${Thread.currentThread().name}" ) ) }.run() } override suspend fun afterProject() { Stove.stop() } private fun toR2dbcUrl(url: String): String = url.replace("jdbc:", "r2dbc:") } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/setup/TestData.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.setup import com.trendyol.stove.examples.domain.product.Product import kotliquery.Row object TestData { object Random { fun positiveInt() = kotlin.random.Random.nextInt(1, Int.MAX_VALUE) } } object ProductFrom { operator fun invoke(row: Row): Product = Product.fromPersistency( row.string("id"), row.string("name"), row.double("price"), row.int("category_id"), row.long("version") ) } ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/IndexTests.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests import com.trendyol.stove.http.http import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class IndexTests : FunSpec({ test("Index page should return 200") { stove { http { getResponse("/") { actual -> actual.status shouldBe 200 actual.body() shouldBe "Hello, World!" } } } } }) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/configuration/ConfigurationTests.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests.configuration import com.trendyol.stove.examples.kotlin.ktor.application.RecipeAppConfig import com.trendyol.stove.system.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe class ConfigurationTests : FunSpec({ test("configuration can be changed from app") { stove { using { this.server.name shouldNotBe "test" } } } }) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/ktor/e2e/tests/product/CreateTests.kt ================================================ package com.trendyol.stove.examples.kotlin.ktor.e2e.tests.product import arrow.core.* import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.domain.product.events.ProductCreatedEvent import com.trendyol.stove.examples.kotlin.ktor.domain.product.ProductRepository import com.trendyol.stove.examples.kotlin.ktor.e2e.setup.* import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.api.ProductCreateRequest import com.trendyol.stove.examples.kotlin.ktor.infra.components.product.persistency.ProductTable import com.trendyol.stove.functional.get import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.recipes.shared.application.category.CategoryApiResponse import com.trendyol.stove.system.* import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import java.util.* import kotlin.time.Duration.Companion.seconds class CreateTests : FunSpec({ test("product can be created with valid category") { stove { val productName = TestData.Random.positiveInt().toString() val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) val categoryApiResponse = CategoryApiResponse( TestData.Random.positiveInt(), "category-name", true ) wiremock { mockGet( url = "/categories/${categoryApiResponse.id}", statusCode = 200, responseBody = categoryApiResponse.some() ) } http { val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) postAndExpectBody("/products", body = req.some()) { actual -> actual.status shouldBe 200 } } postgresql { shouldQuery( "SELECT * FROM ${ProductTable.tableName} WHERE ${ProductTable.id.name} = '$productId'", parameters = emptyList(), mapper = ProductFrom::invoke ) { actual -> actual.size shouldBe 1 actual[0].name shouldBe productName actual[0].price shouldBe 100.0 } } using { val product = findById(productId.toString()).get() product.name shouldBe productName product.price shouldBe 100.0 product.categoryId shouldBe categoryApiResponse.id } kafka { shouldBePublished(10.seconds) { actual.price == 100.0 && actual.name == productName } shouldBeConsumed(10.seconds) { actual.price == 100.0 && actual.name == productName } } } } test("when category is not active, product creation should fail") { stove { val productName = TestData.Random.positiveInt().toString() val productId = UUID.nameUUIDFromBytes(productName.toByteArray()) val categoryApiResponse = CategoryApiResponse( TestData.Random.positiveInt(), "category-name", false ) wiremock { mockGet( url = "/categories/${categoryApiResponse.id}", statusCode = 200, responseBody = categoryApiResponse.some() ) } http { val req = ProductCreateRequest(productName, 100.0, categoryApiResponse.id) postAndExpectBody("/products", body = req.some()) { actual -> actual.status shouldBe 409 } } postgresql { shouldQuery( "SELECT * FROM ${ProductTable.tableName} WHERE ${ProductTable.id.name} = '$productId'", parameters = emptyList(), ProductFrom::invoke ) { actual -> actual.size shouldBe 0 } } using { findById(productId.toString()) shouldBe None } } } }) ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.examples.kotlin.ktor.e2e.setup.StoveConfig ================================================ FILE: recipes/jvm/kotlin-recipes/ktor-postgres-recipe/src/test-e2e/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/build.gradle.kts ================================================ plugins { alias(libs.plugins.spring.plugin) alias(libs.plugins.protobuf) id("com.trendyol.stove.tracing") version libs.versions.stove.get() } dependencies { // Spring Boot implementation(libs.spring.boot.webflux) implementation(libs.spring.boot.autoconfigure) implementation(libs.spring.boot.data.r2dbc) implementation(libs.spring.boot.kafka) // Database implementation(libs.postgresql.r2dbc) implementation(libs.r2dbc.pool) implementation(libs.spring.boot.starter.jdbc) // Required for db-scheduler implementation(libs.postgresql) // JDBC driver for db-scheduler // Scheduling implementation(libs.db.scheduler.spring.boot.starter) // Kotlin Coroutines implementation(libs.kotlinx.core) implementation(libs.kotlinx.reactive) implementation(libs.kotlinx.reactor) implementation(libs.kotlinx.jdk8) // Logging implementation(libs.kotlin.logging.jvm) // OpenTelemetry - for production-grade tracing implementation(libs.opentelemetry.extension.kotlin) implementation(libs.opentelemetry.instrumentation.annotations) // gRPC implementation(libs.grpc.protobuf) implementation(libs.grpc.stub) implementation(libs.grpc.netty) implementation(libs.grpc.kotlin.stub) implementation(libs.protobuf.kotlin) annotationProcessor(libs.spring.boot.annotationProcessor) } dependencies { // Stove Testing testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stovePostgres) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveWiremock) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveSpring) testImplementation(stoveLibs.stoveTracing) testImplementation(stoveLibs.stoveGrpc) // For testing our gRPC server testImplementation(stoveLibs.stoveGrpcMock) // For mocking external gRPC services testImplementation(stoveLibs.stoveExtensionsKotest) // Ktor client for streaming tests testImplementation(libs.ktor.client.websockets) testImplementation(libs.ktor.client.okhttp) testImplementation(libs.ktor.client.content.negotiation) testImplementation(libs.ktor.serialization.jackson.json) // Testcontainers testImplementation(libs.testcontainers.kafka) } // ============================================================================ // PROTOBUF CONFIGURATION - gRPC Code Generation // ============================================================================ protobuf { protoc { artifact = libs.protoc.get().toString() } plugins { create("grpc") { artifact = libs.grpc.protoc.gen.java.get().toString() } create("grpckt") { artifact = "${libs.grpc.protoc.gen.kotlin.get()}:jdk8@jar" } } generateProtoTasks { all().forEach { task -> task.plugins { create("grpc") create("grpckt") } task.builtins { create("kotlin") } } } } // ============================================================================ // TRACING SETUP - OpenTelemetry Java Agent // ============================================================================ stoveTracing { serviceName.set("stove-kotlin-spring-showcase") testTaskNames.set(listOf("e2eTest")) otelAgentVersion.set(libs.opentelemetry.instrumentation.annotations.get().version!!) } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/ExampleStoveSpringBootApp.kt ================================================ package com.trendyol.stove.examples.kotlin.spring import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.context.ConfigurableApplicationContext import org.springframework.http.MediaType import org.springframework.web.bind.annotation.* @SpringBootApplication class ExampleStoveSpringBootApp fun main(args: Array) { run(args) } fun run(args: Array, init: SpringApplication.() -> Unit = {}): ConfigurableApplicationContext = runApplication(*args) { init() } data class ExampleData( val id: Int, val name: String ) @RestController @RequestMapping("/api/streaming") class StreamingController { @GetMapping( "json", produces = [ MediaType.APPLICATION_NDJSON_VALUE ] ) fun json( @RequestParam load: Int = 100, @RequestParam delay: Long = 1 ): Flow = (1..load) .asFlow() .onEach { delay(delay) } .map { ExampleData(it, "name$it") } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/domain/order/Order.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.domain.order import java.time.Instant import java.util.UUID data class Order( val id: String = UUID.randomUUID().toString(), val userId: String, val productId: String, val amount: Double, val status: OrderStatus = OrderStatus.PENDING, val paymentTransactionId: String? = null, val createdAt: Instant = Instant.now() ) { fun confirm(paymentTransactionId: String): Order = copy( status = OrderStatus.CONFIRMED, paymentTransactionId = paymentTransactionId ) fun fail(): Order = copy(status = OrderStatus.FAILED) } enum class OrderStatus { PENDING, CONFIRMED, FAILED } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/domain/order/OrderController.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.domain.order import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/orders") class OrderController( private val orderService: OrderService ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) suspend fun createOrder( @RequestBody request: CreateOrderRequest ): OrderResponse { val order = orderService.createOrder( userId = request.userId, productId = request.productId, amount = request.amount ) return OrderResponse( orderId = order.id, userId = order.userId, productId = order.productId, amount = order.amount, status = order.status.name ) } @GetMapping("/{id}") suspend fun getOrder( @PathVariable id: String ): OrderResponse? { val order = orderService.getOrder(id) ?: return null return OrderResponse( orderId = order.id, userId = order.userId, productId = order.productId, amount = order.amount, status = order.status.name ) } } data class CreateOrderRequest( val userId: String, val productId: String, val amount: Double ) data class OrderResponse( val orderId: String, val userId: String, val productId: String, val amount: Double, val status: String ) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/domain/order/OrderRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.domain.order interface OrderRepository { suspend fun save(order: Order): Order suspend fun findById(id: String): Order? suspend fun findByUserId(userId: String): List } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/domain/order/OrderService.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.domain.order import com.trendyol.stove.examples.kotlin.spring.events.OrderCreatedEvent import com.trendyol.stove.examples.kotlin.spring.events.PaymentProcessedEvent import com.trendyol.stove.examples.kotlin.spring.infra.clients.FraudDetectedException import com.trendyol.stove.examples.kotlin.spring.infra.clients.FraudDetectionClient import com.trendyol.stove.examples.kotlin.spring.infra.clients.InventoryClient import com.trendyol.stove.examples.kotlin.spring.infra.clients.InventoryNotAvailableException import com.trendyol.stove.examples.kotlin.spring.infra.clients.PaymentClient import com.trendyol.stove.examples.kotlin.spring.infra.clients.PaymentFailedException import com.trendyol.stove.examples.kotlin.spring.infra.clients.PaymentResult import com.trendyol.stove.examples.kotlin.spring.infra.kafka.OrderEventPublisher import com.trendyol.stove.examples.kotlin.spring.infra.scheduling.EmailSchedulerService import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.stereotype.Service import java.util.UUID private val logger = KotlinLogging.logger {} /** * Order Service - The main orchestrator for order creation. * * Each step maps directly to a Stove DSL section in TheShowcase.kt: * * ┌─────────────────────────────────────────────────────────────────────┐ * │ SERVICE METHOD │ STOVE DSL │ * ├─────────────────────────────────────────────────────────────────────┤ * │ 1. checkFraudViaGrpc() │ grpcMock { mockUnary(...) } │ * │ 2. checkInventoryViaRest() │ wiremock { mockGet(...) } │ * │ 3. processPaymentViaRest() │ wiremock { mockPost(...) } │ * │ 4. saveOrderToDatabase() │ postgresql { shouldQuery(...) } │ * │ 5. publishEventsToKafka() │ kafka { shouldBePublished(...) } │ * │ 6. scheduleConfirmationEmail() │ tasks { shouldBeExecuted(...) } │ * └─────────────────────────────────────────────────────────────────────┘ */ @Service class OrderService( private val orderRepository: OrderRepository, private val inventoryClient: InventoryClient, private val paymentClient: PaymentClient, private val fraudDetectionClient: FraudDetectionClient, private val eventPublisher: OrderEventPublisher, private val emailSchedulerService: EmailSchedulerService ) { /** * Creates a new order - the main flow that demonstrates all integrations. * * Flow: * 1. Check fraud via gRPC → Stove: grpcMock {} * 2. Check inventory via REST → Stove: wiremock {} * 3. Process payment via REST → Stove: wiremock {} * 4. Save order to database → Stove: postgresql {} * 5. Publish events to Kafka → Stove: kafka {} * 6. Schedule confirmation email → Stove: tasks {} */ @WithSpan("OrderService.createOrder") suspend fun createOrder( userId: String, productId: String, amount: Double ): Order { logger.info { "═══ Creating order for user=$userId, product=$productId, amount=$amount ═══" } val orderId = UUID.randomUUID().toString() // Step 1: Check fraud via gRPC service → grpcMock { mockUnary(...) } checkFraudViaGrpc(orderId, userId, amount, productId) // Step 2: Check inventory via REST API → wiremock { mockGet(...) } checkInventoryViaRest(productId) // Step 3: Process payment via REST API → wiremock { mockPost(...) } val payment = processPaymentViaRest(userId, amount) // Step 4: Save order to database → postgresql { shouldQuery(...) } val savedOrder = saveOrderToDatabase(orderId, userId, productId, amount, payment.transactionId!!) // Step 5: Publish events to Kafka → kafka { shouldBePublished(...) } publishEventsToKafka(savedOrder, payment.transactionId) // Step 6: Schedule confirmation email → tasks { shouldBeExecuted(...) } scheduleConfirmationEmail(savedOrder) logger.info { "═══ Order completed: id=${savedOrder.id}, status=${savedOrder.status} ═══" } return savedOrder } // ════════════════════════════════════════════════════════════════════════════ // Step 1: gRPC Integration → Tested with: grpcMock { mockUnary(...) } // ════════════════════════════════════════════════════════════════════════════ @WithSpan("OrderService.checkFraudViaGrpc") private suspend fun checkFraudViaGrpc( orderId: String, userId: String, amount: Double, productId: String ) { logger.info { "→ Checking fraud via gRPC for order=$orderId" } val fraudCheck = fraudDetectionClient.checkFraud(orderId, userId, amount, productId) if (fraudCheck.isFraudulent) { logger.warn { "✗ Fraud detected: reason=${fraudCheck.reason}" } throw FraudDetectedException(fraudCheck.reason) } logger.info { "✓ Fraud check passed: riskScore=${fraudCheck.riskScore}" } } // ════════════════════════════════════════════════════════════════════════════ // Step 2: REST Integration (Inventory) → Tested with: wiremock { mockGet(...) } // ════════════════════════════════════════════════════════════════════════════ @WithSpan("OrderService.checkInventoryViaRest") private suspend fun checkInventoryViaRest(productId: String) { logger.info { "→ Checking inventory via REST for product=$productId" } val inventory = inventoryClient.checkAvailability(productId) if (!inventory.available) { logger.warn { "✗ Inventory not available for product=$productId" } throw InventoryNotAvailableException(productId) } logger.info { "✓ Inventory available: quantity=${inventory.quantity}" } } // ════════════════════════════════════════════════════════════════════════════ // Step 3: REST Integration (Payment) → Tested with: wiremock { mockPost(...) } // ════════════════════════════════════════════════════════════════════════════ @WithSpan("OrderService.processPaymentViaRest") private suspend fun processPaymentViaRest(userId: String, amount: Double): PaymentResult { logger.info { "→ Processing payment via REST for user=$userId, amount=$amount" } val payment = paymentClient.charge(userId, amount) if (!payment.success) { logger.error { "✗ Payment failed: reason=${payment.errorMessage}" } throw PaymentFailedException(payment.errorMessage ?: "Unknown error") } logger.info { "✓ Payment successful: transactionId=${payment.transactionId}" } return payment } // ════════════════════════════════════════════════════════════════════════════ // Step 4: Database → Tested with: postgresql { shouldQuery(...) } // ════════════════════════════════════════════════════════════════════════════ @WithSpan("OrderService.saveOrderToDatabase") private suspend fun saveOrderToDatabase( orderId: String, userId: String, productId: String, amount: Double, transactionId: String ): Order { logger.info { "→ Saving order to database: id=$orderId" } val order = Order( id = orderId, userId = userId, productId = productId, amount = amount ).confirm(transactionId) val savedOrder = orderRepository.save(order) logger.info { "✓ Order saved: id=${savedOrder.id}, status=${savedOrder.status}" } return savedOrder } // ════════════════════════════════════════════════════════════════════════════ // Step 5: Kafka → Tested with: kafka { shouldBePublished(...) } // ════════════════════════════════════════════════════════════════════════════ @WithSpan("OrderService.publishEventsToKafka") private suspend fun publishEventsToKafka(order: Order, transactionId: String) { logger.info { "→ Publishing events to Kafka for order=${order.id}" } eventPublisher.publish( OrderCreatedEvent( orderId = order.id, userId = order.userId, productId = order.productId, amount = order.amount ) ) logger.info { " ✓ OrderCreatedEvent published" } eventPublisher.publish( PaymentProcessedEvent( orderId = order.id, transactionId = transactionId, amount = order.amount, success = true ) ) logger.info { " ✓ PaymentProcessedEvent published" } } // ════════════════════════════════════════════════════════════════════════════ // Step 6: db-scheduler → Tested with: tasks { shouldBeExecuted(...) } // ════════════════════════════════════════════════════════════════════════════ @WithSpan("OrderService.scheduleConfirmationEmail") private suspend fun scheduleConfirmationEmail(order: Order) { logger.info { "→ Scheduling confirmation email for order=${order.id}" } emailSchedulerService.scheduleOrderConfirmationEmail( orderId = order.id, userId = order.userId, amount = order.amount, productId = order.productId ) logger.info { " ✓ Email task scheduled" } } @WithSpan("OrderService.getOrder") suspend fun getOrder(id: String): Order? = orderRepository.findById(id) @WithSpan("OrderService.getOrderByUserId") suspend fun getOrderByUserId(userId: String): Order? = orderRepository.findByUserId(userId).firstOrNull() @WithSpan("OrderService.getOrdersByUserId") suspend fun getOrdersByUserId(userId: String): List = orderRepository.findByUserId(userId) } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/domain/statistics/UserOrderStatistics.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.domain.statistics import java.time.Instant /** * Read model for user order statistics. * Updated asynchronously when OrderCreatedEvent is consumed. */ data class UserOrderStatistics( val userId: String, val totalOrders: Int = 0, val totalAmount: Double = 0.0, val lastOrderAt: Instant? = null ) { fun addOrder(amount: Double, orderTime: Instant): UserOrderStatistics = copy( totalOrders = totalOrders + 1, totalAmount = totalAmount + amount, lastOrderAt = orderTime ) } interface UserOrderStatisticsRepository { suspend fun findByUserId(userId: String): UserOrderStatistics? suspend fun save(statistics: UserOrderStatistics): UserOrderStatistics } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/events/OrderCreatedEvent.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.events import java.time.Instant data class OrderCreatedEvent( val orderId: String, val userId: String, val productId: String, val amount: Double, val createdAt: Instant = Instant.now() ) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/events/PaymentProcessedEvent.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.events import java.time.Instant data class PaymentProcessedEvent( val orderId: String, val transactionId: String, val amount: Double, val success: Boolean, val createdAt: Instant = Instant.now() ) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/GlobalErrorHandler.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra import com.trendyol.stove.examples.kotlin.spring.infra.clients.* import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.api.trace.* import org.springframework.http.* import org.springframework.web.bind.annotation.* private val logger = KotlinLogging.logger {} @RestControllerAdvice class GlobalErrorHandler { @ExceptionHandler(InventoryNotAvailableException::class) fun handleInventoryNotAvailable(ex: InventoryNotAvailableException): ResponseEntity { logger.warn(ex) { "Inventory not available" } Span.current().apply { recordException(ex) setStatus(StatusCode.ERROR, ex.message ?: "Unknown error") } return ResponseEntity .status(HttpStatus.CONFLICT) .body(ErrorResponse(message = ex.message ?: "Inventory not available", errorCode = "INVENTORY_NOT_AVAILABLE")) } @ExceptionHandler(PaymentFailedException::class) fun handlePaymentFailed(ex: PaymentFailedException): ResponseEntity { logger.error(ex) { "Payment failed" } Span.current().apply { recordException(ex) setStatus(StatusCode.ERROR, ex.message ?: "Unknown error") } return ResponseEntity .status(HttpStatus.BAD_GATEWAY) .body(ErrorResponse(message = ex.message ?: "Payment failed", errorCode = "PAYMENT_FAILED")) } @ExceptionHandler(Exception::class) fun handleGenericException(ex: Exception): ResponseEntity { logger.error(ex) { "Unexpected error occurred" } Span.current().apply { recordException(ex) setStatus(StatusCode.ERROR, ex.message ?: "Unknown error") } return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ErrorResponse(message = "Internal server error", errorCode = "INTERNAL_ERROR")) } } data class ErrorResponse( val message: String, val errorCode: String ) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/clients/FraudDetectionClient.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.clients import com.trendyol.stove.examples.kotlin.spring.grpc.CheckFraudRequest import com.trendyol.stove.examples.kotlin.spring.grpc.CheckFraudResponse import com.trendyol.stove.examples.kotlin.spring.grpc.FraudDetectionServiceGrpcKt import io.github.oshai.kotlinlogging.KotlinLogging import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import io.opentelemetry.instrumentation.annotations.WithSpan import jakarta.annotation.PreDestroy import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component private val logger = KotlinLogging.logger {} @Component class FraudDetectionClient( @param:Value("\${external-apis.fraud-detection.host}") private val host: String, @param:Value("\${external-apis.fraud-detection.port}") private val port: Int ) { private val channel: ManagedChannel = ManagedChannelBuilder .forAddress(host, port) .usePlaintext() .build() private val stub = FraudDetectionServiceGrpcKt.FraudDetectionServiceCoroutineStub(channel) @WithSpan("FraudDetectionClient.checkFraud") suspend fun checkFraud( orderId: String, userId: String, amount: Double, productId: String ): FraudCheckResult { logger.info { "Checking fraud for order=$orderId, user=$userId, amount=$amount" } val request = CheckFraudRequest .newBuilder() .setOrderId(orderId) .setUserId(userId) .setAmount(amount) .setProductId(productId) .build() val response: CheckFraudResponse = stub.checkFraud(request) return FraudCheckResult( isFraudulent = response.isFraudulent, riskScore = response.riskScore, reason = response.reason ) } @PreDestroy fun shutdown() { channel.shutdown() } } data class FraudCheckResult( val isFraudulent: Boolean, val riskScore: Double, val reason: String ) class FraudDetectedException( reason: String ) : RuntimeException("Order flagged as fraudulent: $reason") ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/clients/InventoryClient.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.clients import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.awaitBody private val logger = KotlinLogging.logger {} @Component class InventoryClient( @param:Value("\${external-apis.inventory.url}") private val baseUrl: String ) { private val webClient = WebClient .builder() .baseUrl(baseUrl) .build() @WithSpan("InventoryClient.checkAvailability") suspend fun checkAvailability(productId: String): InventoryResponse { logger.info { "Checking inventory for product=$productId" } return webClient .get() .uri("/inventory/$productId") .retrieve() .awaitBody() } } data class InventoryResponse( val productId: String, val available: Boolean, val quantity: Int = 0 ) class InventoryNotAvailableException( productId: String ) : RuntimeException("Inventory not available for product: $productId") ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/clients/PaymentClient.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.clients import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.awaitBody private val logger = KotlinLogging.logger {} @Component class PaymentClient( @param:Value("\${external-apis.payment.url}") private val baseUrl: String ) { private val webClient = WebClient .builder() .baseUrl(baseUrl) .build() @WithSpan("PaymentClient.charge") suspend fun charge(userId: String, amount: Double): PaymentResult { logger.info { "Processing payment for user=$userId, amount=$amount" } return webClient .post() .uri("/payments/charge") .bodyValue(PaymentRequest(userId, amount)) .retrieve() .awaitBody() } } data class PaymentRequest( val userId: String, val amount: Double ) data class PaymentResult( val success: Boolean, val transactionId: String? = null, val amount: Double = 0.0, val errorMessage: String? = null ) class PaymentFailedException( message: String ) : RuntimeException("Payment failed: $message") ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/grpc/GrpcErrorSpanInterceptor.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.grpc import io.grpc.Context import io.grpc.Contexts import io.grpc.ForwardingServerCall import io.grpc.Metadata import io.grpc.ServerCall import io.grpc.ServerCallHandler import io.grpc.ServerInterceptor import io.grpc.Status import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.StatusCode import org.springframework.stereotype.Component /** * gRPC server interceptor that records errors on the current OpenTelemetry span. * * When any gRPC call fails (non-OK status), the error is recorded on the span * so it appears in traces for debugging and observability. */ @Component class GrpcErrorSpanInterceptor : ServerInterceptor { override fun interceptCall( call: ServerCall, headers: Metadata, next: ServerCallHandler ): ServerCall.Listener { val wrappedCall = object : ForwardingServerCall.SimpleForwardingServerCall(call) { override fun close(status: Status, trailers: Metadata) { if (!status.isOk) { Span.current().apply { recordException(status.asRuntimeException()) setStatus(StatusCode.ERROR, status.description ?: status.code.name) } } super.close(status, trailers) } } return Contexts.interceptCall( Context.current(), wrappedCall, headers, next ) } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/grpc/GrpcServerConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.grpc import io.github.oshai.kotlinlogging.KotlinLogging import io.grpc.Server import io.grpc.ServerBuilder import jakarta.annotation.PostConstruct import jakarta.annotation.PreDestroy import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration private val logger = KotlinLogging.logger {} /** * Configuration for the gRPC server. * * Starts a gRPC server that exposes the OrderQueryService. */ @Configuration class GrpcServerConfig( private val orderQueryGrpcService: OrderQueryGrpcService, private val grpcErrorSpanInterceptor: GrpcErrorSpanInterceptor, @param:Value("\${grpc.server.port:50051}") private val port: Int ) { private lateinit var server: Server @PostConstruct fun start() { server = ServerBuilder .forPort(port) .intercept(grpcErrorSpanInterceptor) .addService(orderQueryGrpcService) .build() .start() logger.info { "gRPC server started on port $port" } } @PreDestroy fun stop() { server.shutdown() logger.info { "gRPC server stopped" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/grpc/OrderQueryGrpcService.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.grpc import com.trendyol.stove.examples.kotlin.spring.domain.order.OrderRepository import com.trendyol.stove.examples.kotlin.spring.grpc.* import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.stereotype.Service private val logger = KotlinLogging.logger {} /** * gRPC service implementation for querying orders. * * This demonstrates exposing our application's functionality via gRPC, * which Stove can test using the `stove-grpc` module. */ @Service class OrderQueryGrpcService( private val orderRepository: OrderRepository ) : OrderQueryServiceGrpcKt.OrderQueryServiceCoroutineImplBase() { @WithSpan("OrderQueryGrpcService.getOrder") override suspend fun getOrder(request: GetOrderRequest): GetOrderResponse { logger.info { "gRPC: GetOrder called for id=${request.orderId}" } val order = orderRepository.findById(request.orderId) return if (order != null) { GetOrderResponse .newBuilder() .setFound(true) .setOrder(order.toProto()) .build() } else { GetOrderResponse .newBuilder() .setFound(false) .build() } } @WithSpan("OrderQueryGrpcService.getOrdersByUser") override suspend fun getOrdersByUser(request: GetOrdersByUserRequest): GetOrdersResponse { logger.info { "gRPC: GetOrdersByUser called for userId=${request.userId}" } val orders = orderRepository.findByUserId(request.userId) return GetOrdersResponse .newBuilder() .addAllOrders(orders.map { it.toProto() }) .build() } private fun com.trendyol.stove.examples.kotlin.spring.domain.order.Order.toProto(): OrderProto = OrderProto .newBuilder() .setId(id) .setUserId(userId) .setProductId(productId) .setAmount(amount) .setStatus(status.name) .setPaymentTransactionId(paymentTransactionId ?: "") .setCreatedAt(createdAt.toEpochMilli()) .build() } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/kafka/KafkaConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.kafka import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.examples.kotlin.spring.events.OrderCreatedEvent import io.github.oshai.kotlinlogging.KotlinLogging import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.TopicPartition import org.apache.kafka.common.serialization.ByteArraySerializer import org.apache.kafka.common.serialization.StringDeserializer import org.apache.kafka.common.serialization.StringSerializer import org.springframework.boot.autoconfigure.kafka.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.ConsumerFactory import org.springframework.kafka.core.DefaultKafkaConsumerFactory import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaOperations import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory import org.springframework.kafka.listener.DeadLetterPublishingRecoverer import org.springframework.kafka.listener.DefaultErrorHandler import org.springframework.kafka.support.serializer.JsonDeserializer import org.springframework.kafka.support.serializer.JsonSerializer import org.springframework.util.backoff.FixedBackOff private val logger = KotlinLogging.logger {} private const val DLQ_SUFFIX = ".DLT" private const val MAX_RETRY_ATTEMPTS = 3L private const val RETRY_INTERVAL_MS = 1000L @Configuration class KafkaConfig { @Bean fun producerFactory( kafkaProperties: KafkaProperties, objectMapper: ObjectMapper ): ProducerFactory { val props = kafkaProperties.buildProducerProperties(null).toMutableMap() props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java val factory = DefaultKafkaProducerFactory(props) factory.setValueSerializer(JsonSerializer(objectMapper)) return factory } @Bean fun kafkaTemplate( producerFactory: ProducerFactory ): KafkaTemplate = KafkaTemplate(producerFactory) /** * Dedicated producer factory for Dead Letter Queue. * Uses ByteArraySerializer to forward raw message bytes. */ @Bean fun dlqProducerFactory( kafkaProperties: KafkaProperties ): ProducerFactory { val props = kafkaProperties.buildProducerProperties(null).toMutableMap() props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = ByteArraySerializer::class.java return DefaultKafkaProducerFactory(props) } @Bean fun dlqKafkaTemplate( dlqProducerFactory: ProducerFactory ): KafkaTemplate = KafkaTemplate(dlqProducerFactory) @Bean fun consumerFactory( kafkaProperties: KafkaProperties, objectMapper: ObjectMapper ): ConsumerFactory { // buildConsumerProperties includes spring.kafka.consumer.properties.* (e.g., interceptor.classes) val props = kafkaProperties.buildConsumerProperties(null).toMutableMap() props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = JsonDeserializer::class.java // Only set default if not already configured props.putIfAbsent(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") val jsonDeserializer = JsonDeserializer(OrderCreatedEvent::class.java, objectMapper) jsonDeserializer.addTrustedPackages("*") return DefaultKafkaConsumerFactory( props, StringDeserializer(), jsonDeserializer ) } /** * Dead Letter Publishing Recoverer - sends failed messages to DLQ topic. * Topic name: original-topic.DLT */ @Bean fun deadLetterPublishingRecoverer( dlqKafkaTemplate: KafkaTemplate ): DeadLetterPublishingRecoverer { @Suppress("UNCHECKED_CAST") val recoverer = DeadLetterPublishingRecoverer( dlqKafkaTemplate as KafkaOperations ) { record: ConsumerRecord<*, *>, _: Exception -> TopicPartition("${record.topic()}$DLQ_SUFFIX", record.partition()) } return recoverer } /** * Error handler with retry and dead letter queue support. * After MAX_RETRY_ATTEMPTS failures, message is sent to DLQ. */ @Bean fun kafkaErrorHandler( deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer ): DefaultErrorHandler { val errorHandler = DefaultErrorHandler( deadLetterPublishingRecoverer, FixedBackOff(RETRY_INTERVAL_MS, MAX_RETRY_ATTEMPTS) ) // Log level is INFO by default, which is fine for our use case errorHandler.addNotRetryableExceptions(IllegalArgumentException::class.java) return errorHandler } @Bean fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, kafkaErrorHandler: DefaultErrorHandler ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.consumerFactory = consumerFactory factory.setCommonErrorHandler(kafkaErrorHandler) return factory } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/kafka/OrderCreatedEventListener.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.kafka import com.trendyol.stove.examples.kotlin.spring.domain.statistics.* import com.trendyol.stove.examples.kotlin.spring.events.OrderCreatedEvent import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import kotlinx.coroutines.runBlocking import org.springframework.kafka.annotation.KafkaListener import org.springframework.stereotype.Component private val logger = KotlinLogging.logger {} /** * Kafka listener that consumes OrderCreatedEvent and updates the read model. * * This demonstrates the Event Sourcing / CQRS pattern where: * - Commands create orders (write model) * - Events update statistics (read model) * * In the showcase test, we verify: * 1. The event was consumed (shouldBeConsumed) * 2. The side effect happened (statistics were updated) */ @Component class OrderCreatedEventListener( private val statisticsRepository: UserOrderStatisticsRepository ) { @KafkaListener( topics = ["\${kafka.topics.orders-created}"], groupId = "order-statistics-updater", containerFactory = "kafkaListenerContainerFactory" ) @WithSpan("OrderCreatedEventListener.onOrderCreated") fun onOrderCreated(event: OrderCreatedEvent) = runBlocking { logger.info { "Received OrderCreatedEvent: orderId=${event.orderId}, userId=${event.userId}" } updateStatistics(event) logger.info { "Statistics updated for user=${event.userId}" } } private suspend fun updateStatistics(event: OrderCreatedEvent) { val existing = statisticsRepository.findByUserId(event.userId) val updated = (existing ?: UserOrderStatistics(userId = event.userId)) .addOrder(event.amount, event.createdAt) statisticsRepository.save(updated) } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/kafka/OrderEventPublisher.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.kafka import com.trendyol.stove.examples.kotlin.spring.events.OrderCreatedEvent import com.trendyol.stove.examples.kotlin.spring.events.PaymentProcessedEvent import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.beans.factory.annotation.Value import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Component private val logger = KotlinLogging.logger {} @Component class OrderEventPublisher( private val kafkaTemplate: KafkaTemplate, @param:Value("\${kafka.topics.orders-created}") private val ordersCreatedTopic: String, @param:Value("\${kafka.topics.payments-processed}") private val paymentsProcessedTopic: String ) { @WithSpan("OrderEventPublisher.publishOrderCreated") fun publish(event: OrderCreatedEvent) { logger.info { "Publishing OrderCreatedEvent: orderId=${event.orderId}" } kafkaTemplate.send(ordersCreatedTopic, event.orderId, event) } @WithSpan("OrderEventPublisher.publishPaymentProcessed") fun publish(event: PaymentProcessedEvent) { logger.info { "Publishing PaymentProcessedEvent: orderId=${event.orderId}" } kafkaTemplate.send(paymentsProcessedTopic, event.orderId, event) } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/persistence/DataSourceConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.persistence import com.zaxxer.hikari.HikariDataSource import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import javax.sql.DataSource /** * Configuration for JDBC DataSource. * Required for db-scheduler which needs JDBC, while the app uses R2DBC for reactive operations. * Derives JDBC URL from R2DBC URL for consistency. */ @Configuration class DataSourceConfig { @Bean fun dataSource( @Value("\${spring.r2dbc.url}") r2dbcUrl: String, @Value("\${spring.r2dbc.username}") username: String, @Value("\${spring.r2dbc.password}") password: String ): DataSource = HikariDataSource().apply { // Convert R2DBC URL to JDBC URL jdbcUrl = r2dbcUrl.replace("r2dbc:", "jdbc:") this.username = username this.password = password driverClassName = "org.postgresql.Driver" maximumPoolSize = 5 poolName = "db-scheduler-pool" validate() } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/persistence/PostgresOrderRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.persistence import com.trendyol.stove.examples.kotlin.spring.domain.order.* import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.SpanAttribute import io.opentelemetry.instrumentation.annotations.WithSpan import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.* import org.springframework.r2dbc.core.DatabaseClient import org.springframework.r2dbc.core.bind import org.springframework.stereotype.Repository import java.time.Instant private val logger = KotlinLogging.logger {} @Repository class PostgresOrderRepository( private val databaseClient: DatabaseClient ) : OrderRepository { @WithSpan("PostgresOrderRepository.save") override suspend fun save(order: Order): Order { logger.info { "Saving order: id=${order.id}" } // ══════════════════════════════════════════════════════════════════════════ // 🐛 DEMO BUG: Uncomment below to simulate a deep production bug // This bug only triggers for high-value orders (> $1000), making it hard // to catch in simple unit tests. The trace will show exactly where it fails! // ══════════════════════════════════════════════════════════════════════════ // validateOrderAmount(order) databaseClient .sql( """ INSERT INTO orders (id, user_id, product_id, amount, status, payment_transaction_id, created_at) VALUES (:id, :userId, :productId, :amount, :status, :paymentTransactionId, :createdAt) ON CONFLICT (id) DO UPDATE SET status = :status, payment_transaction_id = :paymentTransactionId """.trimIndent() ).bind("id", order.id) .bind("userId", order.userId) .bind("productId", order.productId) .bind("amount", order.amount) .bind("status", order.status.name) .bind("paymentTransactionId", order.paymentTransactionId) .bind("createdAt", order.createdAt) .fetch() .rowsUpdated() .awaitSingle() return order } @WithSpan("PostgresOrderRepository.findById") override suspend fun findById( @SpanAttribute("order.id") id: String ): Order? = databaseClient .sql( """ SELECT id, user_id, product_id, amount, status, payment_transaction_id, created_at FROM orders WHERE id = :id """.trimIndent() ).bind("id", id) .map { row, _ -> mapToOrder(row) } .first() .awaitFirstOrNull() @WithSpan("PostgresOrderRepository.findByUserId") override suspend fun findByUserId( @SpanAttribute("order.userId") userId: String ): List = databaseClient .sql( """ SELECT id, user_id, product_id, amount, status, payment_transaction_id, created_at FROM orders WHERE user_id = :userId """.trimIndent() ).bind("userId", userId) .map { row, _ -> mapToOrder(row) } .all() .asFlow() .toList() private fun mapToOrder(row: io.r2dbc.spi.Row): Order = Order( id = row.get("id", String::class.java)!!, userId = row.get("user_id", String::class.java)!!, productId = row.get("product_id", String::class.java)!!, amount = (row.get("amount", java.math.BigDecimal::class.java)!!).toDouble(), status = OrderStatus.valueOf(row.get("status", String::class.java)!!), paymentTransactionId = row.get("payment_transaction_id", String::class.java), createdAt = row.get("created_at", Instant::class.java)!! ) // ══════════════════════════════════════════════════════════════════════════ // 🐛 DEMO BUG: Simulates a bug deep in the persistence layer // In production, this might be: connection pool exhaustion, constraint // violation, or business rule that wasn't properly documented. // ══════════════════════════════════════════════════════════════════════════ @Suppress("UnusedPrivateMember", "ThrowsCount") private fun validateOrderAmount(order: Order) { if (order.amount > 1000) { // Simulating a bug: maybe the payment gateway has an undocumented limit, // or there's a database constraint we didn't know about throw OrderPersistenceException( "Failed to persist order ${order.id}: amount exceeds internal threshold" ) } } } class OrderPersistenceException( message: String ) : RuntimeException(message) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/persistence/PostgresUserOrderStatisticsRepository.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.persistence import com.trendyol.stove.examples.kotlin.spring.domain.statistics.UserOrderStatistics import com.trendyol.stove.examples.kotlin.spring.domain.statistics.UserOrderStatisticsRepository import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitSingle import org.springframework.r2dbc.core.DatabaseClient import org.springframework.r2dbc.core.bind import org.springframework.stereotype.Repository import java.time.Instant private val logger = KotlinLogging.logger {} @Repository class PostgresUserOrderStatisticsRepository( private val databaseClient: DatabaseClient ) : UserOrderStatisticsRepository { @WithSpan("UserOrderStatisticsRepository.findByUserId") override suspend fun findByUserId(userId: String): UserOrderStatistics? = databaseClient .sql( """ SELECT user_id, total_orders, total_amount, last_order_at FROM user_order_statistics WHERE user_id = :userId """.trimIndent() ).bind("userId", userId) .map { row, _ -> @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") UserOrderStatistics( userId = row.get("user_id", String::class.java)!!, totalOrders = (row.get("total_orders", java.lang.Integer::class.java) as Int?) ?: 0, totalAmount = row.get("total_amount", java.math.BigDecimal::class.java)!!.toDouble(), lastOrderAt = row.get("last_order_at", Instant::class.java) ) }.first() .awaitFirstOrNull() @WithSpan("UserOrderStatisticsRepository.save") override suspend fun save(statistics: UserOrderStatistics): UserOrderStatistics { logger.info { "Saving user statistics: userId=${statistics.userId}, totalOrders=${statistics.totalOrders}" } databaseClient .sql( """ INSERT INTO user_order_statistics (user_id, total_orders, total_amount, last_order_at) VALUES (:userId, :totalOrders, :totalAmount, :lastOrderAt) ON CONFLICT (user_id) DO UPDATE SET total_orders = :totalOrders, total_amount = :totalAmount, last_order_at = :lastOrderAt """.trimIndent() ).bind("userId", statistics.userId) .bind("totalOrders", statistics.totalOrders) .bind("totalAmount", statistics.totalAmount) .bind("lastOrderAt", statistics.lastOrderAt) .fetch() .rowsUpdated() .awaitSingle() return statistics } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/scheduling/DbSchedulerConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.scheduling import com.github.kagkarlsson.scheduler.boot.config.DbSchedulerCustomizer import com.github.kagkarlsson.scheduler.event.AbstractSchedulerListener import com.github.kagkarlsson.scheduler.task.ExecutionComplete import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component private val logger = KotlinLogging.logger {} /** * Customizer for db-scheduler configuration. */ @Component class DbSchedulerCustomizerConfig : DbSchedulerCustomizer /** * Listener for db-scheduler task executions. * Logs task execution results for observability. */ @Component class DbSchedulerLoggingListener : AbstractSchedulerListener() { override fun onExecutionComplete(executionComplete: ExecutionComplete) { logger.info { "Task execution completed: " + "task=${executionComplete.execution.taskInstance.taskName}, " + "instanceId=${executionComplete.execution.taskInstance.id}, " + "result=${executionComplete.result}" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/scheduling/EmailSchedulerService.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.scheduling import com.github.kagkarlsson.scheduler.Scheduler import com.github.kagkarlsson.scheduler.task.Task import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.SpanAttribute import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.stereotype.Service import java.time.Instant import java.util.UUID private val logger = KotlinLogging.logger {} /** * Service for scheduling order-related email tasks. * Uses db-scheduler for persistent, reliable task scheduling. * * This demonstrates: * - Integration with db-scheduler for persistent scheduling * - OpenTelemetry instrumentation for observability * - Stove testing with DbSchedulerSystem */ @Service class EmailSchedulerService( private val scheduler: Scheduler, private val sendOrderEmailTask: Task ) { /** * Schedules an order confirmation email to be sent. * * @param orderId The order ID * @param userId The user ID * @param email The email address (defaults to userId@example.com) * @param amount The order amount * @param productId The product ID * @param executeAt When to send the email (defaults to now) */ @WithSpan("EmailSchedulerService.scheduleOrderConfirmationEmail") fun scheduleOrderConfirmationEmail( @SpanAttribute("orderId") orderId: String, @SpanAttribute("userId") userId: String, email: String = "$userId@example.com", amount: Double, productId: String, executeAt: Instant = Instant.now() ) { val payload = OrderEmailPayload( orderId = orderId, userId = userId, email = email, amount = amount, productId = productId ) val taskInstanceId = "order-email-$orderId-${UUID.randomUUID()}" logger.info { "Scheduling order confirmation email for order $orderId to $email at $executeAt" } scheduler.scheduleIfNotExists( sendOrderEmailTask.instance(taskInstanceId, payload), executeAt ) logger.info { "Successfully scheduled email task: $taskInstanceId" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/kotlin/com/trendyol/stove/examples/kotlin/spring/infra/scheduling/SendOrderEmailTask.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.infra.scheduling import com.github.kagkarlsson.scheduler.task.Task import com.github.kagkarlsson.scheduler.task.helper.Tasks import io.github.oshai.kotlinlogging.KotlinLogging import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.io.Serializable /** * Payload for the order email task. * Contains all information needed to send an order confirmation email. */ data class OrderEmailPayload( val orderId: String, val userId: String, val email: String, val amount: Double, val productId: String ) : Serializable { companion object { private const val serialVersionUID: Long = 1L } } private val logger = KotlinLogging.logger {} @Configuration class SendOrderEmailTaskConfig { @Bean fun sendOrderEmailTask(): Task = Tasks .oneTime("send-order-email", OrderEmailPayload::class.java) .execute { taskInstance, _ -> val payload = taskInstance.data sendEmail(payload) } @WithSpan("SendOrderEmailTask.sendEmail") private fun sendEmail(payload: OrderEmailPayload) { // Simulate sending email - in production this would call an email service logger.info { """ |============================================ | SENDING ORDER CONFIRMATION EMAIL |============================================ | To: ${payload.email} | Order ID: ${payload.orderId} | User ID: ${payload.userId} | Product: ${payload.productId} | Amount: $${payload.amount} |============================================ """.trimMargin() } // Simulate email sending delay Thread.sleep(100) logger.info { "Email sent successfully for order ${payload.orderId}" } } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/proto/fraud_detection.proto ================================================ syntax = "proto3"; package frauddetection; option java_package = "com.trendyol.stove.examples.kotlin.spring.grpc"; option java_multiple_files = true; // Request to check if an order is fraudulent message CheckFraudRequest { string order_id = 1; string user_id = 2; double amount = 3; string product_id = 4; } // Response with fraud check result message CheckFraudResponse { bool is_fraudulent = 1; double risk_score = 2; // 0.0 - 1.0 string reason = 3; // e.g., "high_value_first_order", "suspicious_pattern" } // External Fraud Detection service (simulates a real microservice) service FraudDetectionService { // Check if an order is potentially fraudulent rpc CheckFraud(CheckFraudRequest) returns (CheckFraudResponse); } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/proto/order_query.proto ================================================ syntax = "proto3"; package orderquery; option java_multiple_files = true; option java_package = "com.trendyol.stove.examples.kotlin.spring.grpc"; // Request to get order by ID message GetOrderRequest { string order_id = 1; } // Request to get orders by user message GetOrdersByUserRequest { string user_id = 1; } // Order details in response message OrderProto { string id = 1; string user_id = 2; string product_id = 3; double amount = 4; string status = 5; string payment_transaction_id = 6; int64 created_at = 7; // Unix timestamp millis } // Single order response message GetOrderResponse { bool found = 1; OrderProto order = 2; } // Multiple orders response message GetOrdersResponse { repeated OrderProto orders = 1; } // Order Query Service - exposed by our application service OrderQueryService { // Get a single order by ID rpc GetOrder(GetOrderRequest) returns (GetOrderResponse); // Get all orders for a user rpc GetOrdersByUser(GetOrdersByUserRequest) returns (GetOrdersResponse); } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/main/resources/application.yml ================================================ server: port: 8024 grpc: server: port: 50051 spring: application: name: stove-kotlin-spring-showcase r2dbc: url: r2dbc:postgresql://localhost:5432/stove username: postgres password: postgres datasource: url: jdbc:postgresql://localhost:5432/stove username: postgres password: postgres driver-class-name: org.postgresql.Driver kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer kafka: topics: orders-created: showcase.orders.created payments-processed: showcase.payments.processed external-apis: inventory: url: http://localhost:9091 payment: url: http://localhost:9091 fraud-detection: host: localhost port: 9092 logging: level: com.trendyol.stove: DEBUG org.springframework.r2dbc: DEBUG com.github.kagkarlsson.scheduler: DEBUG # db-scheduler configuration db-scheduler: enabled: true table-name: scheduled_tasks threads: 2 polling-interval: 100ms polling-strategy: lock-and-fetch polling-strategy-lower-limit-fraction-of-threads: 0.5 polling-strategy-upper-limit-fraction-of-threads: 2.0 immediate-execution-enabled: true always-persist-timestamp-in-utc: true ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/painful/BaseIntegrationTest.kt ================================================ @file:Suppress("all") package com.trendyol.stove.examples.kotlin.spring.e2e.painful /* @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers abstract class BaseIntegrationTest { companion object { @Container val postgres = PostgreSQLContainer("postgres:16-alpine") .withDatabaseName("test") .withUsername("test") .withPassword("test") @Container val kafka = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.8.1")) .withStartupAttempts(3) val wiremock = WireMockServer(WireMockConfiguration.options().dynamicPort()) @JvmStatic @BeforeAll fun startWiremock() { wiremock.start() } @JvmStatic @AfterAll fun stopWiremock() { wiremock.stop() } @JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { registry.add("spring.r2dbc.url") { "r2dbc:postgresql://${postgres.host}:${postgres.firstMappedPort}/${postgres.databaseName}" } registry.add("spring.r2dbc.username") { postgres.username } registry.add("spring.r2dbc.password") { postgres.password } registry.add("spring.kafka.bootstrap-servers") { kafka.bootstrapServers } registry.add("spring.kafka.producer.properties.interceptor.classes") { "" } registry.add("external-apis.inventory.url") { "http://localhost:${wiremock.port()}" } registry.add("external-apis.payment.url") { "http://localhost:${wiremock.port()}" } } } @LocalServerPort protected var port: Int = 0 @Autowired protected lateinit var r2dbcEntityTemplate: R2dbcEntityTemplate @Autowired protected lateinit var kafkaTemplate: KafkaTemplate @Autowired protected lateinit var orderRepository: OrderRepository @Autowired protected lateinit var objectMapper: ObjectMapper @BeforeEach fun setup() { RestAssured.port = port RestAssured.baseURI = "http://localhost" wiremock.resetAll() // Database cleanup - hope you didn't miss a table! runBlocking { r2dbcEntityTemplate.databaseClient .sql("TRUNCATE orders CASCADE") .fetch() .rowsUpdated() .awaitSingle() } // Kafka cleanup? Good luck with that... } } // ════════════════════════════════════════════════════════════════════════════════ // NOW you can write a test... but with THREE different assertion APIs // ════════════════════════════════════════════════════════════════════════════════ class OrderControllerPainfulTest : BaseIntegrationTest() { @Test suspend fun `should create order with all verifications`() { val userId = UUID.randomUUID().toString() val productId = "macbook-pro-16" val amount = 2499.99 // ── WireMock setup (its own API) ──────────────────────────────────── wiremock.stubFor( get(urlEqualTo("/inventory/$productId")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody( """ { "productId": "$productId", "available": true, "quantity": 10 } """.trimIndent() ) ) ) wiremock.stubFor( post(urlEqualTo("/payments/charge")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody( """ { "success": true, "transactionId": "txn-123", "amount": $amount } """.trimIndent() ) ) ) // ── HTTP call with RestAssured (API #1) ───────────────────────────── val response = given() .contentType(ContentType.JSON) .body( """ { "userId": "$userId", "productId": "$productId", "amount": $amount } """.trimIndent() ) .`when`() .post("/api/orders") .then() .statusCode(201) .extract() .asString() val orderResponse = objectMapper.readValue(response, OrderResponse::class.java) // ── Database verification with R2DBC (API #2) ─────────────────────── // Completely different syntax than HTTP assertions val orders = r2dbcEntityTemplate.databaseClient .sql("SELECT * FROM orders WHERE user_id = :userId") .bind("userId", userId) .fetch() .all() .asFlow() .toList() assertEquals(1, orders.size) assertEquals("CONFIRMED", orders.first()["status"]) assertEquals(amount, (orders.first()["amount"] as BigDecimal).toDouble()) // ── Kafka verification with KafkaTestUtils (API #3) ───────────────── // Yet another API pattern to learn val consumer = createConsumer() consumer.subscribe(listOf("showcase.orders.created")) val records = KafkaTestUtils.getRecords(consumer, Duration.ofSeconds(10)) assertTrue(records.count() > 0) { "Expected at least one Kafka message" } val event = objectMapper.readValue( records.first().value() as String, OrderCreatedEvent::class.java ) assertEquals(userId, event.userId) assertEquals(productId, event.productId) consumer.close() } private fun createConsumer(): KafkaConsumer { // 10 more lines of Kafka consumer setup... val props = Properties().apply { put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.bootstrapServers) put(ConsumerConfig.GROUP_ID_CONFIG, "test-group-${UUID.randomUUID()}") put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java) put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java) } return KafkaConsumer(props) } } */ ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/setup/DbSchedulerSystem.kt ================================================ @file:Suppress("UNCHECKED_CAST") package com.trendyol.stove.examples.kotlin.spring.e2e.setup import arrow.core.* import com.github.kagkarlsson.scheduler.event.AbstractSchedulerListener import com.github.kagkarlsson.scheduler.task.* import com.trendyol.stove.reporting.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.* import org.springframework.beans.factory.getBean import org.springframework.context.ApplicationContext import java.time.Instant import java.util.concurrent.* import kotlin.reflect.KClass import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** * Listener that tracks db-scheduler task executions for testing purposes. * Captures scheduled, completed, and failed task executions. */ class StoveDbSchedulerListener : AbstractSchedulerListener() { private val completedExecutions: ConcurrentMap = ConcurrentHashMap() private val failedExecutions: ConcurrentMap = ConcurrentHashMap() private val scheduledExecutions: ConcurrentMap = ConcurrentHashMap() override fun onExecutionComplete(executionComplete: ExecutionComplete) { val instanceId = executionComplete.execution.taskInstance.id completedExecutions[instanceId] = executionComplete // Track failures separately for easy access if (executionComplete.result == ExecutionComplete.Result.FAILED) { failedExecutions[instanceId] = executionComplete } } override fun onExecutionScheduled(taskInstanceId: TaskInstanceId, executionTime: Instant) { scheduledExecutions[taskInstanceId.id] = executionTime } /** * Returns a snapshot of completed executions for reporting. */ fun getCompletedExecutionsSnapshot(): List> = completedExecutions.map { (id, execution) -> mapOf( "instanceId" to id, "taskName" to execution.execution.taskInstance.taskName, "result" to execution.result.toString(), "payloadType" to execution.execution.taskInstance.data ?.javaClass ?.simpleName ) } /** * Returns a snapshot of failed executions for reporting. */ fun getFailedExecutionsSnapshot(): List> = failedExecutions.map { (id, execution) -> mapOf( "instanceId" to id, "taskName" to execution.execution.taskInstance.taskName, "result" to execution.result.toString(), "cause" to execution.cause.orElse(null)?.message, "payloadType" to execution.execution.taskInstance.data ?.javaClass ?.simpleName ) } /** * Returns a snapshot of scheduled executions for reporting. */ fun getScheduledExecutionsSnapshot(): List> = scheduledExecutions.map { (id, time) -> mapOf("instanceId" to id, "executionTime" to time.toString()) } /** * Waits until a task execution with the specified payload type and condition is observed. * Throws assertion error if the task execution failed. */ suspend fun waitUntilObservedSuccessfully( atLeastIn: Duration, clazz: KClass, condition: (T) -> Boolean ): Collection = coroutineScope { val matchingExecutions = waitForMatchingExecutions(atLeastIn, clazz, condition) // Check if any matching execution failed val failedMatches = matchingExecutions.filter { it.result == ExecutionComplete.Result.FAILED } if (failedMatches.isNotEmpty()) { val failures = failedMatches.map { exec -> val cause = exec.cause.orElse(null) "Task '${exec.execution.taskInstance.taskName}' " + "(instance: ${exec.execution.taskInstance.id}) " + "FAILED: ${cause?.message ?: "Unknown error"}" } throw AssertionError( "Task execution(s) failed:\n${failures.joinToString("\n")}\n" + "Expected: successful execution of ${clazz.simpleName}" ) } matchingExecutions } private suspend fun waitForMatchingExecutions( atLeastIn: Duration, clazz: KClass, condition: (T) -> Boolean ): Collection { val getExecutions = { completedExecutions.values.toList() } return getExecutions.waitUntilConditionMet( atLeastIn, "While OBSERVING ${clazz.java.simpleName}" ) { execution -> val data = execution.execution.taskInstance?.data ?: return@waitUntilConditionMet false when { clazz.java.isAssignableFrom(data.javaClass) -> condition(data as T) else -> false } } } private suspend fun (() -> Collection).waitUntilConditionMet( duration: Duration, subject: String, condition: (T) -> Boolean ): Collection = runCatching { val collectionFunc = this withTimeout(duration) { while (!collectionFunc().any { condition(it) }) delay(50) } return collectionFunc().filter { condition(it) } }.recoverCatching { when (it) { is TimeoutCancellationException -> throw AssertionError("GOT A TIMEOUT: $subject.") is ConcurrentModificationException -> Result.success(waitUntilConditionMet(duration, subject, condition)) else -> throw it }.getOrThrow() }.getOrThrow() } /** * Stove system for testing db-scheduler task executions. * Allows assertions on scheduled tasks being executed with expected payloads. */ class DbSchedulerSystem( override val stove: Stove ) : PluggedSystem, AfterRunAwareWithContext, Reports { lateinit var listener: StoveDbSchedulerListener override val reportSystemName: String = "DbScheduler" override suspend fun afterRun(context: ApplicationContext) { listener = context.getBean() } override fun snapshot(): SystemSnapshot = SystemSnapshot( system = reportSystemName, state = mapOf( "completedExecutions" to listener.getCompletedExecutionsSnapshot(), "failedExecutions" to listener.getFailedExecutionsSnapshot(), "scheduledExecutions" to listener.getScheduledExecutionsSnapshot() ), summary = buildString { val completed = listener.getCompletedExecutionsSnapshot() val failed = listener.getFailedExecutionsSnapshot() val scheduled = listener.getScheduledExecutionsSnapshot() append("Completed: ${completed.size} task(s)") if (completed.isNotEmpty()) { append(" [${completed.joinToString { it["taskName"].toString() }}]") } if (failed.isNotEmpty()) { append(", FAILED: ${failed.size} task(s)") append(" [${failed.joinToString { "${it["taskName"]}: ${it["cause"]}" }}]") } append(", Scheduled: ${scheduled.size} task(s)") } ) /** * Asserts that a task with the specified payload type was executed successfully. * Fails if the task execution itself failed (e.g., threw an exception). * * @param atLeastIn Maximum time to wait for the task execution * @param condition Predicate to match the task payload */ suspend inline fun shouldBeExecuted( atLeastIn: Duration = 5.seconds, noinline condition: T.() -> Boolean ): DbSchedulerSystem = report( action = "Assert task executed successfully: ${T::class.simpleName}", expected = "Task with ${T::class.simpleName} payload executed successfully".some(), metadata = mapOf("timeout" to atLeastIn.toString()) ) { listener.waitUntilObservedSuccessfully(atLeastIn, T::class, condition) }.let { this } override fun close() = Unit } // ============================================================================ // DSL Extensions // ============================================================================ /** * Registers the DbSchedulerSystem with Stove. */ fun Stove.withDbSchedulerListener(): Stove = getOrRegister(DbSchedulerSystem(this)).let { this } /** * Gets the registered DbSchedulerSystem. */ fun Stove.dbScheduler(): DbSchedulerSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(DbSchedulerSystem::class) } /** * DSL extension for registering DbSchedulerSystem during Stove setup. */ fun WithDsl.dbScheduler(): Stove = this.stove.withDbSchedulerListener() /** * DSL extension for asserting on scheduled tasks during validation. */ suspend fun ValidationDsl.tasks(validation: suspend DbSchedulerSystem.() -> Unit): Unit = validation(this.stove.dbScheduler()) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/setup/OrderExampleInitialMigration.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.e2e.setup import com.trendyol.stove.database.migrations.DatabaseMigration import com.trendyol.stove.postgres.PostgresSqlMigrationContext import io.github.oshai.kotlinlogging.KotlinLogging private val logger = KotlinLogging.logger {} class OrderExampleInitialMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { logger.info { "Creating orders table" } connection.operations.execute( """ ${orders()} ${orderStatistics()} ${dbScheduler()} """.trimIndent() ) logger.info { "Orders, user_order_statistics, and scheduled_tasks tables created" } } private fun dbScheduler(): String = """ -- db-scheduler table for scheduled tasks -- Schema from: https://github.com/kagkarlsson/db-scheduler/blob/master/db-scheduler/src/test/resources/postgresql_tables.sql DROP TABLE IF EXISTS scheduled_tasks; CREATE TABLE IF NOT EXISTS scheduled_tasks ( task_name TEXT NOT NULL, task_instance TEXT NOT NULL, task_data BYTEA, execution_time TIMESTAMP WITH TIME ZONE NOT NULL, picked BOOLEAN NOT NULL, picked_by TEXT, last_success TIMESTAMP WITH TIME ZONE, last_failure TIMESTAMP WITH TIME ZONE, consecutive_failures INT, last_heartbeat TIMESTAMP WITH TIME ZONE, version BIGINT NOT NULL, priority SMALLINT, PRIMARY KEY (task_name, task_instance) ); CREATE INDEX IF NOT EXISTS execution_time_idx ON scheduled_tasks (execution_time); CREATE INDEX IF NOT EXISTS last_heartbeat_idx ON scheduled_tasks (last_heartbeat); CREATE INDEX IF NOT EXISTS priority_execution_time_idx ON scheduled_tasks (priority DESC, execution_time ASC);""" private fun orderStatistics(): String = """ DROP TABLE IF EXISTS user_order_statistics; CREATE TABLE IF NOT EXISTS user_order_statistics ( user_id VARCHAR(255) PRIMARY KEY, total_orders INT NOT NULL DEFAULT 0, total_amount DECIMAL(10, 2) NOT NULL DEFAULT 0, last_order_at TIMESTAMP ); """ private fun orders(): String = """ DROP TABLE IF EXISTS orders; CREATE TABLE IF NOT EXISTS orders ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL, product_id VARCHAR(255) NOT NULL, amount DECIMAL(10, 2) NOT NULL, status VARCHAR(50) NOT NULL, payment_transaction_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT NOW() );""" } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/setup/StoveConfig.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.e2e.setup import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.grpc.* import com.trendyol.stove.http.* import com.trendyol.stove.kafka.* import com.trendyol.stove.postgres.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.* import com.trendyol.stove.system.Stove import com.trendyol.stove.testing.grpcmock.* import com.trendyol.stove.tracing.tracing import com.trendyol.stove.wiremock.* import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.springframework.kafka.support.serializer.JsonSerializer const val GRPC_MOCK_PORT = 9092 const val GRPC_SERVER_PORT = 50051 class StoveConfig : AbstractProjectConfig() { init { stoveKafkaBridgePortDefault = "50053" System.setProperty(STOVE_KAFKA_BRIDGE_PORT, stoveKafkaBridgePortDefault) } override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8024" ) } bridge() // Enable tracing - starts OTLP gRPC receiver // Service name is automatically extracted from incoming spans (set by OTel agent) tracing { enableSpanReceiver() } // gRPC Mock for external gRPC services (Fraud Detection) grpcMock { GrpcMockSystemOptions(port = GRPC_MOCK_PORT) } // gRPC Client for testing OUR gRPC server (OrderQueryService) grpc { GrpcSystemOptions( host = "localhost", port = GRPC_SERVER_PORT ) } wiremock { WireMockSystemOptions( port = 0, // Dynamic port allocation for CI compatibility serde = StoveSerde.jackson.anyByteArraySerde(), configureExposedConfiguration = { cfg -> listOf( "external-apis.inventory.url=${cfg.baseUrl}", "external-apis.payment.url=${cfg.baseUrl}" ) } ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( // R2DBC configuration for reactive database access "spring.r2dbc.url=r2dbc:postgresql://${cfg.host}:${cfg.port}/stove", "spring.r2dbc.username=${cfg.username}", "spring.r2dbc.password=${cfg.password}", // JDBC configuration for db-scheduler "spring.datasource.url=jdbc:postgresql://${cfg.host}:${cfg.port}/stove", "spring.datasource.username=${cfg.username}", "spring.datasource.password=${cfg.password}" ) } ).migrations { register() } } kafka { KafkaSystemOptions( serde = StoveSerde.jackson.anyByteArraySerde(), valueSerializer = JsonSerializer(), containerOptions = KafkaContainerOptions(tag = "8.0.3") { withStartupAttempts(3) }, configureExposedConfiguration = { cfg -> listOf( "spring.kafka.bootstrap-servers=${cfg.bootstrapServers}", "spring.kafka.producer.properties.interceptor.classes=${cfg.interceptorClass}", "spring.kafka.consumer.properties.interceptor.classes=${cfg.interceptorClass}" ) } ) } // db-scheduler system for testing scheduled tasks dbScheduler() springBoot( runner = { params -> com.trendyol.stove.examples.kotlin.spring .run(params) { // Register test-specific beans for db-scheduler addTestDependencies { bean(isPrimary = true) } } }, withParameters = listOf( "server.port=8024", "grpc.server.port=$GRPC_SERVER_PORT", "external-apis.fraud-detection.host=localhost", "external-apis.fraud-detection.port=$GRPC_MOCK_PORT" // WireMock URLs are set via configureExposedConfiguration for dynamic port support ) ) }.run() } override suspend fun afterProject() { Stove.stop() } } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/tests/StreamingTests.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.e2e.tests import com.trendyol.stove.examples.kotlin.spring.ExampleData import com.trendyol.stove.http.http import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.serialization.StoveSerde.Companion.deserialize import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.utils.io.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import java.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration class StreamingTests : FunSpec({ test("streaming") { stove { http { streamClient() .prepareGet { url("http://localhost:8024/api/streaming/json") parameter("load", 100) parameter("delay", 1) contentType(ContentType.parse("application/x-ndjson")) }.also { response -> response .readJsonStream { line -> StoveSerde.jackson.anyJsonStringSerde().deserialize(line) }.collect { data -> println(data) } } } } } }) @OptIn(InternalAPI::class) fun HttpStatement.readJsonStream(transform: (String) -> T): Flow = flow { execute { while (!it.rawContent.isClosedForRead) { val line = it.rawContent.readLineStrict() if (line != null) { emit(transform(line)) } } } }.flowOn(Dispatchers.IO) private fun streamClient(timeout: Duration = 30.seconds.toJavaDuration()): HttpClient { val client = HttpClient(OkHttp) { engine { config { followRedirects(true) callTimeout(timeout) connectTimeout(timeout) readTimeout(timeout) writeTimeout(timeout) } } install(ContentNegotiation) { register(ContentType.Application.Json, JacksonConverter()) register(ContentType.Application.ProblemJson, JacksonConverter()) register(ContentType.parse("application/x-ndjson"), JacksonConverter()) } } return client } ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/kotlin/com/trendyol/stove/examples/kotlin/spring/e2e/tests/TheShowcase.kt ================================================ package com.trendyol.stove.examples.kotlin.spring.e2e.tests import arrow.core.some import com.trendyol.stove.examples.kotlin.spring.domain.order.* import com.trendyol.stove.examples.kotlin.spring.e2e.setup.tasks import com.trendyol.stove.examples.kotlin.spring.events.* import com.trendyol.stove.examples.kotlin.spring.grpc.* import com.trendyol.stove.examples.kotlin.spring.infra.clients.* import com.trendyol.stove.examples.kotlin.spring.infra.scheduling.OrderEmailPayload import com.trendyol.stove.grpc.grpc import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.* import com.trendyol.stove.testing.grpcmock.grpcMock import com.trendyol.stove.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.* import java.util.* import kotlin.time.Duration.Companion.seconds /** * THE SHOWCASE - One comprehensive test demonstrating all Stove features. * * Walk through this test section-by-section during your presentation. * * ══════════════════════════════════════════════════════════════════════════════ * 🎯 TWO WAYS TO DEMO FAILURE REPORTS: * ══════════════════════════════════════════════════════════════════════════════ * * Option 1: ASSERTION FAILURE (simple) * → Edit line ~110: change "CONFIRMED" to "WRONG_STATUS" * → Shows: Test assertion failed, with full trace of what happened * * Option 2: DEEP APPLICATION BUG (realistic) ⭐ RECOMMENDED * → Go to PostgresOrderRepository.kt * → Uncomment the line: // validateOrderAmount(order) * → Shows: Bug deep in persistence layer, test assertions are correct! * → The trace reveals the exact failure point in the call stack * * The amount ($2499.99) is intentionally > $1000 to trigger the demo bug. * ══════════════════════════════════════════════════════════════════════════════ */ class TheShowcase : FunSpec({ test("The Complete Order Flow - Every Feature in One Test") { stove { val userId = "user-${UUID.randomUUID()}" val productId = "macbook-pro-16" val amount = 2499.99 var orderId: String? = null // ══════════════════════════════════════════════════════════════ // SECTION 1: gRPC Mock - Mock External gRPC Service // "First, we mock the Fraud Detection gRPC service" // ══════════════════════════════════════════════════════════════ grpcMock { mockUnary( serviceName = "frauddetection.FraudDetectionService", methodName = "CheckFraud", response = CheckFraudResponse .newBuilder() .setIsFraudulent(false) .setRiskScore(0.15) .setReason("low_risk_user") .build() ) } // ══════════════════════════════════════════════════════════════ // SECTION 2: WireMock - Mock External REST APIs // "Our order service also calls inventory and payment APIs" // ══════════════════════════════════════════════════════════════ wiremock { mockGet( url = "/inventory/$productId", statusCode = 200, responseBody = InventoryResponse( productId = productId, available = true, quantity = 10 ).some() ) mockPost( url = "/payments/charge", statusCode = 200, responseBody = PaymentResult( success = true, transactionId = "txn-${UUID.randomUUID()}", amount = amount ).some() ) } // ══════════════════════════════════════════════════════════════ // SECTION 3: HTTP - Call Our API // "Now we call our order endpoint" // ══════════════════════════════════════════════════════════════ http { postAndExpectBody( uri = "/api/orders", body = CreateOrderRequest( userId = userId, productId = productId, amount = amount ).some() ) { response -> response.status shouldBe 201 response.body().status shouldBe "CONFIRMED" response.body().orderId shouldNotBe null orderId = response.body().orderId } } // ══════════════════════════════════════════════════════════════ // SECTION 4: Database - Verify State // "Let's check what's actually in the database" // ══════════════════════════════════════════════════════════════ postgresql { shouldQuery( query = "SELECT id, user_id, product_id, amount, status FROM orders WHERE user_id = '$userId'", mapper = { row -> OrderRow( id = row.string("id"), userId = row.string("user_id"), productId = row.string("product_id"), amount = row.double("amount"), status = row.string("status") ) } ) { orders -> orders.size shouldBe 1 orders.first().apply { this.userId shouldBe userId this.productId shouldBe productId this.amount shouldBe amount this.status shouldBe "CONFIRMED" // <-- EDIT THIS TO "WRONG_STATUS" TO SHOW FAILURE REPORT } } } // ══════════════════════════════════════════════════════════════ // SECTION 5: Kafka - Verify Events Published // "And check that the right events were published" // ══════════════════════════════════════════════════════════════ kafka { shouldBePublished(10.seconds) { actual.userId == userId && actual.productId == productId } shouldBePublished(10.seconds) { actual.amount == amount && actual.success } } // ══════════════════════════════════════════════════════════════ // SECTION 5b: Kafka - Verify Events Consumed + Side Effects // "Now let's verify the consumer processed the event AND // updated the read model (CQRS pattern)" // ══════════════════════════════════════════════════════════════ kafka { shouldBeConsumed(10.seconds) { actual.userId == userId && actual.orderId == orderId } } // Verify the side effect: statistics read model was updated postgresql { shouldQuery( query = "SELECT user_id, total_orders, total_amount FROM user_order_statistics WHERE user_id = '$userId'", mapper = { row -> UserStatisticsRow( userId = row.string("user_id"), totalOrders = row.int("total_orders"), totalAmount = row.double("total_amount") ) } ) { stats -> stats.size shouldBe 1 stats.first().apply { this.userId shouldBe userId this.totalOrders shouldBe 1 this.totalAmount shouldBe amount } } } // ══════════════════════════════════════════════════════════════ // SECTION 6: gRPC - Test OUR gRPC Server // "Our app also exposes a gRPC API for querying orders" // ══════════════════════════════════════════════════════════════ grpc { channel { // Query order by ID via gRPC val orderById = getOrder( GetOrderRequest .newBuilder() .setOrderId(orderId!!) .build() ) orderById.found shouldBe true orderById.order.userId shouldBe userId orderById.order.status shouldBe "CONFIRMED" // Query orders by user via gRPC val ordersByUser = getOrdersByUser( GetOrdersByUserRequest .newBuilder() .setUserId(userId) .build() ) ordersByUser.ordersCount shouldBe 1 ordersByUser.ordersList.first().productId shouldBe productId } } // ══════════════════════════════════════════════════════════════ // SECTION 7: Bridge - Access Application Beans // "We can also access our services directly" // ══════════════════════════════════════════════════════════════ using { val order = getOrderByUserId(userId) order shouldNotBe null order!!.status shouldBe OrderStatus.CONFIRMED } // ══════════════════════════════════════════════════════════════ // SECTION 8: db-scheduler - Verify Scheduled Tasks // "When an order is created, we schedule a confirmation email. // Let's verify the task was executed with the correct payload. // This showcases how to write your own Stove System!" // ══════════════════════════════════════════════════════════════ tasks { shouldBeExecuted { this.orderId == orderId && this.userId == userId && this.amount == amount } } } } }) /** * Simple data class for mapping database rows. */ data class OrderRow( val id: String, val userId: String, val productId: String, val amount: Double, val status: String ) /** * Data class for mapping user statistics rows. */ data class UserStatisticsRow( val userId: String, val totalOrders: Int, val totalAmount: Double ) ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.examples.kotlin.spring.e2e.setup.StoveConfig ================================================ FILE: recipes/jvm/kotlin-recipes/spring-showcase/src/test-e2e/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: recipes/jvm/scala-recipes/build.gradle.kts ================================================ plugins { java idea alias(libs.plugins.spotless) } subprojects { apply { plugin("java") plugin("idea") plugin(rootProject.libs.plugins.spotless.get().pluginId) } sourceSets { @Suppress("LocalVariableName", "ktlint:standard:property-naming") val `test-e2e` by creating { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output } val testE2eImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) } idea { module { testSources.from(sourceSets[TestFolders.e2e].allSource.sourceDirectories) testResources.from(sourceSets[TestFolders.e2e].resources.sourceDirectories) isDownloadJavadoc = true isDownloadSources = true } } dependencies { implementation(rootProject.projects.shared.domain) } tasks.register("e2eTest") { description = "Runs e2e tests." group = "verification" testClassesDirs = sourceSets[TestFolders.e2e].output.classesDirs classpath = sourceSets[TestFolders.e2e].runtimeClasspath useJUnitPlatform() reports { junitXml.required.set(true) html.required.set(true) } } } ================================================ FILE: recipes/jvm/scala-recipes/spring-boot-basic-recipe/build.gradle.kts ================================================ plugins { alias(libs.plugins.spring.boot) alias(libs.plugins.spring.plugin) alias(libs.plugins.spring.dependencyManagement) scala } dependencies { implementation(libs.scala2.library) implementation(libs.spring.boot.webflux) implementation(libs.spring.boot.autoconfigure) implementation(libs.spring.boot.kafka) annotationProcessor(libs.spring.boot.annotationProcessor) } dependencies { testImplementation(stoveLibs.stove) testImplementation(stoveLibs.stoveCouchbase) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveWiremock) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveSpring) } ================================================ FILE: recipes/jvm/scala-recipes/spring-boot-basic-recipe/src/main/scala/com/trendyol/stove/recipes/scala/spring/SpringBootRecipeApp.scala ================================================ package com.trendyol.stove.recipes.scala.spring import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.context.ConfigurableApplicationContext import org.springframework.stereotype.Component import org.springframework.web.bind.annotation.{ GetMapping, RequestMapping, RestController } @SpringBootApplication class SpringBootRecipeApp object SpringBootRecipeApp { def main(args: Array[String]): Unit = run(args, _ => ()) def run( args: Array[String], configure: SpringApplication => _ ): ConfigurableApplicationContext = { val app = new SpringApplication(classOf[SpringBootRecipeApp]) configure(app) app.run(args: _*) } } @RestController @RequestMapping(Array("/hello")) class HelloWorldController( private val currentThreadRetriever: CurrentThreadRetriever ) { @GetMapping def hello(): String = "Hello, World! from " + currentThreadRetriever.getCurrentThreadName } @Component class CurrentThreadRetriever { def getCurrentThreadName: String = Thread.currentThread().getName } ================================================ FILE: recipes/jvm/scala-recipes/spring-boot-basic-recipe/src/test-e2e/kotlin/com/trendyol/stove/recipes/scala/spring/e2e/setup/StoveConfig.kt ================================================ package com.trendyol.stove.recipes.scala.spring.e2e.setup import com.trendyol.stove.http.* import com.trendyol.stove.recipes.scala.spring.SpringBootRecipeApp import com.trendyol.stove.spring.* import com.trendyol.stove.system.Stove import io.kotest.core.config.AbstractProjectConfig class StoveConfig : AbstractProjectConfig() { override suspend fun beforeProject() { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:8080" ) } bridge() springBoot( runner = { parameters -> SpringBootRecipeApp.run(parameters) { } }, withParameters = listOf() ) }.run() } override suspend fun afterProject() { Stove.stop() } } ================================================ FILE: recipes/jvm/scala-recipes/spring-boot-basic-recipe/src/test-e2e/kotlin/com/trendyol/stove/recipes/scala/spring/e2e/tests/IndexTests.kt ================================================ package com.trendyol.stove.recipes.scala.spring.e2e.tests import com.trendyol.stove.http.http import com.trendyol.stove.recipes.scala.spring.CurrentThreadRetriever import com.trendyol.stove.system.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain class IndexTests : FunSpec({ test("Index page should be accessible") { stove { http { get("/hello") { actual -> actual shouldContain "Hello, World! from reactor" } } } } test("bridge should work") { stove { using { this.currentThreadName shouldNotBe "" println(this.currentThreadName) } } } }) ================================================ FILE: recipes/jvm/scala-recipes/spring-boot-basic-recipe/src/test-e2e/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.recipes.scala.spring.e2e.setup.StoveConfig ================================================ FILE: recipes/jvm/settings.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") import dev.aga.gradle.versioncatalogs.Generator.generate import dev.aga.gradle.versioncatalogs.GeneratorConfig rootProject.name = "jvm-recipes" pluginManagement { repositories { gradlePluginPortal() mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots") } } include( "kotlin-recipes", "kotlin-recipes:ktor-mongo-recipe", "kotlin-recipes:ktor-postgres-recipe", "kotlin-recipes:spring-showcase", "java-recipes", "java-recipes:spring-boot-postgres-recipe", "java-recipes:quarkus-basic-recipe", "scala-recipes", "scala-recipes:spring-boot-basic-recipe", "shared", "shared:domain", "shared:application", ) plugins { id("dev.aga.gradle.version-catalog-generator") version ("4.2.0") } enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") dependencyResolutionManagement { repositories { mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots") { content { includeGroup("com.trendyol") } } } versionCatalogs { generate("stoveLibs") { fromToml("stove-bom") { aliasPrefixGenerator = GeneratorConfig.NO_PREFIX // (8) } } } } ================================================ FILE: recipes/jvm/shared/application/build.gradle.kts ================================================ plugins { kotlin("jvm") version libs.versions.kotlin java idea } dependencies { compileOnly(libs.lombok) annotationProcessor(libs.lombok) } dependencies { testCompileOnly(libs.lombok) testAnnotationProcessor(libs.lombok) testImplementation(libs.arrow.core) } ================================================ FILE: recipes/jvm/shared/application/src/main/java/com/trendyol/stove/recipes/shared/application/BusinessException.java ================================================ package com.trendyol.stove.recipes.shared.application; public class BusinessException extends Exception { public BusinessException(String message) { super(message); } public BusinessException(String message, Throwable cause) { super(message, cause); } } ================================================ FILE: recipes/jvm/shared/application/src/main/java/com/trendyol/stove/recipes/shared/application/ErrorResponse.java ================================================ package com.trendyol.stove.recipes.shared.application; public record ErrorResponse(String message, String code) {} ================================================ FILE: recipes/jvm/shared/application/src/main/java/com/trendyol/stove/recipes/shared/application/ExternalApiConfiguration.java ================================================ package com.trendyol.stove.recipes.shared.application; import lombok.Data; @Data public abstract class ExternalApiConfiguration { private String url; private int timeout; public ExternalApiConfiguration() { this("", 0); } public ExternalApiConfiguration(String url, int timeout) { this.url = url; this.timeout = timeout; } } ================================================ FILE: recipes/jvm/shared/application/src/main/java/com/trendyol/stove/recipes/shared/application/category/CategoryApiConfiguration.java ================================================ package com.trendyol.stove.recipes.shared.application.category; import com.trendyol.stove.recipes.shared.application.ExternalApiConfiguration; public class CategoryApiConfiguration extends ExternalApiConfiguration {} ================================================ FILE: recipes/jvm/shared/application/src/main/java/com/trendyol/stove/recipes/shared/application/category/CategoryApiResponse.java ================================================ package com.trendyol.stove.recipes.shared.application.category; public record CategoryApiResponse(int id, String name, boolean isActive) { public CategoryApiResponse { if (name == null) { throw new IllegalArgumentException("Name cannot be null"); } } } ================================================ FILE: recipes/jvm/shared/domain/build.gradle.kts ================================================ plugins { kotlin("jvm") version libs.versions.kotlin java idea } dependencies { implementation(libs.jackson.annotations) compileOnly(libs.lombok) annotationProcessor(libs.lombok) } dependencies { testCompileOnly(libs.lombok) testAnnotationProcessor(libs.lombok) testImplementation(libs.arrow.core) } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/AggregateRoot.java ================================================ package com.trendyol.stove.examples.domain.ddd; import com.fasterxml.jackson.annotation.JsonIgnore; import java.util.List; import java.util.Locale; import java.util.function.Consumer; import lombok.Getter; @SuppressWarnings("unchecked") public abstract class AggregateRoot { private final EventRouter router; private final EventRecorder recorder; @Getter protected long version; @Getter private final TId id; protected AggregateRoot(TId id) { this.id = id; this.version = 0; this.router = new EventRouter(); this.recorder = new EventRecorder(); } protected void register( Class event, Consumer eventAction) { router.register(event, eventAction); } protected void applyEvent(TEvent event) { version++; event.setVersion(version); play(event); recorder.record(event); } protected void play(TEvent event) { router.route(event); } @JsonIgnore public String getIdAsString() { return id.toString(); } @JsonIgnore public void clearDomainEvents() { recorder.removeAll(); } @JsonIgnore public List domainEvents() { return recorder.getRecords(); } @JsonIgnore public boolean hasChanges() { return !domainEvents().isEmpty(); } @JsonIgnore public boolean isNew() { return version - domainEvents().size() == 0; } @JsonIgnore public String getAggregateName() { return this.getClass().getSimpleName().toLowerCase(Locale.ROOT); } @Override public boolean equals(Object other) { if (this == other) return true; if (getClass() != other.getClass()) return false; AggregateRoot otherAggregate = (AggregateRoot) other; return id.equals(otherAggregate.id); } @Override public int hashCode() { return id.hashCode(); } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/DomainEvent.java ================================================ package com.trendyol.stove.examples.domain.ddd; import lombok.AccessLevel; import lombok.Setter; public abstract class DomainEvent { public final String type = this.getClass().getSimpleName(); @Setter(AccessLevel.PROTECTED) private long version; public DomainEvent() { this.version = 0; } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/Entity.java ================================================ package com.trendyol.stove.examples.domain.ddd; import java.util.function.Consumer; public class Entity> { private final TId id; private final EventRouter router; public Entity(TId id) { this.id = id; this.router = new EventRouter(); } protected void register( Class event, Consumer eventAction) { router.register(event, eventAction); } public void route(TEvent event) { router.route(event); } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventPublisher.java ================================================ package com.trendyol.stove.examples.domain.ddd; public interface EventPublisher { void publishFor(AggregateRoot aggregateRoot); } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventRecorder.java ================================================ package com.trendyol.stove.examples.domain.ddd; import java.util.ArrayList; import java.util.List; public class EventRecorder { private final List events; public EventRecorder() { this.events = new ArrayList<>(); } public void record(DomainEvent event) { events.add(event); } public List getRecords() { return events; } public void removeAll() { events.clear(); } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/ddd/EventRouter.java ================================================ package com.trendyol.stove.examples.domain.ddd; import java.util.HashMap; import java.util.Map; import java.util.NoSuchElementException; import java.util.function.Consumer; public class EventRouter { private final Map, Consumer> eventActions = new HashMap<>(); public void register( Class eventClass, Consumer eventAction) { eventActions.put(eventClass, eventAction); } public void route(TEvent event) { if (!eventActions.containsKey(event.getClass())) { throw new NoSuchElementException( "Handler not found for: " + event.getClass().getName()); } } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/product/Product.java ================================================ package com.trendyol.stove.examples.domain.product; import com.trendyol.stove.examples.domain.ddd.AggregateRoot; import com.trendyol.stove.examples.domain.product.events.ProductCreatedEvent; import com.trendyol.stove.examples.domain.product.events.ProductNameChangedEvent; import com.trendyol.stove.examples.domain.product.events.ProductPriceChangedEvent; import java.nio.charset.StandardCharsets; import java.util.UUID; import lombok.Getter; @Getter public class Product extends AggregateRoot { @SuppressWarnings("unused") private Product() { super(null); } private Product(String id, String name, double price, int categoryId) { super(id); this.name = name; this.price = price; this.categoryId = categoryId; register(ProductCreatedEvent.class, this::handle); register(ProductNameChangedEvent.class, this::handle); register(ProductPriceChangedEvent.class, this::handle); } private String name; private double price; private int categoryId; public void changePrice(double newPrice) { applyEvent(new ProductPriceChangedEvent(newPrice)); } public void changeName(String newName) { applyEvent(new ProductNameChangedEvent(newName)); } private void handle(ProductCreatedEvent event) { this.name = event.name; this.price = event.price; } private void handle(ProductPriceChangedEvent event) { this.price = event.newPrice; } private void handle(ProductNameChangedEvent event) { this.name = event.newName; } public static Product create(String name, double price, int categoryId) { var aggregate = new Product( UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)).toString(), name, price, categoryId); aggregate.applyEvent(new ProductCreatedEvent(name, price, categoryId)); return aggregate; } public static Product fromPersistency( String id, String name, double price, int categoryId, long version) { var aggregate = new Product(id, name, price, categoryId); aggregate.version = version; return aggregate; } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/product/events/ProductCreatedEvent.java ================================================ package com.trendyol.stove.examples.domain.product.events; import com.trendyol.stove.examples.domain.ddd.DomainEvent; import lombok.NoArgsConstructor; @NoArgsConstructor(force = true) public class ProductCreatedEvent extends DomainEvent { public final String name; public final double price; public final int categoryId; public ProductCreatedEvent(String name, double price, int categoryId) { this.name = name; this.price = price; this.categoryId = categoryId; } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/product/events/ProductNameChangedEvent.java ================================================ package com.trendyol.stove.examples.domain.product.events; import com.trendyol.stove.examples.domain.ddd.DomainEvent; import lombok.NoArgsConstructor; @NoArgsConstructor(force = true) public class ProductNameChangedEvent extends DomainEvent { public final String newName; public ProductNameChangedEvent(String newName) { this.newName = newName; } } ================================================ FILE: recipes/jvm/shared/domain/src/main/java/com/trendyol/stove/examples/domain/product/events/ProductPriceChangedEvent.java ================================================ package com.trendyol.stove.examples.domain.product.events; import com.trendyol.stove.examples.domain.ddd.DomainEvent; import lombok.NoArgsConstructor; @NoArgsConstructor(force = true) public class ProductPriceChangedEvent extends DomainEvent { public final double newPrice; public ProductPriceChangedEvent(double newPrice) { this.newPrice = newPrice; } } ================================================ FILE: recipes/jvm/shared/domain/src/test/kotlin/com/trendyol/stove/examples/domain/ProductTests.kt ================================================ package com.trendyol.stove.examples.domain import com.trendyol.stove.examples.domain.product.Product import com.trendyol.stove.examples.domain.product.events.* import com.trendyol.stove.examples.domain.testing.aggregateroot.AggregateRootAssertion.Companion.assertEvents import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class ProductTests : FunSpec({ test("should create product") { // Given val product = Product.create("Product 1", 100.0, 1) // Then product.name shouldBe "Product 1" product.price shouldBe 100.0 } test("when price change") { // Given val product = Product.create("Product 1", 100.0, 1) // When product.changePrice(200.0) // Then product.version shouldBe 2 assertEvents(product) { shouldContain { newPrice shouldBe 200.0 } shouldContain { name shouldBe "Product 1" price shouldBe 100.0 } shouldNotContain() } } test("change name") { // Given val product = Product.create("Product 1", 100.0, 1) // When product.changeName("Product 2") // Then product.version shouldBe 2 assertEvents(product) { shouldContain { newName shouldBe "Product 2" } shouldContain { name shouldBe "Product 1" price shouldBe 100.0 } shouldNotContain() } } }) ================================================ FILE: recipes/jvm/shared/domain/src/test/kotlin/com/trendyol/stove/examples/domain/testing/aggregateroot/AggregateRootAssertion.kt ================================================ package com.trendyol.stove.examples.domain.testing.aggregateroot import arrow.core.firstOrNone import com.trendyol.stove.examples.domain.ddd.* import io.kotest.assertions.* import io.kotest.assertions.print.Printed import io.kotest.engine.mapError import io.kotest.matchers.collections.shouldHaveAtLeastSize import io.kotest.matchers.shouldBe class AggregateRootAssertion>( val root: AggregateRoot ) { fun shouldHaveCount(expectedCount: Int) = runCatching { root.domainEvents().count().shouldBe(expectedCount) } .mapError { throw createAssertionError( expected = Expected(Printed(expectedCount.toString())), actual = Actual(Printed(root.domainEvents().count().toString())), message = "Expected Count but found:", cause = it ) } inline fun shouldContain(act: T.() -> Unit) { shouldContain() root .domainEvents() .filter { it::class == T::class } .map { it as T } .shouldHaveAtLeastSize(1) .forEach { act(it) } } inline fun shouldContain() = root .domainEvents() .map { it.javaClass } .firstOrNone { it == T::class.java } .onNone { throw createAssertionError( expected = Expected(Printed(T::class.java.simpleName)), actual = Actual(Printed(domainEventsPrinted())), message = "Expected Domain Event Contain, but not found:", cause = null ) } inline fun shouldNotContain() = root .domainEvents() .map { it.javaClass } .firstOrNone { it == T::class.java } .onSome { throw createAssertionError( expected = Expected(Printed("[]")), actual = Actual(Printed(domainEventsPrinted())), message = "Expected Domain Event Not Contain, but found:", cause = null ) } @PublishedApi internal fun domainEventsPrinted(): String { val eventNames = root.domainEvents().joinToString(", ") { event -> event.javaClass.simpleName } return "[$eventNames]" } companion object { inline fun > assertEvents( root: TAggregateRoot, block: (AggregateRootAssertion).() -> Unit ) = block(AggregateRootAssertion(root)) } } ================================================ FILE: recipes/process/golang/go-showcase/.dockerignore ================================================ .gradle/ build/ go-coverage/ ================================================ FILE: recipes/process/golang/go-showcase/.editorconfig ================================================ root = true [*] insert_final_newline = true [{*.kt,*.kts}] indent_style = space max_line_length = 140 indent_size = 2 ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_continuation_indent_size = 2 ij_kotlin_allow_trailing_comma = false ij_kotlin_allow_trailing_comma_on_call_site = false ij_kotlin_name_count_to_use_star_import = 2 ij_kotlin_name_count_to_use_star_import_for_members = 2 [{**/stovetests/**.kt}] max_line_length = 240 ================================================ FILE: recipes/process/golang/go-showcase/.gitignore ================================================ # Go binary (produced by `go build .` in this directory) stove-go-showcase ================================================ FILE: recipes/process/golang/go-showcase/Dockerfile.container ================================================ FROM golang:1.26.3 AS build WORKDIR /workspace COPY go.mod go.sum ./ RUN go mod download COPY *.go ./ ARG GO_BUILD_FLAGS="" RUN CGO_ENABLED=0 GOOS=linux go build ${GO_BUILD_FLAGS} -o /out/go-showcase . FROM alpine:3.23 WORKDIR /app COPY --from=build /out/go-showcase /app/go-showcase EXPOSE 8090 ENTRYPOINT ["/app/go-showcase"] ================================================ FILE: recipes/process/golang/go-showcase/build.gradle.kts ================================================ plugins { kotlin("jvm") version "2.3.21" idea } // -- Go build ---------------------------------------------------------------- val goBinary = layout.buildDirectory.file("go-app").get().asFile val goExecutable = providers.environmentVariable("GO_EXECUTABLE").getOrElse("go") val dockerExecutable = providers.environmentVariable("DOCKER_EXECUTABLE").getOrElse("docker") val coverageEnabled = providers.gradleProperty("go.coverage").map { it.toBoolean() }.getOrElse(false) val goCoverDirPath = layout.buildDirectory.dir("go-coverage").get().asFile.absolutePath val goCoverOutPath = layout.buildDirectory.dir("go-coverage").get().asFile.resolve("coverage.out").absolutePath val goShowcaseContainerImage = "stove-go-showcase-container:local" tasks.register("goModTidy") { description = "Runs go mod tidy to sync dependencies." group = "build" commandLine(goExecutable, "mod", "tidy") inputs.files("go.mod", "go.sum") outputs.files("go.mod", "go.sum") } tasks.register("buildGoApp") { description = "Compiles the Go application." group = "build" dependsOn("goModTidy") val args = mutableListOf(goExecutable, "build") if (coverageEnabled) args.add("-cover") args.addAll(listOf("-o", goBinary.absolutePath, ".")) commandLine(args) inputs.files(fileTree(".") { include("*.go", "go.mod", "go.sum") }) inputs.property("goExecutable", goExecutable) outputs.file(goBinary) } tasks.register("buildContainerImage") { description = "Builds the Go showcase Docker image." group = "build" dependsOn("goModTidy") val buildFlags = if (coverageEnabled) "-cover" else "" commandLine( dockerExecutable, "build", "--file", projectDir.resolve("Dockerfile.container").absolutePath, "--tag", goShowcaseContainerImage, "--build-arg", "GO_BUILD_FLAGS=$buildFlags", projectDir.absolutePath ) inputs.file(project.file("Dockerfile.container")) inputs.file(project.file(".dockerignore")) inputs.files(fileTree(".") { include("*.go", "go.mod", "go.sum") }) inputs.property("coverageEnabled", coverageEnabled) outputs.upToDateWhen { false } } val removeContainerImageTask = tasks.register("removeContainerImage") { description = "Removes the local Go showcase Docker image." group = "build" commandLine(dockerExecutable, "image", "rm", goShowcaseContainerImage) isIgnoreExitValue = true } // -- Test source set ---------------------------------------------------------- val stoveTests = "stovetests" sourceSets { create(stoveTests) { kotlin { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output srcDirs("stovetests/kotlin") } resources.srcDirs("stovetests/resources") } } val stovetestsImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } configurations["stovetestsRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) idea { module { testSources.from(sourceSets[stoveTests].allSource.sourceDirectories) testResources.from(sourceSets[stoveTests].resources.sourceDirectories) } } // -- E2E test tasks ----------------------------------------------------------- val kafkaLibraries = listOf("sarama", "franz", "segmentio") val kafkaE2eTasks = kafkaLibraries.mapIndexed { index, lib -> tasks.register("e2eTest_$lib") { description = "Runs e2e tests with the $lib Kafka library." group = "verification" dependsOn("buildGoApp") testClassesDirs = sourceSets[stoveTests].output.classesDirs classpath = sourceSets[stoveTests].runtimeClasspath useJUnitPlatform() systemProperty("go.aut.mode", "process") systemProperty("go.app.binary", goBinary.absolutePath) systemProperty("kafka.library", lib) if (coverageEnabled) { systemProperty("go.cover.dir", goCoverDirPath) outputs.cacheIf { false } // Coverage data is a side effect, not a tracked output } if (index > 0) mustRunAfter("e2eTest_${kafkaLibraries[index - 1]}") } } tasks.register("e2eTest") { description = "Runs e2e tests for all Kafka libraries." group = "verification" dependsOn(kafkaE2eTasks) enabled = false } val containerE2eTask = tasks.register("e2eTest-container") { description = "Runs container-based e2e tests with sarama Kafka library." group = "verification" dependsOn("buildContainerImage") testClassesDirs = sourceSets[stoveTests].output.classesDirs classpath = sourceSets[stoveTests].runtimeClasspath useJUnitPlatform() systemProperty("go.aut.mode", "container") systemProperty("go.app.container.image", goShowcaseContainerImage) systemProperty("kafka.library", "sarama") if (coverageEnabled) { systemProperty("go.cover.dir", goCoverDirPath) outputs.cacheIf { false } // Coverage data is a side effect, not a tracked output } } // -- Go coverage reports ------------------------------------------------------ if (coverageEnabled) { val goCoverHtmlPath = layout.buildDirectory.dir("go-coverage").get().asFile.resolve("coverage.html").absolutePath tasks.register("goCoverageReport") { description = "Converts Go coverage data to standard format." group = "verification" mustRunAfter(kafkaE2eTasks) mustRunAfter(containerE2eTask) commandLine(goExecutable, "tool", "covdata", "textfmt", "-i=$goCoverDirPath", "-o=$goCoverOutPath") } tasks.register("goCoverageSummary") { description = "Prints Go coverage summary." group = "verification" dependsOn("goCoverageReport") commandLine(goExecutable, "tool", "cover", "-func=$goCoverOutPath") } tasks.register("goCoverageHtml") { description = "Generates HTML coverage report." group = "verification" dependsOn("goCoverageReport") commandLine(goExecutable, "tool", "cover", "-html=$goCoverOutPath", "-o=$goCoverHtmlPath") doLast { logger.lifecycle("Go coverage HTML: $goCoverHtmlPath") } } tasks.register("e2eTestWithCoverage") { description = "Runs e2e tests and generates Go coverage report." group = "verification" dependsOn(kafkaE2eTasks) finalizedBy("goCoverageSummary", "goCoverageHtml") } tasks.register("e2eTest-containerWithCoverage") { description = "Runs container e2e tests and generates Go coverage report." group = "verification" dependsOn(containerE2eTask) finalizedBy("goCoverageSummary", "goCoverageHtml") } } // -- Dependencies ------------------------------------------------------------- dependencies { testImplementation(stoveLibs.stove) testImplementation("com.trendyol:stove-container:${libs.versions.stove.get()}") testImplementation(stoveLibs.stoveProcess) testImplementation(stoveLibs.stovePostgres) testImplementation(stoveLibs.stoveHttp) testImplementation(stoveLibs.stoveTracing) testImplementation(stoveLibs.stoveDashboard) testImplementation(stoveLibs.stoveKafka) testImplementation(stoveLibs.stoveExtensionsKotest) testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.framework.engine) testImplementation(libs.kotest.assertions.core) } // -- Kotlin / Java settings --------------------------------------------------- kotlin { jvmToolchain(21) } tasks.withType { useJUnitPlatform() jvmArgs("--add-opens", "java.base/java.util=ALL-UNNAMED") } ================================================ FILE: recipes/process/golang/go-showcase/db.go ================================================ package main import ( "context" "database/sql" "fmt" "github.com/XSAM/otelsql" _ "github.com/lib/pq" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" ) func initDB(connStr string) (*sql.DB, error) { // otelsql wraps database/sql — all queries are automatically traced db, err := otelsql.Open("postgres", connStr, otelsql.WithAttributes(semconv.DBSystemPostgreSQL), ) if err != nil { return nil, fmt.Errorf("open: %w", err) } if err := db.Ping(); err != nil { return nil, fmt.Errorf("ping: %w", err) } return db, nil } func insertProduct(ctx context.Context, db *sql.DB, p Product) error { _, err := db.ExecContext(ctx, "INSERT INTO products (id, name, price) VALUES ($1, $2, $3)", p.ID, p.Name, p.Price, ) return err } func getProduct(ctx context.Context, db *sql.DB, id string) (*Product, error) { row := db.QueryRowContext(ctx, "SELECT id, name, price FROM products WHERE id = $1", id) var p Product if err := row.Scan(&p.ID, &p.Name, &p.Price); err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, err } return &p, nil } func updateProduct(ctx context.Context, db *sql.DB, id string, name string, price float64) error { _, err := db.ExecContext(ctx, "UPDATE products SET name = $1, price = $2 WHERE id = $3", name, price, id, ) return err } func listProducts(ctx context.Context, db *sql.DB) ([]Product, error) { rows, err := db.QueryContext(ctx, "SELECT id, name, price FROM products") if err != nil { return nil, err } defer rows.Close() var products []Product for rows.Next() { var p Product if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil { return nil, err } products = append(products, p) } return products, rows.Err() } ================================================ FILE: recipes/process/golang/go-showcase/go.mod ================================================ module github.com/trendyol/stove-go-showcase go 1.26.2 require ( github.com/IBM/sarama v1.48.1 github.com/XSAM/otelsql v0.42.0 github.com/google/uuid v1.6.0 github.com/lib/pq v1.12.3 github.com/segmentio/kafka-go v0.4.51 github.com/trendyol/stove/go/stove-kafka v0.0.0-20260511094143-5fae367ca053 github.com/twmb/franz-go v1.21.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 google.golang.org/grpc v1.81.0 ) require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/protobuf v1.36.11 // indirect ) ================================================ FILE: recipes/process/golang/go-showcase/go.sum ================================================ github.com/IBM/sarama v1.48.0 h1:9LJS0VNeg/boXxT/GLAMDKX6uSQ1mr/5F/j4v9gSeBQ= github.com/IBM/sarama v1.48.0/go.mod h1:UhvwPF8zilmLOSd6O+ENzdycCJYwMww1U9DJOZpoCro= github.com/IBM/sarama v1.48.1 h1:x1dSWebprjjE7Wr7n8RVAxwa4mt4O9JejRxnZrGIXk0= github.com/IBM/sarama v1.48.1/go.mod h1:m/Q1aFezH82/AglfTpJbw/fO0ZybYXhPgTmvajiZX50= github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o= github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260503095836-333309ee621f h1:9LnGCifF9X/Udrjo4BFBf86LVMTgBnTbCMMw1KLokvI= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260503095836-333309ee621f/go.mod h1:iLVd0USfKOR+f5CQkdsoeBhE1hN4V6XG5/6+kyGfT00= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260503102840-e1066b3be7fe h1:pYVQ83PqcHCBG1rhe4OM+vFMLaxtQ+NHqphiB1hYoLU= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260503102840-e1066b3be7fe/go.mod h1:iLVd0USfKOR+f5CQkdsoeBhE1hN4V6XG5/6+kyGfT00= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260503221219-fcfb87c85a78 h1:vZFpp8fLTkkXjuJFPJBRCEJmwW48S0+m1gfN8sZUPag= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260503221219-fcfb87c85a78/go.mod h1:iLVd0USfKOR+f5CQkdsoeBhE1hN4V6XG5/6+kyGfT00= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260506073743-d4b7ee1d0368 h1:bzdIcvw7f1O9/uAsOlPSFwugYZ/W+U+Nmp6jF+aAnSE= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260506073743-d4b7ee1d0368/go.mod h1:/FSas5cvybs+2bAM1mlfP193vPoI5RJOXWQuu7q9ncE= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260511094143-5fae367ca053 h1:zCLRdLXmYhVJIw5lR5oGuPM0IMyZ41sGepMEcq7e0n8= github.com/trendyol/stove/go/stove-kafka v0.0.0-20260511094143-5fae367ca053/go.mod h1:LlJxOgdy8NlPub93cxvFNBOev22rlXna/nM3t0N2TxM= github.com/twmb/franz-go v1.21.0 h1:J3uB/poWgHD6VIilER2uCPFAZHDRXVFT+11pBgRKod4= github.com/twmb/franz-go v1.21.0/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= github.com/twmb/franz-go v1.21.1 h1:sp17bMRLz6OB/w+7vHtBadHGIQVymzQHwvRbEKe5c4I= github.com/twmb/franz-go v1.21.1/go.mod h1:1o+jj5oRbItsIMoE+DGpfJIcPcPtDdtkcNFPj4bWNwU= github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: recipes/process/golang/go-showcase/gradle/libs.versions.toml ================================================ [versions] stove = "1.0.0.529-SNAPSHOT" kotest = "6.1.11" [libraries] stove-bom = { module = "com.trendyol:stove-bom", version.ref = "stove" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" } kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } ================================================ FILE: recipes/process/golang/go-showcase/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: recipes/process/golang/go-showcase/gradle.properties ================================================ org.gradle.parallel=false org.gradle.caching=true org.gradle.configuration-cache=true ================================================ FILE: recipes/process/golang/go-showcase/gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: recipes/process/golang/go-showcase/gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: recipes/process/golang/go-showcase/handlers.go ================================================ package main import ( "database/sql" "encoding/json" "log" "net/http" "github.com/google/uuid" ) // Product represents a product entity. type Product struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` } type createProductRequest struct { Name string `json:"name"` Price float64 `json:"price"` } func registerRoutes(mux *http.ServeMux, db *sql.DB, producer KafkaProducer) { mux.HandleFunc("GET /health", handleHealth) mux.HandleFunc("POST /api/products", handleCreateProduct(db, producer)) mux.HandleFunc("GET /api/products/{id}", handleGetProduct(db)) mux.HandleFunc("GET /api/products", handleListProducts(db)) } func handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "UP"}) } func handleCreateProduct(db *sql.DB, producer KafkaProducer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req createProductRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) return } product := Product{ ID: uuid.New().String(), Name: req.Name, Price: req.Price, } if err := insertProduct(r.Context(), db, product); err != nil { http.Error(w, `{"error":"failed to create product"}`, http.StatusInternalServerError) return } // Publish ProductCreatedEvent to Kafka if producer != nil { event := ProductCreatedEvent{ID: product.ID, Name: product.Name, Price: product.Price} eventBytes, err := json.Marshal(event) if err != nil { log.Printf("failed to marshal ProductCreatedEvent: %v", err) } else if err := producer.SendMessage(topicProductCreated, product.ID, eventBytes); err != nil { log.Printf("failed to publish ProductCreatedEvent: %v", err) } } writeJSON(w, http.StatusCreated, product) } } func handleGetProduct(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") product, err := getProduct(r.Context(), db, id) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } if product == nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } writeJSON(w, http.StatusOK, product) } } func handleListProducts(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { products, err := listProducts(r.Context(), db) if err != nil { http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } if products == nil { products = []Product{} } writeJSON(w, http.StatusOK, products) } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } ================================================ FILE: recipes/process/golang/go-showcase/kafka.go ================================================ package main import ( "context" "database/sql" "encoding/json" "log" stovekafka "github.com/trendyol/stove/go/stove-kafka" ) const ( topicProductCreated = "product.created" topicProductUpdate = "product.update" ) // ProductCreatedEvent is published to the product.created topic when a product is created. type ProductCreatedEvent struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` } // ProductUpdateEvent is consumed from the product.update topic to update existing products. type ProductUpdateEvent struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` } // KafkaProducer abstracts message production across different Kafka client libraries. type KafkaProducer interface { SendMessage(topic, key string, value []byte) error Close() error } // initKafka creates a producer and starts a consumer using the specified library. // Supported libraries: "sarama" (default), "franz", "segmentio". func initKafka(library, brokers string, db *sql.DB, bridge *stovekafka.Bridge) (KafkaProducer, func(), error) { if brokers == "" { return nil, func() {}, nil } log.Printf("Kafka initializing with library=%s brokers=%s", library, brokers) groupID := "go-showcase-" + library switch library { case "franz": return initFranzKafka(brokers, groupID, db, bridge) case "segmentio": return initSegmentioKafka(brokers, groupID, db, bridge) default: return initSaramaKafka(brokers, groupID, db, bridge) } } // handleProductUpdate is shared consumer logic for all Kafka libraries. func handleProductUpdate(db *sql.DB, value []byte) { var event ProductUpdateEvent if err := json.Unmarshal(value, &event); err != nil { log.Printf("failed to unmarshal update event: %v", err) return } if err := updateProduct(context.Background(), db, event.ID, event.Name, event.Price); err != nil { log.Printf("failed to update product %s: %v", event.ID, err) } } ================================================ FILE: recipes/process/golang/go-showcase/kafka_franz.go ================================================ package main import ( "context" "database/sql" "log" "strings" "time" stovekafka "github.com/trendyol/stove/go/stove-kafka" stovefranz "github.com/trendyol/stove/go/stove-kafka/franz" "github.com/twmb/franz-go/pkg/kgo" ) type franzProducer struct { client *kgo.Client } func (p *franzProducer) SendMessage(topic, key string, value []byte) error { results := p.client.ProduceSync(context.Background(), &kgo.Record{ Topic: topic, Key: []byte(key), Value: value, }) return results.FirstErr() } func (p *franzProducer) Close() error { p.client.Close() return nil } func initFranzKafka(brokers, groupID string, db *sql.DB, bridge *stovekafka.Bridge) (KafkaProducer, func(), error) { brokerList := strings.Split(brokers, ",") hook := &stovefranz.Hook{Bridge: bridge} // Separate producer client — no consumer group overhead producerClient, err := kgo.NewClient( kgo.SeedBrokers(brokerList...), kgo.AllowAutoTopicCreation(), kgo.WithHooks(hook), ) if err != nil { return nil, nil, err } // Separate consumer client — consumer group coordination won't block produces consumerClient, err := kgo.NewClient( kgo.SeedBrokers(brokerList...), kgo.ConsumeTopics(topicProductUpdate), kgo.ConsumerGroup(groupID), kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()), kgo.AutoCommitInterval(100*time.Millisecond), kgo.AllowAutoTopicCreation(), kgo.WithHooks(hook), ) if err != nil { producerClient.Close() return nil, nil, err } ctx, cancel := context.WithCancel(context.Background()) go func() { for { fetches := consumerClient.PollFetches(ctx) if ctx.Err() != nil { return } fetches.EachRecord(func(r *kgo.Record) { if r.Topic == topicProductUpdate { handleProductUpdate(db, r.Value) } }) } }() stop := func() { cancel() consumerClient.Close() producerClient.Close() } log.Printf("Kafka (franz-go) initialized") return &franzProducer{client: producerClient}, stop, nil } ================================================ FILE: recipes/process/golang/go-showcase/kafka_sarama.go ================================================ package main import ( "context" "database/sql" "log" "strings" "time" "github.com/IBM/sarama" stovekafka "github.com/trendyol/stove/go/stove-kafka" stovesarama "github.com/trendyol/stove/go/stove-kafka/sarama" ) type saramaProducer struct { producer sarama.SyncProducer } func (p *saramaProducer) SendMessage(topic, key string, value []byte) error { _, _, err := p.producer.SendMessage(&sarama.ProducerMessage{ Topic: topic, Key: sarama.StringEncoder(key), Value: sarama.ByteEncoder(value), }) return err } func (p *saramaProducer) Close() error { return p.producer.Close() } func initSaramaKafka(brokers, groupID string, db *sql.DB, bridge *stovekafka.Bridge) (KafkaProducer, func(), error) { brokerList := strings.Split(brokers, ",") config := sarama.NewConfig() config.Producer.Return.Successes = true config.Consumer.Offsets.Initial = sarama.OffsetOldest config.Consumer.Offsets.AutoCommit.Interval = 100 * time.Millisecond config.Producer.Interceptors = []sarama.ProducerInterceptor{ &stovesarama.ProducerInterceptor{Bridge: bridge}, } config.Consumer.Interceptors = []sarama.ConsumerInterceptor{ &stovesarama.ConsumerInterceptor{Bridge: bridge}, } producer, err := sarama.NewSyncProducer(brokerList, config) if err != nil { return nil, nil, err } consumerGroup, err := sarama.NewConsumerGroup(brokerList, groupID, config) if err != nil { producer.Close() return nil, nil, err } ctx, cancel := context.WithCancel(context.Background()) handler := &saramaUpdateHandler{db: db} go func() { for { if err := consumerGroup.Consume(ctx, []string{topicProductUpdate}, handler); err != nil { log.Printf("sarama consumer group error: %v", err) } if ctx.Err() != nil { return } } }() stop := func() { cancel() consumerGroup.Close() producer.Close() } log.Printf("Kafka (sarama) initialized") return &saramaProducer{producer: producer}, stop, nil } type saramaUpdateHandler struct { db *sql.DB } func (h *saramaUpdateHandler) Setup(_ sarama.ConsumerGroupSession) error { return nil } func (h *saramaUpdateHandler) Cleanup(_ sarama.ConsumerGroupSession) error { return nil } func (h *saramaUpdateHandler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { for msg := range claim.Messages() { handleProductUpdate(h.db, msg.Value) session.MarkMessage(msg, "") } return nil } ================================================ FILE: recipes/process/golang/go-showcase/kafka_segmentio.go ================================================ package main import ( "context" "database/sql" "errors" "log" "strings" "time" kafka "github.com/segmentio/kafka-go" stovekafka "github.com/trendyol/stove/go/stove-kafka" "github.com/trendyol/stove/go/stove-kafka/segmentio" ) type segmentioProducer struct { writer *kafka.Writer bridge *stovekafka.Bridge } func (p *segmentioProducer) SendMessage(topic, key string, value []byte) error { ctx := context.Background() msg := kafka.Message{ Topic: topic, Key: []byte(key), Value: value, } if err := p.writer.WriteMessages(ctx, msg); err != nil { return err } segmentio.ReportWritten(ctx, p.bridge, msg) return nil } func (p *segmentioProducer) Close() error { return p.writer.Close() } func initSegmentioKafka(brokers, groupID string, db *sql.DB, bridge *stovekafka.Bridge) (KafkaProducer, func(), error) { brokerList := strings.Split(brokers, ",") writer := &kafka.Writer{ Addr: kafka.TCP(brokerList...), BatchSize: 1, BatchTimeout: 10 * time.Millisecond, RequiredAcks: kafka.RequireAll, AllowAutoTopicCreation: true, } reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: brokerList, GroupID: groupID, Topic: topicProductUpdate, MinBytes: 1, MaxBytes: 10e6, CommitInterval: 100 * time.Millisecond, MaxWait: 500 * time.Millisecond, }) ctx, cancel := context.WithCancel(context.Background()) go func() { for { msg, err := reader.ReadMessage(ctx) if err != nil { if errors.Is(err, context.Canceled) { return } log.Printf("segmentio reader error: %v", err) continue } segmentio.ReportRead(ctx, bridge, msg) handleProductUpdate(db, msg.Value) } }() stop := func() { cancel() reader.Close() writer.Close() } log.Printf("Kafka (segmentio/kafka-go) initialized") return &segmentioProducer{writer: writer, bridge: bridge}, stop, nil } ================================================ FILE: recipes/process/golang/go-showcase/main.go ================================================ package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" stovekafka "github.com/trendyol/stove/go/stove-kafka" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func main() { // Ignore SIGPIPE so log writes to a closed stdout pipe don't kill the process. // This ensures clean shutdown (and coverage flush) when run under a process manager. signal.Ignore(syscall.SIGPIPE) ctx := context.Background() port := getEnv("APP_PORT", "8080") dbHost := getEnv("DB_HOST", "localhost") dbPort := getEnv("DB_PORT", "5432") dbName := getEnv("DB_NAME", "stove") dbUser := getEnv("DB_USER", "sa") dbPass := getEnv("DB_PASS", "sa") shutdownTracing, err := initTracing(ctx, "go-showcase") if err != nil { log.Fatalf("failed to init tracing: %v", err) } defer shutdownTracing(ctx) connStr := fmt.Sprintf( "host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", dbHost, dbPort, dbName, dbUser, dbPass, ) db, err := initDB(connStr) if err != nil { log.Fatalf("failed to connect to database: %v", err) } defer db.Close() // Initialize Stove Kafka bridge (nil in production — zero overhead) bridge, err := stovekafka.NewBridgeFromEnv() if err != nil { log.Fatalf("failed to init stove bridge: %v", err) } defer bridge.Close() // Initialize Kafka producer and consumer kafkaLibrary := getEnv("KAFKA_LIBRARY", "sarama") brokers := getEnv("KAFKA_BROKERS", "") producer, stopKafka, err := initKafka(kafkaLibrary, brokers, db, bridge) if err != nil { log.Fatalf("failed to init kafka: %v", err) } defer stopKafka() mux := http.NewServeMux() registerRoutes(mux, db, producer) // Wrap with OTel HTTP instrumentation for automatic span creation handler := otelhttp.NewHandler(mux, "http.request") server := &http.Server{ Addr: ":" + port, Handler: handler, ReadHeaderTimeout: 10 * time.Second, } // Graceful shutdown on SIGTERM/SIGINT stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) go func() { log.Printf("Go showcase app listening on :%s", port) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } }() <-stop log.Println("shutting down...") shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("shutdown error: %v", err) } log.Println("server stopped") } ================================================ FILE: recipes/process/golang/go-showcase/settings.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") import dev.aga.gradle.versioncatalogs.Generator.generate import dev.aga.gradle.versioncatalogs.GeneratorConfig rootProject.name = "go-showcase" val useMavenLocal = providers.gradleProperty("useMavenLocal").map(String::toBoolean).getOrElse(false) pluginManagement { repositories { gradlePluginPortal() mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots") } } plugins { id("dev.aga.gradle.version-catalog-generator") version "4.2.0" } dependencyResolutionManagement { repositories { if (useMavenLocal) { mavenLocal() } mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots") { content { includeGroup("com.trendyol") } } } versionCatalogs { generate("stoveLibs") { fromToml("stove-bom") { aliasPrefixGenerator = GeneratorConfig.NO_PREFIX } } } } ================================================ FILE: recipes/process/golang/go-showcase/stovetests/kotlin/com/trendyol/stove/examples/go/e2e/setup/ProductMigration.kt ================================================ package com.trendyol.stove.examples.go.e2e.setup import com.trendyol.stove.database.migrations.DatabaseMigration import com.trendyol.stove.postgres.PostgresSqlMigrationContext class ProductMigration : DatabaseMigration { override val order: Int = 1 override suspend fun execute(connection: PostgresSqlMigrationContext) { connection.operations.execute( """ CREATE TABLE IF NOT EXISTS products ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, price DECIMAL(10, 2) NOT NULL ); """.trimIndent() ) } } ================================================ FILE: recipes/process/golang/go-showcase/stovetests/kotlin/com/trendyol/stove/examples/go/e2e/setup/StoveConfig.kt ================================================ package com.trendyol.stove.examples.go.e2e.setup import com.trendyol.stove.container.ContainerTarget import com.trendyol.stove.container.containerApp import com.trendyol.stove.dashboard.DashboardSystemOptions import com.trendyol.stove.dashboard.dashboard import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.http.HttpClientSystemOptions import com.trendyol.stove.http.httpClient import com.trendyol.stove.kafka.KafkaSystemOptions import com.trendyol.stove.kafka.kafka import com.trendyol.stove.kafka.stoveKafkaBridgePortDefault import com.trendyol.stove.postgres.PostgresqlOptions import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.process.ProcessTarget import com.trendyol.stove.process.envMapper as processEnvMapper import com.trendyol.stove.process.goApp import com.trendyol.stove.system.Stove import com.trendyol.stove.system.application.envMapper as containerEnvMapper import com.trendyol.stove.tracing.tracing import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import java.io.File private const val APP_PORT = 8090 private const val OTLP_PORT = 4317 private const val COVERAGE_DIR_IN_CONTAINER = "/tmp/go-coverage" private enum class GoAutMode { Process, Container } class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() { val autMode = resolveAutMode() val appImage = System.getProperty("go.app.container.image").orEmpty() val kafkaLibrary = System.getProperty("kafka.library") ?: "sarama" val hostCoverageDir = System.getProperty("go.cover.dir").orEmpty() val coverageDirInContainer = if (hostCoverageDir.isBlank()) "" else COVERAGE_DIR_IN_CONTAINER Stove() .with { httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:$APP_PORT") } dashboard { DashboardSystemOptions(appName = "go-showcase") } tracing { enableSpanReceiver(port = OTLP_PORT) } kafka { KafkaSystemOptions( configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.bootstrapServers}") } ) } postgresql { PostgresqlOptions( databaseName = "stove", configureExposedConfiguration = { cfg -> listOf( "database.host=${cfg.host}", "database.port=${cfg.port}", "database.name=stove", "database.username=${cfg.username}", "database.password=${cfg.password}" ) } ).migrations { register() } } when (autMode) { GoAutMode.Process -> goApp( target = ProcessTarget.Server(port = APP_PORT, portEnvVar = "APP_PORT"), envProvider = processEnvMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "database.username" to "DB_USER" "database.password" to "DB_PASS" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:$OTLP_PORT") env("KAFKA_LIBRARY", kafkaLibrary) env("STOVE_KAFKA_BRIDGE_PORT", stoveKafkaBridgePortDefault) env("GOCOVERDIR") { hostCoverageDir.takeIf { it.isNotBlank() }?.also { File(it).mkdirs() } ?: "" } } ) GoAutMode.Container -> { require(appImage.isNotBlank()) { "go.app.container.image system property not set" } containerApp( image = appImage, target = ContainerTarget.Server( hostPort = APP_PORT, internalPort = APP_PORT, portEnvVar = "APP_PORT", bindHostPort = false ), envProvider = containerEnvMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" "database.name" to "DB_NAME" "database.username" to "DB_USER" "database.password" to "DB_PASS" "kafka.bootstrapServers" to "KAFKA_BROKERS" env("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:$OTLP_PORT") env("KAFKA_LIBRARY", kafkaLibrary) env("STOVE_KAFKA_BRIDGE_PORT", stoveKafkaBridgePortDefault) env("GOCOVERDIR", coverageDirInContainer) }, configureContainer = { withNetworkMode("host") if (hostCoverageDir.isNotBlank()) { withFileSystemBind(hostCoverageDir, COVERAGE_DIR_IN_CONTAINER) } } ) } } }.run() } override suspend fun afterProject() { Stove.stop() } } private fun resolveAutMode(): GoAutMode = when ((System.getProperty("go.aut.mode") ?: System.getenv("GO_AUT_MODE") ?: "process").lowercase()) { "process" -> GoAutMode.Process "container" -> GoAutMode.Container else -> error("Unsupported go.aut.mode. Use 'process' or 'container'.") } ================================================ FILE: recipes/process/golang/go-showcase/stovetests/kotlin/com/trendyol/stove/examples/go/e2e/tests/GoShowcaseTest.kt ================================================ package com.trendyol.stove.examples.go.e2e.tests import arrow.core.some import com.trendyol.stove.http.http import com.trendyol.stove.kafka.kafka import com.trendyol.stove.postgres.postgresql import com.trendyol.stove.system.stove import com.trendyol.stove.tracing.tracing import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import kotliquery.Row import kotlin.time.Duration.Companion.seconds data class CreateProductRequest( val name: String, val price: Double ) data class ProductResponse( val id: String, val name: String, val price: Double ) data class ProductRow( val id: String, val name: String, val price: Double ) data class ProductCreatedEvent( val id: String, val name: String, val price: Double ) data class ProductUpdateEvent( val id: String, val name: String, val price: Double ) private val productRowMapper: (Row) -> ProductRow = { row -> ProductRow( id = row.string("id"), name = row.string("name"), price = row.double("price") ) } class GoShowcaseTest : FunSpec({ test("should create a product and verify via HTTP, database, and traces") { stove { val productName = "Stove Go Showcase Product" val productPrice = 42.99 var productId: String? = null // 1. Create a product via the Go application's REST API http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = productName, price = productPrice).some() ) { actual -> actual.status shouldBe 201 productId = actual.body().id actual.body().name shouldBe productName actual.body().price shouldBe productPrice } } // 2. Verify the product was persisted in PostgreSQL postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = productRowMapper ) { rows -> rows.size shouldBe 1 rows.first().name shouldBe productName rows.first().price shouldBe productPrice } } // 3. Read the product back via HTTP http { getResponse( uri = "/api/products/$productId" ) { actual -> actual.status shouldBe 200 actual.body().id shouldBe productId actual.body().name shouldBe productName } } // 4. Verify traces — spans are auto-created by otelhttp middleware and otelsql driver tracing { waitForSpans(4, 5000) shouldContainSpan("http.request") shouldNotHaveFailedSpans() spanCountShouldBeAtLeast(4) executionTimeShouldBeLessThan(30.seconds) } } } test("should list all products") { stove { http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Product A", price = 10.0).some() ) { actual -> actual.body().name shouldBe "Product A" } } http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Product B", price = 20.0).some() ) { actual -> actual.body().name shouldBe "Product B" } } http { getMany( uri = "/api/products" ) { actual -> actual.size shouldNotBe 0 } } tracing { waitForSpans(4, 5000) shouldContainSpan("http.request") shouldNotHaveFailedSpans() } } } test("should return 404 for non-existent product") { stove { http { getBodilessResponse("/api/products/non-existent-id") { actual -> actual.status shouldBe 404 } } } } test("should publish ProductCreatedEvent when product is created") { stove { http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Kafka Product", price = 29.99).some() ) { actual -> actual.status shouldBe 201 actual.body().name shouldBe "Kafka Product" } } kafka { shouldBePublished(10.seconds) { actual.name == "Kafka Product" && actual.price == 29.99 } } } } test("should consume product update events from Kafka") { stove { var productId: String? = null // First create a product via HTTP http { postAndExpectBody( uri = "/api/products", body = CreateProductRequest(name = "Original Name", price = 10.0).some() ) { actual -> actual.status shouldBe 201 productId = actual.body().id } } // Publish an update event to Kafka — Go consumer picks it up and updates DB kafka { publish("product.update", ProductUpdateEvent(id = productId!!, name = "Updated Name", price = 99.99)) shouldBeConsumed(10.seconds) { actual.id == productId && actual.name == "Updated Name" } } // Verify the product was updated in the database postgresql { shouldQuery( query = "SELECT id, name, price FROM products WHERE id = '$productId'", mapper = productRowMapper ) { rows -> rows.size shouldBe 1 rows.first().name shouldBe "Updated Name" rows.first().price shouldBe 99.99 } } } } }) ================================================ FILE: recipes/process/golang/go-showcase/stovetests/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.examples.go.e2e.setup.StoveConfig ================================================ FILE: recipes/process/golang/go-showcase/tracing.go ================================================ package main import ( "context" "log" "os" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) func initTracing(ctx context.Context, serviceName string) (func(context.Context), error) { endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") if endpoint == "" { return func(context.Context) {}, nil } conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, err } exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn)) if err != nil { return nil, err } res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceNameKey.String(serviceName)), ) if err != nil { return nil, err } tp := sdktrace.NewTracerProvider( sdktrace.WithSyncer(exporter), sdktrace.WithResource(res), ) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) log.Printf("Tracing enabled, exporting to %s", endpoint) return func(ctx context.Context) { if err := tp.Shutdown(ctx); err != nil { log.Printf("tracing shutdown error: %v", err) } }, nil } ================================================ FILE: renovate.json ================================================ { "extends": [ "config:recommended", "group:allNonMajor", "group:monorepos", "schedule:earlyMondays" ], "branchPrefix": "renovate/", "packageRules": [ { "allowedVersions": "!/M/", "matchPackageNames": [ "/io.kotest:kotest.*/" ] }, { "matchDepNames": [ "/org.jetbrains.kotlin.*/", "/com.google.devtools.ksp.*/" ], "groupName": "kotlin" }, { "matchDepNames": [ "/.*micronaut.*/" ], "groupName": "micronaut" }, { "matchDepNames": [ "/.*springframework.*/" ], "groupName": "spring" }, { "matchDepNames": [ "/.*quarkus.*/" ], "groupName": "quarkus" }, { "matchDepNames": [ "/io.confluent.*/" ], "groupName": "confluent" }, { "matchDepNames": [ "/org.apache.kafka.*/" ], "groupName": "kafka" }, { "matchDepNames": [ "/.*springframework.*/" ], "matchUpdateTypes": ["major"], "enabled": false }, { "matchDepNames": [ "/org.apache.kafka.*/" ], "allowedVersions": "!/^[78]\\./" }, { "matchDepNames": [ "org.scala-lang:scala-library" ], "matchUpdateTypes": ["major"], "enabled": false }, { "matchDepNames": [ "/org.jetbrains.kotlinx:kotlinx-coroutines.*/" ], "allowedVersions": "<=1.10.2" }, { "groupName": "com.trendyol", "allowedVersions": "!/1f1ca59/", "matchPackageNames": [ "/com.trendyol:stove.*/" ] } ] } ================================================ FILE: settings.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") rootProject.name = "stove" pluginManagement { repositories { gradlePluginPortal() mavenCentral() } } include( "lib:stove-bom", "lib:stove", "lib:stove-tracing", "lib:stove-wiremock", "lib:stove-grpc-mock", "lib:stove-http", "lib:stove-grpc", "lib:stove-kafka", "lib:stove-couchbase", "lib:stove-rdbms", "lib:stove-postgres", "lib:stove-mysql", "lib:stove-mssql", "lib:stove-elasticsearch", "lib:stove-redis", "lib:stove-mongodb", "lib:stove-cassandra", "lib:stove-dashboard-api", "lib:stove-dashboard" ) include( "test-extensions:stove-extensions-kotest", "test-extensions:stove-extensions-junit" ) include( "starters:container:stove-container", "starters:ktor:stove-ktor", "starters:ktor:tests:ktor-test-fixtures", "starters:ktor:tests:ktor-koin-tests", "starters:ktor:tests:ktor-di-tests", "starters:quarkus:stove-quarkus", "starters:spring:stove-spring", "starters:spring:stove-spring-kafka", "starters:spring:tests:spring-test-fixtures", "starters:spring:tests:spring-2x-tests", "starters:spring:tests:spring-2x-kafka-tests", "starters:spring:tests:spring-3x-tests", "starters:spring:tests:spring-3x-kafka-tests", "starters:spring:tests:spring-4x-tests", "starters:spring:tests:spring-4x-kafka-tests", "starters:micronaut:stove-micronaut", "starters:process:stove-process" ) include( "examples:spring-example", "examples:spring-standalone-example", "examples:spring-4x-example", "examples:ktor-example", "examples:quarkus-example", "examples:spring-streams-example", "examples:micronaut-example" ) include( "plugins:stove-tracing-gradle-plugin" ) dependencyResolutionManagement { repositories { mavenCentral() maven { url = uri("https://packages.confluent.io/maven/") } } } plugins { id("org.danilopianini.gradle-pre-commit-git-hooks").version("2.1.16") } gitHooks { preCommit { from(rootDir.resolve("pre-commit.sh")) } createHooks(overwriteExisting = true) } ================================================ FILE: starters/container/stove-container/api/stove-container.api ================================================ public final class com/trendyol/stove/container/ContainerDslKt { public static final fun containerApp-tBQrr_I (Lcom/trendyol/stove/system/Stove;Ljava/lang/String;Lcom/trendyol/stove/container/ContainerTarget;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/system/application/EnvProvider;Lcom/trendyol/stove/system/application/ArgsProvider;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;J)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun containerApp-tBQrr_I$default (Lcom/trendyol/stove/system/Stove;Ljava/lang/String;Lcom/trendyol/stove/container/ContainerTarget;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/system/application/EnvProvider;Lcom/trendyol/stove/system/application/ArgsProvider;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;JILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } public abstract interface class com/trendyol/stove/container/ContainerTarget { public abstract fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; } public final class com/trendyol/stove/container/ContainerTarget$Server : com/trendyol/stove/container/ContainerTarget { public fun (IILjava/lang/String;ZLcom/trendyol/stove/system/ReadinessStrategy;)V public synthetic fun (IILjava/lang/String;ZLcom/trendyol/stove/system/ReadinessStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()Ljava/lang/String; public final fun component4 ()Z public final fun component5 ()Lcom/trendyol/stove/system/ReadinessStrategy; public final fun copy (IILjava/lang/String;ZLcom/trendyol/stove/system/ReadinessStrategy;)Lcom/trendyol/stove/container/ContainerTarget$Server; public static synthetic fun copy$default (Lcom/trendyol/stove/container/ContainerTarget$Server;IILjava/lang/String;ZLcom/trendyol/stove/system/ReadinessStrategy;ILjava/lang/Object;)Lcom/trendyol/stove/container/ContainerTarget$Server; public fun equals (Ljava/lang/Object;)Z public final fun getBindHostPort ()Z public final fun getHostPort ()I public final fun getInternalPort ()I public final fun getPortEnvVar ()Ljava/lang/String; public fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/container/ContainerTarget$Worker : com/trendyol/stove/container/ContainerTarget { public fun ()V public fun (Lcom/trendyol/stove/system/ReadinessStrategy;)V public synthetic fun (Lcom/trendyol/stove/system/ReadinessStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/ReadinessStrategy; public final fun copy (Lcom/trendyol/stove/system/ReadinessStrategy;)Lcom/trendyol/stove/container/ContainerTarget$Worker; public static synthetic fun copy$default (Lcom/trendyol/stove/container/ContainerTarget$Worker;Lcom/trendyol/stove/system/ReadinessStrategy;ILjava/lang/Object;)Lcom/trendyol/stove/container/ContainerTarget$Worker; public fun equals (Ljava/lang/Object;)Z public fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; public fun hashCode ()I public fun toString ()Ljava/lang/String; } ================================================ FILE: starters/container/stove-container/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) } ================================================ FILE: starters/container/stove-container/src/main/kotlin/com/trendyol/stove/container/ContainerApplicationUnderTest.kt ================================================ package com.trendyol.stove.container import arrow.core.None import arrow.core.Option import arrow.core.Some import com.github.dockerjava.api.model.ExposedPort import com.github.dockerjava.api.model.HostConfig import com.github.dockerjava.api.model.PortBinding import com.github.dockerjava.api.model.Ports import com.trendyol.stove.containers.DEFAULT_REGISTRY import com.trendyol.stove.containers.withProvidedRegistry import com.trendyol.stove.system.ReadinessChecker import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.application.ArgsProvider import com.trendyol.stove.system.application.EnvProvider import com.trendyol.stove.system.application.toConfigurationMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.slf4j.LoggerFactory import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.output.Slf4jLogConsumer import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds internal typealias ContainerFactory = () -> GenericContainer<*> internal typealias LaunchConfigurationObserver = (List, Map) -> Unit internal class ContainerApplicationUnderTest( private val image: String, private val target: ContainerTarget, private val command: List = emptyList(), private val envProvider: EnvProvider = EnvProvider.empty(), private val argsProvider: ArgsProvider = ArgsProvider.empty(), private val beforeStarted: suspend (configurations: Map) -> Unit = {}, private val configureContainer: GenericContainer<*>.() -> Unit = {}, private val gracefulShutdownTimeout: Duration = 5.seconds, private val containerFactory: ContainerFactory, private val launchConfigurationObserver: LaunchConfigurationObserver ) : ApplicationUnderTest { constructor( image: String, target: ContainerTarget, registry: String = DEFAULT_REGISTRY, compatibleSubstitute: String? = null, command: List = emptyList(), envProvider: EnvProvider = EnvProvider.empty(), argsProvider: ArgsProvider = ArgsProvider.empty(), beforeStarted: suspend (configurations: Map) -> Unit = {}, configureContainer: GenericContainer<*>.() -> Unit = {}, gracefulShutdownTimeout: Duration = 5.seconds ) : this( image = image, target = target, command = command, envProvider = envProvider, argsProvider = argsProvider, beforeStarted = beforeStarted, configureContainer = configureContainer, gracefulShutdownTimeout = gracefulShutdownTimeout, containerFactory = { defaultContainerFactory( image = image, registry = registry, compatibleSubstitute = compatibleSubstitute ) }, launchConfigurationObserver = { _, _ -> } ) private val logger = LoggerFactory.getLogger(javaClass) private var runningContainer: Option> = None override suspend fun start(configurations: List): ContainerApplicationContext { val configurationMap = configurations.toConfigurationMap() val commandArgs = argsProvider.provide(configurationMap) val fullCommand = command + commandArgs val envVars = resolveEnv(configurationMap) launchConfigurationObserver(fullCommand, envVars) beforeStarted(configurationMap) val container = containerFactory() applyContainerConfiguration(container = container, fullCommand = fullCommand, envVars = envVars) logger.info("Starting container image {} with {} env vars", image, envVars.size) runCatching { withContext(Dispatchers.IO) { container.start() } }.fold( onSuccess = {}, onFailure = { throwable -> val containerLogs = runCatching { container.logs }.getOrElse { "" } throw IllegalStateException( "Failed to start container application `$image`. Logs:\n$containerLogs", throwable ) } ) withContext(Dispatchers.IO) { runCatching { container.followOutput(Slf4jLogConsumer(logger).withPrefix(image)) }.onFailure { logger.debug("Container log streaming could not be attached: {}", it.message) } } runningContainer = Some(container) try { ReadinessChecker.check(target.readiness) logger.info("Container application is ready") } catch (t: IllegalStateException) { stop() throw t } return ContainerApplicationContext(container) } override suspend fun stop() { when (val activeContainer = runningContainer) { is Some -> { val container = activeContainer.value val gracefullyStopped = withContext(Dispatchers.IO) { withTimeoutOrNull(gracefulShutdownTimeout) { container.stop() } != null } if (!gracefullyStopped) { logger.warn("Container did not stop in time, force-closing") withContext(Dispatchers.IO) { container.close() } } } None -> Unit } runningContainer = None } private fun resolveEnv(configurationMap: Map): Map { val mappedEnv = envProvider.provide(configurationMap) return when (val target = target) { is ContainerTarget.Server -> mappedEnv + (target.portEnvVar to target.internalPort.toString()) is ContainerTarget.Worker -> mappedEnv } } private fun applyContainerConfiguration( container: GenericContainer<*>, fullCommand: List, envVars: Map ) { container .withEnv(envVars) configureTarget(container) if (fullCommand.isNotEmpty()) { container.withCommand(*fullCommand.toTypedArray()) } configureContainer(container) } private fun configureTarget(container: GenericContainer<*>) { when (val target = target) { is ContainerTarget.Server -> { if (target.bindHostPort) { container.withExposedPorts(target.internalPort) container.withCreateContainerCmdModifier { command -> val hostConfig = command.hostConfig ?: HostConfig.newHostConfig() command.withHostConfig( hostConfig.withPortBindings( PortBinding( Ports.Binding.bindPort(target.hostPort), ExposedPort(target.internalPort) ) ) ) } } } is ContainerTarget.Worker -> Unit } } companion object { private fun defaultContainerFactory( image: String, registry: String, compatibleSubstitute: String? ): GenericContainer<*> = withProvidedRegistry( imageName = image, registry = registry, compatibleSubstitute = compatibleSubstitute ) { imageName -> GenericContainer(imageName) } } } ================================================ FILE: starters/container/stove-container/src/main/kotlin/com/trendyol/stove/container/ContainerDsl.kt ================================================ package com.trendyol.stove.container import com.trendyol.stove.containers.DEFAULT_REGISTRY import com.trendyol.stove.system.WithDsl import com.trendyol.stove.system.abstractions.ReadyStove import com.trendyol.stove.system.application.ArgsProvider import com.trendyol.stove.system.application.EnvProvider import org.testcontainers.containers.GenericContainer import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds fun WithDsl.containerApp( image: String, target: ContainerTarget, registry: String = DEFAULT_REGISTRY, compatibleSubstitute: String? = null, command: List = emptyList(), envProvider: EnvProvider = EnvProvider.empty(), argsProvider: ArgsProvider = ArgsProvider.empty(), beforeStarted: suspend (configurations: Map) -> Unit = {}, configureContainer: GenericContainer<*>.() -> Unit = {}, gracefulShutdownTimeout: Duration = 5.seconds ): ReadyStove { stove.applicationUnderTest( ContainerApplicationUnderTest( image = image, target = target, registry = registry, compatibleSubstitute = compatibleSubstitute, command = command, envProvider = envProvider, argsProvider = argsProvider, beforeStarted = beforeStarted, configureContainer = configureContainer, gracefulShutdownTimeout = gracefulShutdownTimeout ) ) return stove } ================================================ FILE: starters/container/stove-container/src/main/kotlin/com/trendyol/stove/container/ContainerTarget.kt ================================================ package com.trendyol.stove.container import com.trendyol.stove.system.ReadinessStrategy import org.testcontainers.containers.GenericContainer sealed interface ContainerTarget { val readiness: ReadinessStrategy data class Server( val hostPort: Int, val internalPort: Int = hostPort, val portEnvVar: String = "PORT", val bindHostPort: Boolean = true, override val readiness: ReadinessStrategy = ReadinessStrategy.HttpGet(url = "http://localhost:$hostPort/health") ) : ContainerTarget data class Worker( override val readiness: ReadinessStrategy = ReadinessStrategy.FixedDelay() ) : ContainerTarget } internal data class ContainerApplicationContext( val container: GenericContainer<*> ) ================================================ FILE: starters/container/stove-container/src/test/kotlin/com/trendyol/stove/container/ContainerApplicationUnderTestTest.kt ================================================ package com.trendyol.stove.container import com.trendyol.stove.system.ReadinessStrategy import com.trendyol.stove.system.application.argsMapper import com.trendyol.stove.system.application.envMapper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.runBlocking import org.testcontainers.containers.GenericContainer import org.testcontainers.utility.DockerImageName import kotlin.time.Duration.Companion.milliseconds class ContainerApplicationUnderTestTest : FunSpec({ test("builds command and env map including server port") { val fakeContainer = FakeContainer() var capturedCommand: List = emptyList() var capturedEnv: Map = emptyMap() val aut = ContainerApplicationUnderTest( image = "busybox:latest", command = listOf("worker"), target = ContainerTarget.Server( hostPort = 18090, internalPort = 8090, portEnvVar = "APP_PORT", readiness = ReadinessStrategy.FixedDelay(1.milliseconds) ), envProvider = envMapper { "database.host" to "DB_HOST" }, argsProvider = argsMapper(prefix = "--", separator = "=") { "database.port" to "db-port" }, containerFactory = { fakeContainer }, launchConfigurationObserver = { fullCommand, envVars -> capturedCommand = fullCommand capturedEnv = envVars } ) runBlocking { aut.start(listOf("database.host=localhost", "database.port=5432")) aut.stop() } capturedCommand shouldBe listOf("worker", "--db-port=5432") capturedEnv shouldBe mapOf( "DB_HOST" to "localhost", "APP_PORT" to "8090" ) } test("invokes container customizer before start") { val fakeContainer = FakeContainer() var customizerCalled = false val aut = ContainerApplicationUnderTest( image = "busybox:latest", target = ContainerTarget.Worker( readiness = ReadinessStrategy.FixedDelay(1.milliseconds) ), configureContainer = { customizerCalled = true }, containerFactory = { fakeContainer }, launchConfigurationObserver = { _, _ -> } ) runBlocking { aut.start(emptyList()) aut.stop() } customizerCalled shouldBe true fakeContainer.started shouldBe true fakeContainer.stopped shouldBe true } test("stop is a no-op when container was never started") { val aut = ContainerApplicationUnderTest( image = "busybox:latest", target = ContainerTarget.Worker(), containerFactory = { FakeContainer() }, launchConfigurationObserver = { _, _ -> } ) runBlocking { aut.stop() } } }) private class FakeContainer : GenericContainer(DockerImageName.parse("busybox:latest")) { var started: Boolean = false var stopped: Boolean = false override fun start() { started = true } override fun stop() { stopped = true } } ================================================ FILE: starters/container/stove-container/src/test/kotlin/com/trendyol/stove/container/ContainerDslTest.kt ================================================ package com.trendyol.stove.container import com.trendyol.stove.system.Stove import com.trendyol.stove.system.WithDsl import com.trendyol.stove.system.abstractions.ReadyStove import com.trendyol.stove.system.application.ArgsProvider import com.trendyol.stove.system.application.EnvProvider import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import kotlin.time.Duration.Companion.seconds class ContainerDslTest : FunSpec({ test("containerApp accepts option elements as parameters") { val stove = Stove() val readyStove: ReadyStove = WithDsl(stove).containerApp( image = "busybox:latest", target = ContainerTarget.Worker(), command = listOf("echo", "ready"), envProvider = EnvProvider.empty(), argsProvider = ArgsProvider.empty(), gracefulShutdownTimeout = 1.seconds ) readyStove shouldBe stove } }) ================================================ FILE: starters/container/stove-container/src/test/kotlin/com/trendyol/stove/container/ContainerTargetTest.kt ================================================ package com.trendyol.stove.container import com.trendyol.stove.system.ReadinessStrategy import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class ContainerTargetTest : FunSpec({ test("server target defaults use host port and health readiness") { val target = ContainerTarget.Server(hostPort = 8080) target.internalPort shouldBe 8080 target.portEnvVar shouldBe "PORT" target.bindHostPort shouldBe true (target.readiness is ReadinessStrategy.HttpGet) shouldBe true (target.readiness as ReadinessStrategy.HttpGet).url shouldBe "http://localhost:8080/health" } test("worker target defaults to fixed delay readiness") { val target = ContainerTarget.Worker() (target.readiness is ReadinessStrategy.FixedDelay) shouldBe true } }) ================================================ FILE: starters/ktor/stove-ktor/api/stove-ktor.api ================================================ public final class com/trendyol/stove/ktor/DependencyResolvers { public static final field INSTANCE Lcom/trendyol/stove/ktor/DependencyResolvers; public final fun autoDetect ()Lkotlin/jvm/functions/Function2; public final fun getKoin ()Lkotlin/jvm/functions/Function2; public final fun getKtorDi ()Lkotlin/jvm/functions/Function2; } public final class com/trendyol/stove/ktor/KtorApplicationUnderTest : com/trendyol/stove/system/abstractions/ApplicationUnderTest { public fun (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/ktor/KtorApplicationUnderTestKt { public static final fun ktor-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun ktor-SscbJ7Y$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } public final class com/trendyol/stove/ktor/KtorBridgeSystem : com/trendyol/stove/system/BridgeSystem, com/trendyol/stove/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/system/abstractions/PluggedSystem { public fun (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;)V public fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; public fun getByType (Lkotlin/reflect/KType;)Ljava/lang/Object; public fun getStove ()Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/ktor/KtorBridgeSystemKt { public static final fun bridge-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/system/Stove; public static synthetic fun bridge-ypJx7X8$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/ktor/KtorDiCheck { public static final field INSTANCE Lcom/trendyol/stove/ktor/KtorDiCheck; public final fun isKoinAvailable ()Z public final fun isKtorDiAvailable ()Z } ================================================ FILE: starters/ktor/stove-ktor/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) implementation(libs.ktor.server.host.common) // Both DI systems as compileOnly - users bring their preferred DI at runtime compileOnly(libs.koin.ktor) compileOnly(libs.ktor.server.di) } ================================================ FILE: starters/ktor/stove-ktor/src/main/kotlin/com/trendyol/stove/ktor/DependencyResolvers.kt ================================================ package com.trendyol.stove.ktor import io.ktor.server.application.* import io.ktor.server.plugins.di.* import io.ktor.util.reflect.* import org.koin.ktor.ext.getKoin import kotlin.reflect.* /** * Type alias for a dependency resolver function. * Takes an Application and a KType, returns the resolved dependency. * KType preserves generic type information (e.g., List). */ typealias DependencyResolver = (Application, KType) -> Any /** * Default resolver implementations for supported DI frameworks. */ object DependencyResolvers { /** * Resolver for Koin DI framework. */ val koin: DependencyResolver = { application, type -> val klass = type.classifier as? KClass<*> ?: error("Cannot resolve type: $type") application.getKoin().get(klass) } /** * Resolver for Ktor-DI framework. * Uses full KType to preserve generic type information. */ val ktorDi: DependencyResolver = { application, type -> require(application.attributes.contains(DependencyRegistryKey)) { "Ktor-DI not installed in application. Make sure to install(DI) { ... } in your application." } val klass = type.classifier as? KClass<*> ?: error("Cannot resolve type: $type") val typeInfo = TypeInfo(klass, type) application.dependencies.getBlocking(DependencyKey(type = typeInfo)) } /** * Auto-detects and returns the appropriate resolver based on available DI frameworks. * Prefers Ktor-DI over Koin if both are active in runtime. * Detection is deferred to runtime to ensure Ktor application plugins are fully initialized. */ fun autoDetect(): DependencyResolver = { application, type -> val resolver = when { isKtorDiActive(application) -> ktorDi isKoinActive(application) -> koin else -> error(buildNoActiveDiFrameworkMessage()) } resolver(application, type) } /** * Uses reflection-based availability check first, then typed runtime check. * This avoids hard-loading optional Ktor-DI classes in Koin-only apps. */ private fun isKtorDiActive(application: Application): Boolean { if (!KtorDiCheck.isKtorDiAvailable()) return false return runCatching { application.attributes.contains(DependencyRegistryKey) }.getOrDefault(false) } /** * Uses reflection-based availability check first, then typed runtime check. * This avoids hard-loading optional Koin classes when Koin is not present. */ private fun isKoinActive(application: Application): Boolean { if (!KtorDiCheck.isKoinAvailable()) return false return runCatching { application.getKoin() true }.getOrDefault(false) } private fun buildNoActiveDiFrameworkMessage(): String { val koinOnClasspath = KtorDiCheck.isKoinAvailable() val ktorDiOnClasspath = KtorDiCheck.isKtorDiAvailable() if (!koinOnClasspath && !ktorDiOnClasspath) { return "No supported DI framework found. " + "Add either Koin (io.insert-koin:koin-ktor) or Ktor-DI (io.ktor:ktor-server-di) to your classpath, " + "or provide a custom resolver via bridge(resolver = { app, type -> ... })" } return "No active DI framework detected in the Ktor application runtime. " + "Classpath availability: Koin=$koinOnClasspath, Ktor-DI=$ktorDiOnClasspath. " + "Install Koin via install(Koin) { ... } and/or install Ktor-DI via dependencies { ... }, " + "or provide a custom resolver via bridge(resolver = { app, type -> ... })" } } ================================================ FILE: starters/ktor/stove-ktor/src/main/kotlin/com/trendyol/stove/ktor/KtorApplicationUnderTest.kt ================================================ @file:Suppress("UNCHECKED_CAST") package com.trendyol.stove.ktor import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.ktor.server.application.* import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.* /** * Definition for Application Under Test for Ktor enabled application */ internal fun Stove.systemUnderTest( runner: Runner, withParameters: List = listOf() ): ReadyStove = applicationUnderTest(KtorApplicationUnderTest(this, runner, withParameters)) fun WithDsl.ktor( runner: Runner, withParameters: List = listOf() ): ReadyStove = this.stove.systemUnderTest(runner, withParameters) @StoveDsl class KtorApplicationUnderTest( private val stove: Stove, private val runner: Runner, private val parameters: List ) : ApplicationUnderTest { private lateinit var application: Application override suspend fun start(configurations: List): Application = coroutineScope { val allConfigurations = (configurations + defaultConfigurations() + parameters) .map { "--$it" } .distinct() .toTypedArray() application = runner(allConfigurations) stove.systemsOf>() .map { async { it.afterRun(application) } } .awaitAll() application } @OptIn(InternalAPI::class) override suspend fun stop(): Unit = application.disposeAndJoin() private fun defaultConfigurations(): Array = arrayOf("test-system=true") } ================================================ FILE: starters/ktor/stove-ktor/src/main/kotlin/com/trendyol/stove/ktor/KtorBridgeSystem.kt ================================================ @file:Suppress("UNCHECKED_CAST") package com.trendyol.stove.ktor import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.ktor.server.application.* import kotlin.reflect.* import kotlin.reflect.full.starProjectedType /** * A system that provides a bridge between the test system and the application context. * Supports Koin, Ktor-DI, or a custom resolver for dependency resolution. * * @property stove the test system to bridge. * @property resolver the dependency resolver function to use. */ @StoveDsl class KtorBridgeSystem( override val stove: Stove, private val resolver: DependencyResolver ) : BridgeSystem(stove), PluggedSystem, AfterRunAwareWithContext { /** * Resolves a dependency by KClass (fallback, loses generic info). */ override fun get(klass: KClass): D = resolver(ctx, klass.starProjectedType) as D /** * Resolves a dependency by KType, preserving generic type information. * This allows resolving types like List correctly. */ override fun getByType(type: KType): D = resolver(ctx, type) as D } /** * Registers the Ktor bridge system with automatic DI detection or a custom resolver. * Supports Koin and Ktor-DI out of the box. * * Example usage with auto-detect: * ```kotlin * bridge() // Auto-detects Koin or Ktor-DI * ``` * * Example usage with custom resolver: * ```kotlin * bridge { application, klass -> * application.myCustomDi.resolve(klass) * } * ``` * * @param resolver a function that takes an Application and KClass and returns the resolved dependency. * Defaults to auto-detecting Koin or Ktor-DI. * @throws IllegalStateException if no DI framework is available and no custom resolver is provided. */ fun WithDsl.bridge( resolver: DependencyResolver = DependencyResolvers.autoDetect() ): Stove = this.stove.withBridgeSystem(KtorBridgeSystem(this.stove, resolver)) ================================================ FILE: starters/ktor/stove-ktor/src/main/kotlin/com/trendyol/stove/ktor/KtorDiCheck.kt ================================================ @file:Suppress("TooGenericExceptionCaught", "SwallowedException") package com.trendyol.stove.ktor /** * Checks which DI system is available on the classpath. */ object KtorDiCheck { /** * Returns true if Koin is available on the classpath. */ fun isKoinAvailable(): Boolean = try { Class.forName("org.koin.ktor.ext.ApplicationExtKt") true } catch (_: ClassNotFoundException) { false } /** * Returns true if Ktor-DI is available on the classpath. */ fun isKtorDiAvailable(): Boolean = try { Class.forName("io.ktor.server.plugins.di.DependencyInjectionConfig") true } catch (_: ClassNotFoundException) { false } } ================================================ FILE: starters/ktor/stove-ktor/src/test/kotlin/com/trendyol/stove/ktor/DependencyResolversLinkageTest.kt ================================================ package com.trendyol.stove.ktor import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.core.spec.style.FunSpec class DependencyResolversLinkageTest : FunSpec({ test("autoDetect should load without optional DI libraries on classpath") { shouldNotThrowAny { DependencyResolvers.autoDetect() } } }) ================================================ FILE: starters/ktor/stove-ktor/src/test/kotlin/com/trendyol/stove/ktor/KtorDiCheckTest.kt ================================================ package com.trendyol.stove.ktor import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class KtorDiCheckTest : FunSpec({ test("isKoinAvailable should return false when Koin is not on classpath") { // Koin is compileOnly, so it should not be on the test classpath KtorDiCheck.isKoinAvailable() shouldBe false } test("isKtorDiAvailable should return false when Ktor-DI is not on classpath") { // Ktor-DI is compileOnly, so it should not be on the test classpath KtorDiCheck.isKtorDiAvailable() shouldBe false } test("neither DI framework should be available in test classpath") { // Since both Koin and Ktor-DI are compileOnly dependencies, // neither should be detected in the test runtime classpath val koinAvailable = KtorDiCheck.isKoinAvailable() val ktorDiAvailable = KtorDiCheck.isKtorDiAvailable() koinAvailable shouldBe false ktorDiAvailable shouldBe false } }) ================================================ FILE: starters/ktor/tests/ktor-di-tests/api/ktor-di-tests.api ================================================ ================================================ FILE: starters/ktor/tests/ktor-di-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.ktor.stoveKtor) implementation(libs.ktor.server.netty) implementation(libs.ktor.server.di) implementation(libs.koin.ktor) testImplementation(testFixtures(projects.starters.ktor.tests.ktorTestFixtures)) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.slf4j.simple) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.ktor.KtorDiStove") } ================================================ FILE: starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/ktor/AutoDetectRuntimeStateTest.kt ================================================ package com.trendyol.stove.ktor import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.ktor.server.plugins.di.* import org.koin.core.context.stopKoin import kotlin.reflect.typeOf class AutoDetectRuntimeStateTest : FunSpec({ test("autoDetect prefers Ktor-DI when both Koin and Ktor-DI are active") { val application = BothActiveDiTestApp.run(emptyArray()) application.attributes.contains(DependencyRegistryKey) shouldBe true val resolver = DependencyResolvers.autoDetect() val resolvedService = resolver(application, typeOf()) as ExampleService resolvedService.whatIsTheTime() shouldBe GetUtcNow.frozenTime val resolvedConfig = resolver(application, typeOf()) as TestConfig resolvedConfig.message shouldBe "Hello from Stove!" val resolvedPaymentServices = resolver(application, typeOf>()) (resolvedPaymentServices as List<*>) .filterIsInstance() .map { it.providerName } shouldContainExactlyInAnyOrder listOf("Stripe", "PayPal", "Square") } test("autoDetect throws clear error when no runtime DI is active") { runCatching { stopKoin() } val application = NoDiTestApp.run(emptyArray()) application.attributes.contains(DependencyRegistryKey) shouldBe false val resolver = DependencyResolvers.autoDetect() val error = shouldThrow { resolver(application, typeOf()) } error.message shouldContain "No active DI framework detected" error.message shouldContain "install(Koin)" error.message shouldContain "dependencies { ... }" } }) ================================================ FILE: starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/ktor/StoveConfig.kt ================================================ package com.trendyol.stove.ktor import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension class KtorDiStove : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { bridge() // Auto-detects Ktor-DI ktor( runner = { params -> KtorDiTestApp.run(params) } ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } class KtorDiBridgeSystemTests : BridgeSystemTests(KtorDiStove()) ================================================ FILE: starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/ktor/app.kt ================================================ package com.trendyol.stove.ktor import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.di.* import org.junit.platform.commons.logging.LoggerFactory import org.koin.dsl.module import org.koin.ktor.plugin.Koin import java.net.ServerSocket import java.time.Instant /** * Test Ktor application using Ktor-DI for dependency injection. */ object KtorDiTestApp { private val logger = LoggerFactory.getLogger(KtorDiTestApp::class.java) fun run(args: Array): Application { logger.info { "Starting Ktor-DI test application with args: ${args.joinToString(" ")}" } val port = findAvailablePort() val applicationEngine = embeddedServer(Netty, port = port, host = "localhost") { dependencies { provide { SystemTimeGetUtcNow() } provide { ExampleService(resolve()) } provide { TestConfig() } // Multiple payment service implementations as List provide> { listOf( StripePaymentService(), PayPalPaymentService(), SquarePaymentService() ) } } } applicationEngine.start(wait = false) return applicationEngine.application } private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } } /** * Test app with both Ktor-DI and Koin active. * Koin intentionally provides conflicting values to verify Ktor-DI precedence. */ object BothActiveDiTestApp { private val logger = LoggerFactory.getLogger(BothActiveDiTestApp::class.java) fun run(args: Array): Application { logger.info { "Starting both-active DI test application with args: ${args.joinToString(" ")}" } val port = findAvailablePort() val applicationEngine = embeddedServer(Netty, port = port, host = "localhost") { install(Koin) { modules( module { single { GetUtcNow { Instant.parse("2030-01-01T00:00:00Z") } } single { ExampleService(get()) } single { TestConfig(message = "from-koin") } single> { listOf(StripePaymentService()) } } ) } dependencies { provide { SystemTimeGetUtcNow() } provide { ExampleService(resolve()) } provide { TestConfig() } // Multiple payment service implementations as List provide> { listOf( StripePaymentService(), PayPalPaymentService(), SquarePaymentService() ) } } } applicationEngine.start(wait = false) return applicationEngine.application } private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } } /** * Test app with no DI framework installed in runtime. */ object NoDiTestApp { private val logger = LoggerFactory.getLogger(NoDiTestApp::class.java) fun run(args: Array): Application { logger.info { "Starting no-DI test application with args: ${args.joinToString(" ")}" } val port = findAvailablePort() val applicationEngine = embeddedServer(Netty, port = port, host = "localhost") {} applicationEngine.start(wait = false) return applicationEngine.application } private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } } ================================================ FILE: starters/ktor/tests/ktor-di-tests/src/test/resources/simplelogger.properties ================================================ org.slf4j.simpleLogger.defaultLogLevel=info org.slf4j.simpleLogger.showDateTime=true org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss.SSS org.slf4j.simpleLogger.showShortLogName=true ================================================ FILE: starters/ktor/tests/ktor-koin-tests/api/ktor-koin-tests.api ================================================ ================================================ FILE: starters/ktor/tests/ktor-koin-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.ktor.stoveKtor) implementation(libs.ktor.server.netty) implementation(libs.koin.ktor) testImplementation(testFixtures(projects.starters.ktor.tests.ktorTestFixtures)) } dependencies { testImplementation(project(":test-extensions:stove-extensions-kotest")) testImplementation(libs.slf4j.simple) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.ktor.KoinStove") } ================================================ FILE: starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/ktor/AutoDetectRuntimeSelectionTest.kt ================================================ package com.trendyol.stove.ktor import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.ktor.server.application.Application import io.ktor.util.AttributeKey import kotlin.reflect.typeOf class AutoDetectRuntimeSelectionTest : FunSpec({ test("autoDetect uses active Koin for a Koin-only app") { val application = KoinTestApp.run(emptyArray()) KtorDiCheck.isKoinAvailable() shouldBe true KtorDiCheck.isKtorDiAvailable() shouldBe true isKtorDiRegistryInstalled(application) shouldBe false val resolver = DependencyResolvers.autoDetect() val resolvedService = resolver(application, typeOf()) as ExampleService resolvedService.whatIsTheTime() shouldBe GetUtcNow.frozenTime } }) private fun isKtorDiRegistryInstalled(application: Application): Boolean = runCatching { val dependencyInjectionKt = Class.forName("io.ktor.server.plugins.di.DependencyInjectionKt") val key = dependencyInjectionKt.getMethod("getDependencyRegistryKey").invoke(null) as AttributeKey<*> application.attributes.contains(key) }.getOrDefault(false) ================================================ FILE: starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/ktor/StoveConfig.kt ================================================ package com.trendyol.stove.ktor import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension class KoinStove : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { bridge() // Auto-detects Koin ktor( runner = { params -> KoinTestApp.run(params) } ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } class KoinBridgeSystemTests : BridgeSystemTests(KoinStove()) ================================================ FILE: starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/ktor/app.kt ================================================ package com.trendyol.stove.ktor import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import org.junit.platform.commons.logging.LoggerFactory import org.koin.dsl.module import org.koin.ktor.plugin.Koin import java.net.ServerSocket /** * Test Ktor application using Koin for dependency injection. */ object KoinTestApp { private val logger = LoggerFactory.getLogger(KoinTestApp::class.java) fun run(args: Array): Application { logger.info { "Starting Koin test application with args: ${args.joinToString(" ")}" } val port = findAvailablePort() val applicationEngine = embeddedServer(Netty, port = port, host = "localhost") { install(Koin) { modules( module { single { SystemTimeGetUtcNow() } single { ExampleService(get()) } single { TestConfig() } // Multiple payment service implementations as List single> { listOf( StripePaymentService(), PayPalPaymentService(), SquarePaymentService() ) } } ) } } applicationEngine.start(wait = false) return applicationEngine.application } private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } } ================================================ FILE: starters/ktor/tests/ktor-koin-tests/src/test/resources/simplelogger.properties ================================================ org.slf4j.simpleLogger.defaultLogLevel=info org.slf4j.simpleLogger.showDateTime=true org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss.SSS org.slf4j.simpleLogger.showShortLogName=true ================================================ FILE: starters/ktor/tests/ktor-test-fixtures/api/ktor-test-fixtures.api ================================================ ================================================ FILE: starters/ktor/tests/ktor-test-fixtures/build.gradle.kts ================================================ plugins { `java-test-fixtures` } dependencies { testFixturesApi(projects.starters.ktor.stoveKtor) testFixturesApi(libs.kotest.runner.junit5) testFixturesApi(libs.ktor.server.host.common) // DI systems as compileOnly - version provided by consuming module testFixturesCompileOnly(libs.koin.ktor) testFixturesCompileOnly(libs.ktor.server.di) } ================================================ FILE: starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/ktor/BridgeSystemTests.kt ================================================ package com.trendyol.stove.ktor import com.trendyol.stove.system.stove import com.trendyol.stove.system.using import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe import java.math.BigDecimal /** * Shared bridge system tests that work with both Koin and Ktor-DI. * Each DI variant module only needs to provide the Stove setup configuration. */ abstract class BridgeSystemTests( private val stoveSetup: AbstractProjectConfig ) : ShouldSpec({ beforeSpec { stoveSetup.beforeProject() } afterSpec { stoveSetup.afterProject() } should("resolve service from DI container") { stove { using { whatIsTheTime() shouldBe GetUtcNow.frozenTime } } } should("resolve multiple dependencies") { stove { using { getUtcNow, exampleService -> getUtcNow() shouldBe GetUtcNow.frozenTime exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime } } } should("resolve config from DI container") { stove { using { message shouldBe "Hello from Stove!" } } } should("resolve multiple instances of same interface") { stove { using> { val order = Order("order-123", BigDecimal("99.99")) val results = map { it.pay(order) } results.map { it.provider } shouldContainExactlyInAnyOrder listOf("Stripe", "PayPal", "Square") results.all { it.success } shouldBe true } } } }) ================================================ FILE: starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/ktor/TestDomain.kt ================================================ package com.trendyol.stove.ktor import java.math.BigDecimal import java.time.Instant /** * Common test domain classes for Ktor bridge tests. */ fun interface GetUtcNow { companion object { val frozenTime: Instant = Instant.parse("2021-01-01T00:00:00Z") } operator fun invoke(): Instant } class SystemTimeGetUtcNow : GetUtcNow { override fun invoke(): Instant = GetUtcNow.frozenTime } class ExampleService( private val getUtcNow: GetUtcNow ) { fun whatIsTheTime(): Instant = getUtcNow() } data class TestConfig( val message: String = "Hello from Stove!" ) /** * Domain classes for testing multi-instance resolution. */ data class Order( val id: String, val amount: BigDecimal ) data class PaymentResult( val provider: String, val success: Boolean ) interface PaymentService { val providerName: String fun pay(order: Order): PaymentResult } class StripePaymentService : PaymentService { override val providerName = "Stripe" override fun pay(order: Order) = PaymentResult(providerName, true) } class PayPalPaymentService : PaymentService { override val providerName = "PayPal" override fun pay(order: Order) = PaymentResult(providerName, true) } class SquarePaymentService : PaymentService { override val providerName = "Square" override fun pay(order: Order) = PaymentResult(providerName, true) } ================================================ FILE: starters/micronaut/stove-micronaut/api/stove-micronaut.api ================================================ public final class com/trendyol/stove/micronaut/MicronautApplicationUnderTest : com/trendyol/stove/system/abstractions/ApplicationUnderTest { public static final field Companion Lcom/trendyol/stove/micronaut/MicronautApplicationUnderTest$Companion; public fun (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/micronaut/MicronautApplicationUnderTest$Companion { } public final class com/trendyol/stove/micronaut/MicronautApplicationUnderTestKt { public static final fun micronaut-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun micronaut-SscbJ7Y$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } public final class com/trendyol/stove/micronaut/MicronautBridgeSystem : com/trendyol/stove/system/BridgeSystem, com/trendyol/stove/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/system/abstractions/PluggedSystem { public fun (Lcom/trendyol/stove/system/Stove;)V public fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; public fun getStove ()Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/micronaut/MicronautBridgeSystemKt { public static final fun bridge-IDauA90 (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/Stove; } ================================================ FILE: starters/micronaut/stove-micronaut/build.gradle.kts ================================================ plugins { alias(libs.plugins.micronaut.library) alias(libs.plugins.google.ksp) } dependencies { api(projects.lib.stove) api(libs.micronaut.core) } dependencies { testImplementation(projects.testExtensions.stoveExtensionsKotest) testImplementation(libs.micronaut.test.kotest) kspTest(platform(libs.micronaut.platform)) kspTest(libs.micronaut.inject.kotlin) } micronaut { version(libs.versions.micronaut.platform.get()) processing { incremental(true) annotations("com.trendyol.stove.*") } } ================================================ FILE: starters/micronaut/stove-micronaut/src/main/kotlin/com/trendyol/stove/micronaut/MicronautApplicationUnderTest.kt ================================================ package com.trendyol.stove.micronaut import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.micronaut.context.* import kotlinx.coroutines.* internal fun Stove.systemUnderTest( runner: Runner, withParameters: List = listOf() ): ReadyStove { this.applicationUnderTest(MicronautApplicationUnderTest(this, runner, withParameters)) return this } fun WithDsl.micronaut( runner: Runner, withParameters: List = listOf() ): ReadyStove = this.stove.systemUnderTest(runner, withParameters) @StoveDsl class MicronautApplicationUnderTest( private val stove: Stove, private val runner: Runner, private val parameters: List ) : ApplicationUnderTest { private lateinit var application: ApplicationContext companion object { private const val DELAY = 500L } override suspend fun start(configurations: List): ApplicationContext = coroutineScope { val allConfigurations = (configurations + defaultConfigurations() + parameters).map { "--$it" }.toTypedArray() application = runner(allConfigurations) while (!application.isRunning) { delay(DELAY) continue } stove.systemsOf>() .map { async(context = Dispatchers.IO) { it.afterRun(application) } } .awaitAll() application } override suspend fun stop() { application.stop() } private fun defaultConfigurations(): Array = arrayOf("test-system=true") } ================================================ FILE: starters/micronaut/stove-micronaut/src/main/kotlin/com/trendyol/stove/micronaut/MicronautBridgeSystem.kt ================================================ package com.trendyol.stove.micronaut import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import io.micronaut.context.ApplicationContext import kotlin.reflect.KClass @StoveDsl class MicronautBridgeSystem( override val stove: Stove ) : BridgeSystem(stove), PluggedSystem, AfterRunAwareWithContext { override fun get(klass: KClass): D = ctx.getBean(klass.java) } fun WithDsl.bridge(): Stove = this.stove.withBridgeSystem(MicronautBridgeSystem(this.stove)) ================================================ FILE: starters/micronaut/stove-micronaut/src/main/resources/application.properties ================================================ #Mon Nov 18 19:19:36 UTC 2024 micronaut.application.name=stove-micronaut-testing-e2e ================================================ FILE: starters/micronaut/stove-micronaut/src/main/resources/logback.xml ================================================ %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n ================================================ FILE: starters/micronaut/stove-micronaut/src/test/kotlin/com/trendyol/stove/BridgeSystemTestConfig.kt ================================================ package com.trendyol.stove import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.micronaut.* import com.trendyol.stove.system.* import com.trendyol.stove.system.stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Factory import jakarta.inject.Singleton import java.time.Instant @Factory class TestAppConfig { @Singleton fun objectMapper(): ObjectMapper = ObjectMapper() @Singleton fun getUtcNow(): GetUtcNow = SystemTimeGetUtcNow() @Singleton fun exampleService(getUtcNow: GetUtcNow): ExampleService = ExampleService(getUtcNow) } fun interface GetUtcNow { companion object { val frozenTime: Instant = Instant.parse("2021-01-01T00:00:00Z") } operator fun invoke(): Instant } class SystemTimeGetUtcNow : GetUtcNow { override fun invoke(): Instant = GetUtcNow.frozenTime } class ExampleService( private val getUtcNow: GetUtcNow ) { fun whatIsTheTime(): Instant = getUtcNow() } object TestAppRunner { fun run( args: Array, init: ApplicationContext.() -> Unit = {} ): ApplicationContext { val context = ApplicationContext .builder() .args(*args) .packages(TestAppConfig::class.java.packageName) .build() .also(init) .start() return context } } class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject() = com.trendyol.stove.system .Stove() .with { bridge() micronaut( runner = { params -> TestAppRunner.run(params) } ) }.run() override suspend fun afterProject() = com.trendyol.stove.system.Stove .stop() } class BridgeSystemTests : FunSpec({ test("bridge to application") { stove { using { whatIsTheTime() shouldBe GetUtcNow.frozenTime } using { invoke() shouldBe GetUtcNow.frozenTime } } } test("resolve multiple") { stove { using { getUtcNow, exampleService -> getUtcNow() shouldBe GetUtcNow.frozenTime exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime } } } }) ================================================ FILE: starters/micronaut/stove-micronaut/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.StoveConfig ================================================ FILE: starters/process/stove-process/api/stove-process.api ================================================ public final class com/trendyol/stove/process/ArgsMapperBuilder { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun arg (Ljava/lang/String;Ljava/lang/String;)V public final fun arg (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public static synthetic fun arg$default (Lcom/trendyol/stove/process/ArgsMapperBuilder;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public final fun to (Ljava/lang/String;Ljava/lang/String;)V } public abstract interface class com/trendyol/stove/process/ArgsProvider { public static final field Companion Lcom/trendyol/stove/process/ArgsProvider$Companion; public abstract fun provide (Ljava/util/Map;)Ljava/util/List; } public final class com/trendyol/stove/process/ArgsProvider$Companion { public final fun empty ()Lcom/trendyol/stove/process/ArgsProvider; } public final class com/trendyol/stove/process/ArgsProviderKt { public static final fun argsMapper (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/process/ArgsProvider; public static synthetic fun argsMapper$default (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/process/ArgsProvider; } public final class com/trendyol/stove/process/EnvMapperBuilder { public fun ()V public final fun env (Ljava/lang/String;Ljava/lang/String;)V public final fun env (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public final fun to (Ljava/lang/String;Ljava/lang/String;)V } public abstract interface class com/trendyol/stove/process/EnvProvider { public static final field Companion Lcom/trendyol/stove/process/EnvProvider$Companion; public abstract fun provide (Ljava/util/Map;)Ljava/util/Map; } public final class com/trendyol/stove/process/EnvProvider$Companion { public final fun empty ()Lcom/trendyol/stove/process/EnvProvider; } public final class com/trendyol/stove/process/EnvProviderKt { public static final fun envMapper (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/process/EnvProvider; } public final class com/trendyol/stove/process/ProcessApplicationOptions { public synthetic fun (Ljava/util/List;Lcom/trendyol/stove/process/ProcessTarget;Lcom/trendyol/stove/process/EnvProvider;Lcom/trendyol/stove/process/ArgsProvider;Lkotlin/jvm/functions/Function3;Ljava/io/File;ZJILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/util/List;Lcom/trendyol/stove/process/ProcessTarget;Lcom/trendyol/stove/process/EnvProvider;Lcom/trendyol/stove/process/ArgsProvider;Lkotlin/jvm/functions/Function3;Ljava/io/File;ZJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; public final fun component2 ()Lcom/trendyol/stove/process/ProcessTarget; public final fun component3 ()Lcom/trendyol/stove/process/EnvProvider; public final fun component4 ()Lcom/trendyol/stove/process/ArgsProvider; public final fun component5 ()Lkotlin/jvm/functions/Function3; public final fun component6 ()Ljava/io/File; public final fun component7 ()Z public final fun component8-UwyO8pc ()J public final fun copy-Kk497nc (Ljava/util/List;Lcom/trendyol/stove/process/ProcessTarget;Lcom/trendyol/stove/process/EnvProvider;Lcom/trendyol/stove/process/ArgsProvider;Lkotlin/jvm/functions/Function3;Ljava/io/File;ZJ)Lcom/trendyol/stove/process/ProcessApplicationOptions; public static synthetic fun copy-Kk497nc$default (Lcom/trendyol/stove/process/ProcessApplicationOptions;Ljava/util/List;Lcom/trendyol/stove/process/ProcessTarget;Lcom/trendyol/stove/process/EnvProvider;Lcom/trendyol/stove/process/ArgsProvider;Lkotlin/jvm/functions/Function3;Ljava/io/File;ZJILjava/lang/Object;)Lcom/trendyol/stove/process/ProcessApplicationOptions; public fun equals (Ljava/lang/Object;)Z public final fun getArgsProvider ()Lcom/trendyol/stove/process/ArgsProvider; public final fun getBeforeStarted ()Lkotlin/jvm/functions/Function3; public final fun getCommand ()Ljava/util/List; public final fun getEnvProvider ()Lcom/trendyol/stove/process/EnvProvider; public final fun getGracefulShutdownTimeout-UwyO8pc ()J public final fun getRedirectErrorStream ()Z public final fun getTarget ()Lcom/trendyol/stove/process/ProcessTarget; public final fun getWorkingDirectory ()Ljava/io/File; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/process/ProcessApplicationUnderTest : com/trendyol/stove/system/abstractions/ApplicationUnderTest { public fun (Lcom/trendyol/stove/process/ProcessApplicationOptions;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/process/ProcessDslKt { public static final fun goApp-k5kRdxM (Lcom/trendyol/stove/system/Stove;Ljava/lang/String;Lcom/trendyol/stove/process/ProcessTarget;Lcom/trendyol/stove/process/EnvProvider;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun goApp-k5kRdxM$default (Lcom/trendyol/stove/system/Stove;Ljava/lang/String;Lcom/trendyol/stove/process/ProcessTarget;Lcom/trendyol/stove/process/EnvProvider;ILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static final fun processApp-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } public abstract interface class com/trendyol/stove/process/ProcessTarget { public abstract fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; } public final class com/trendyol/stove/process/ProcessTarget$Server : com/trendyol/stove/process/ProcessTarget { public fun (ILjava/lang/String;Lcom/trendyol/stove/system/ReadinessStrategy;)V public synthetic fun (ILjava/lang/String;Lcom/trendyol/stove/system/ReadinessStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component2 ()Ljava/lang/String; public final fun component3 ()Lcom/trendyol/stove/system/ReadinessStrategy; public final fun copy (ILjava/lang/String;Lcom/trendyol/stove/system/ReadinessStrategy;)Lcom/trendyol/stove/process/ProcessTarget$Server; public static synthetic fun copy$default (Lcom/trendyol/stove/process/ProcessTarget$Server;ILjava/lang/String;Lcom/trendyol/stove/system/ReadinessStrategy;ILjava/lang/Object;)Lcom/trendyol/stove/process/ProcessTarget$Server; public fun equals (Ljava/lang/Object;)Z public final fun getPort ()I public final fun getPortEnvVar ()Ljava/lang/String; public fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/process/ProcessTarget$Worker : com/trendyol/stove/process/ProcessTarget { public fun ()V public fun (Lcom/trendyol/stove/system/ReadinessStrategy;)V public synthetic fun (Lcom/trendyol/stove/system/ReadinessStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/trendyol/stove/system/ReadinessStrategy; public final fun copy (Lcom/trendyol/stove/system/ReadinessStrategy;)Lcom/trendyol/stove/process/ProcessTarget$Worker; public static synthetic fun copy$default (Lcom/trendyol/stove/process/ProcessTarget$Worker;Lcom/trendyol/stove/system/ReadinessStrategy;ILjava/lang/Object;)Lcom/trendyol/stove/process/ProcessTarget$Worker; public fun equals (Ljava/lang/Object;)Z public fun getReadiness ()Lcom/trendyol/stove/system/ReadinessStrategy; public fun hashCode ()I public fun toString ()Ljava/lang/String; } ================================================ FILE: starters/process/stove-process/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.framework.engine) testImplementation(libs.kotest.assertions.core) } ================================================ FILE: starters/process/stove-process/src/main/kotlin/com/trendyol/stove/process/ArgsProvider.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.annotations.StoveDsl import com.trendyol.stove.system.application.ArgsMapperBuilder as CoreArgsMapperBuilder import com.trendyol.stove.system.application.ArgsProvider as CoreArgsProvider fun interface ArgsProvider { fun provide(configurations: Map): List companion object { fun empty(): ArgsProvider = ArgsProvider { emptyList() } } } fun argsMapper( prefix: String = "--", separator: String = "=", block: ArgsMapperBuilder.() -> Unit ): ArgsProvider = ArgsMapperBuilder(prefix, separator).apply(block).build() @StoveDsl class ArgsMapperBuilder( private val prefix: String, private val separator: String ) { private val delegate = CoreArgsMapperBuilder(prefix = prefix, separator = separator) infix fun String.to(flagName: String) { delegate.map(this, flagName) } fun arg(flag: String, value: String? = null) { delegate.arg(flag, value) } fun arg(flag: String, value: () -> String) { delegate.arg(flag, value) } internal fun build(): ArgsProvider { val coreProvider: CoreArgsProvider = delegate.build() return ArgsProvider { configurations -> coreProvider.provide(configurations) } } } ================================================ FILE: starters/process/stove-process/src/main/kotlin/com/trendyol/stove/process/EnvProvider.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.annotations.StoveDsl import com.trendyol.stove.system.application.EnvMapperBuilder as CoreEnvMapperBuilder import com.trendyol.stove.system.application.EnvProvider as CoreEnvProvider fun interface EnvProvider { fun provide(configurations: Map): Map companion object { fun empty(): EnvProvider = EnvProvider { emptyMap() } } } fun envMapper(block: EnvMapperBuilder.() -> Unit): EnvProvider = EnvMapperBuilder().apply(block).build() @StoveDsl class EnvMapperBuilder { private val delegate = CoreEnvMapperBuilder() infix fun String.to(envVarName: String) { delegate.map(this, envVarName) } fun env(name: String, value: String) { delegate.env(name, value) } fun env(name: String, value: () -> String) { delegate.env(name, value) } internal fun build(): EnvProvider { val coreProvider: CoreEnvProvider = delegate.build() return EnvProvider { configurations -> coreProvider.provide(configurations) } } } ================================================ FILE: starters/process/stove-process/src/main/kotlin/com/trendyol/stove/process/ProcessApplicationOptions.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.* import com.trendyol.stove.system.annotations.StoveDsl import java.io.File import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** * Describes what kind of process is being tested and how to verify its readiness. * * ## Server vs Worker * * - [Server]: Listens on a port (HTTP APIs, gRPC servers, TCP servers). * Default readiness is an HTTP health check. * - [Worker]: Does not listen on a port (Kafka consumers, batch processors, CLI tools). * Default readiness is a fixed delay. * * Both variants accept any [ReadinessStrategy], so a gRPC server can use * [ReadinessStrategy.TcpPort] and a worker with a health endpoint can use * [ReadinessStrategy.HttpGet]. * * ## Usage * * ```kotlin * // HTTP API — default health check * ProcessTarget.Server(port = 8080, portEnvVar = "APP_PORT") * * // gRPC server — TCP readiness * ProcessTarget.Server( * port = 50051, * portEnvVar = "GRPC_PORT", * readiness = ReadinessStrategy.TcpPort(port = 50051), * ) * * // Kafka consumer — fixed delay * ProcessTarget.Worker() * * // Worker with custom probe * ProcessTarget.Worker( * readiness = ReadinessStrategy.Probe { File("/tmp/ready").exists() } * ) * ``` * * @see ReadinessStrategy * @see ProcessApplicationOptions */ sealed interface ProcessTarget { val readiness: ReadinessStrategy /** * A process that listens on a network port (HTTP, gRPC, TCP). * * @param port The port the process listens on. * @param portEnvVar The environment variable name used to pass the port to the process. * @param readiness How to verify the process is ready. Defaults to HTTP health check at `/health`. */ data class Server( val port: Int, val portEnvVar: String = "PORT", override val readiness: ReadinessStrategy = ReadinessStrategy.HttpGet(url = "http://localhost:$port/health") ) : ProcessTarget /** * A process without a network port (consumers, workers, CLI tools). * * @param readiness How to verify the process is ready. Defaults to a 2-second fixed delay. */ data class Worker( override val readiness: ReadinessStrategy = ReadinessStrategy.FixedDelay() ) : ProcessTarget } /** * Options for running an OS process as the application under test. * * Works with **any language** — Go, Python, Rust, Node.js, Java CLI, etc. * The process is started via [ProcessBuilder], configured with environment * variables from [envProvider], and verified via the [target]'s readiness strategy. * * ## Example * * ```kotlin * // Environment variables (Go, Node.js, etc.) * processApp { * ProcessApplicationOptions( * command = listOf("/path/to/api-server"), * target = ProcessTarget.Server(port = 8090, portEnvVar = "APP_PORT"), * envProvider = envMapper { * "database.host" to "DB_HOST" * "database.port" to "DB_PORT" * env("LOG_LEVEL", "debug") * } * ) * } * * // CLI arguments (Rust, Python argparse, etc.) * processApp { * ProcessApplicationOptions( * command = listOf("/path/to/server"), * target = ProcessTarget.Server(port = 8090), * argsProvider = argsMapper(prefix = "--", separator = "=") { * "database.host" to "db-host" // --db-host=localhost * "database.port" to "db-port" // --db-port=5432 * } * ) * } * ``` * * @param command The executable and its arguments (e.g., `listOf("/path/to/app", "--verbose")`). * @param target Describes the process type and how to verify readiness. * @param envProvider Maps Stove configurations to environment variables for the process. * @param argsProvider Maps Stove configurations to CLI arguments appended to the command. * @param beforeStarted Called after configurations are resolved but before the process is launched. * Receives the resolved configuration map and the options themselves. Use this to write config files, * seed directories, or perform any setup the process needs at startup. * @param workingDirectory Optional working directory for the process. Defaults to the JVM's current directory. * @param redirectErrorStream Whether to merge stderr into stdout. Defaults to `true`. * @param gracefulShutdownTimeout How long to wait for the process to exit after SIGTERM before force-killing. * * @see ProcessTarget * @see EnvProvider * @see ArgsProvider * @see envMapper * @see argsMapper */ @StoveDsl data class ProcessApplicationOptions( val command: List, val target: ProcessTarget, val envProvider: EnvProvider = EnvProvider.empty(), val argsProvider: ArgsProvider = ArgsProvider.empty(), val beforeStarted: suspend ( configurations: Map, options: ProcessApplicationOptions ) -> Unit = { _, _ -> }, val workingDirectory: File? = null, val redirectErrorStream: Boolean = true, val gracefulShutdownTimeout: Duration = 5.seconds ) ================================================ FILE: starters/process/stove-process/src/main/kotlin/com/trendyol/stove/process/ProcessApplicationUnderTest.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.ReadinessChecker import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.annotations.StoveDsl import com.trendyol.stove.system.application.toConfigurationMap import kotlinx.coroutines.* import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit /** * An [ApplicationUnderTest] that manages an OS process (any language/runtime). * * Lifecycle: * 1. [start]: Parses Stove configurations, builds environment variables via [EnvProvider] * and CLI arguments via [ArgsProvider], starts the process, reads its output, and waits for readiness. * 2. [stop]: Sends SIGTERM, waits for graceful shutdown, force-kills if needed. * * ## Example * * ```kotlin * processApp { * ProcessApplicationOptions( * command = listOf("/path/to/server"), * target = ProcessTarget.Server(port = 8080), * envProvider = envMapper { "database.host" to "DB_HOST" } * ) * } * ``` * * @see ProcessApplicationOptions * @see ProcessTarget * @see EnvProvider * @see ArgsProvider */ @StoveDsl class ProcessApplicationUnderTest( private val options: ProcessApplicationOptions ) : ApplicationUnderTest { private val logger = LoggerFactory.getLogger(javaClass) private var process: Process? = null override suspend fun start(configurations: List) { val configMap = configurations.toConfigurationMap() val envVars = options.envProvider.provide(configMap) val cliArgs = options.argsProvider.provide(configMap) val fullCommand = options.command + cliArgs val processBuilder = ProcessBuilder(fullCommand) .redirectErrorStream(options.redirectErrorStream) options.workingDirectory?.let { processBuilder.directory(it) } processBuilder.environment().putAll(envVars) // Inject port env var for Server targets val target = options.target if (target is ProcessTarget.Server) { processBuilder.environment()[target.portEnvVar] = target.port.toString() } options.beforeStarted(configMap, options) logger.info("Starting process: {} with {} env vars and {} cli args", fullCommand, envVars.size, cliArgs.size) process = withContext(Dispatchers.IO) { processBuilder.start() } launchOutputReader(process!!) ReadinessChecker.check(options.target.readiness) logger.info("Process is ready") } override suspend fun stop() { process?.let { p -> logger.info("Stopping process (SIGTERM)") p.destroy() if (!p.waitFor(options.gracefulShutdownTimeout.inWholeSeconds, TimeUnit.SECONDS)) { logger.warn("Process did not stop gracefully, force-killing") p.destroyForcibly().waitFor() } logger.info("Process stopped (exit code: {})", p.exitValue()) } } private fun launchOutputReader(process: Process) { val commandName = options.command.firstOrNull() ?.substringAfterLast('/') ?.substringAfterLast('\\') ?: "process" Thread { process.inputStream.bufferedReader().forEachLine { line -> logger.info("[{}] {}", commandName, line) } }.apply { isDaemon = true name = "$commandName-output-reader" start() } } } ================================================ FILE: starters/process/stove-process/src/main/kotlin/com/trendyol/stove/process/ProcessDsl.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.WithDsl import com.trendyol.stove.system.abstractions.ReadyStove /** * Registers an OS-process-based application under test. * * Works with **any language** — Go, Python, Rust, Node.js, Java CLI, etc. * The process is started with environment variables derived from Stove's * infrastructure configurations, and readiness is verified via the target's * [ReadinessStrategy][com.trendyol.stove.system.ReadinessStrategy]. * * ## Example * * ```kotlin * Stove().with { * httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8090") } * postgresql { PostgresqlOptions(...) } * * processApp { * ProcessApplicationOptions( * command = listOf("/path/to/server"), * target = ProcessTarget.Server(port = 8090, portEnvVar = "APP_PORT"), * envProvider = envMapper { * "database.host" to "DB_HOST" * "database.port" to "DB_PORT" * } * ) * } * }.run() * ``` * * @param configure Configuration block that returns [ProcessApplicationOptions]. * @return [ReadyStove] to chain with `.run()`. * @see ProcessApplicationOptions * @see ProcessTarget */ fun WithDsl.processApp(configure: () -> ProcessApplicationOptions): ReadyStove { this.stove.applicationUnderTest(ProcessApplicationUnderTest(configure())) return this.stove } /** * Convenience extension for Go applications. * * Defaults the binary path from the `go.app.binary` system property, which is * typically set by the Gradle build task that compiles the Go binary. * * ## Example * * ```kotlin * Stove().with { * httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8090") } * postgresql { PostgresqlOptions(...) } * * goApp( * target = ProcessTarget.Server(port = 8090, portEnvVar = "APP_PORT"), * envProvider = envMapper { * "database.host" to "DB_HOST" * "database.port" to "DB_PORT" * } * ) * }.run() * ``` * * @param binaryPath Path to the compiled Go binary. Defaults to `go.app.binary` system property. * @param target The process target (Server or Worker) with readiness strategy. * @param envProvider Maps Stove configurations to environment variables. * @return [ReadyStove] to chain with `.run()`. */ fun WithDsl.goApp( binaryPath: String = System.getProperty("go.app.binary") ?: error("go.app.binary system property not set"), target: ProcessTarget, envProvider: EnvProvider = EnvProvider.empty() ): ReadyStove = processApp { ProcessApplicationOptions( command = listOf(binaryPath), target = target, envProvider = envProvider ) } ================================================ FILE: starters/process/stove-process/src/test/kotlin/com/trendyol/stove/process/ArgsProviderTest.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.ReadinessStrategy import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactly import kotlinx.coroutines.runBlocking import kotlin.time.Duration.Companion.milliseconds class ArgsProviderTest : FunSpec({ context("ArgsProvider.empty") { test("returns empty list") { val provider = ArgsProvider.empty() provider.provide(mapOf("a" to "b")).shouldBeEmpty() } } context("ArgsProvider fun interface") { test("can be implemented as lambda") { val provider = ArgsProvider { configs -> listOf("--host", configs.getValue("database.host")) } provider.provide(mapOf("database.host" to "localhost")) shouldContainExactly listOf("--host", "localhost") } } context("argsMapper with equals separator") { test("produces --flag=value args") { val provider = argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" "database.port" to "db-port" } val result = provider.provide( mapOf("database.host" to "localhost", "database.port" to "5432") ) result shouldContainExactly listOf("--db-host=localhost", "--db-port=5432") } } context("argsMapper with space separator") { test("produces two separate args per mapping") { val provider = argsMapper(prefix = "--", separator = " ") { "database.host" to "db-host" "database.port" to "db-port" } val result = provider.provide( mapOf("database.host" to "localhost", "database.port" to "5432") ) result shouldContainExactly listOf("--db-host", "localhost", "--db-port", "5432") } } context("argsMapper with single-dash prefix") { test("produces -flag value args") { val provider = argsMapper(prefix = "-", separator = " ") { "database.host" to "h" "database.port" to "p" } val result = provider.provide( mapOf("database.host" to "localhost", "database.port" to "5432") ) result shouldContainExactly listOf("-h", "localhost", "-p", "5432") } } context("argsMapper with no prefix") { test("produces flag=value args") { val provider = argsMapper(prefix = "", separator = "=") { "database.host" to "db-host" } val result = provider.provide(mapOf("database.host" to "localhost")) result shouldContainExactly listOf("db-host=localhost") } } context("argsMapper skips missing keys") { test("only includes present config keys") { val provider = argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" "database.port" to "db-port" } val result = provider.provide(mapOf("database.host" to "localhost")) result shouldContainExactly listOf("--db-host=localhost") } } context("argsMapper static args") { test("adds boolean flag without value") { val provider = argsMapper(prefix = "--", separator = "=") { arg("verbose") } val result = provider.provide(emptyMap()) result shouldContainExactly listOf("--verbose") } test("adds flag with static value") { val provider = argsMapper(prefix = "--", separator = "=") { arg("log-level", "debug") } val result = provider.provide(emptyMap()) result shouldContainExactly listOf("--log-level=debug") } test("adds flag with computed value") { val provider = argsMapper(prefix = "--", separator = "=") { arg("config-file") { "/tmp/test.yaml" } } val result = provider.provide(emptyMap()) result shouldContainExactly listOf("--config-file=/tmp/test.yaml") } test("static flag with space separator produces two args") { val provider = argsMapper(prefix = "--", separator = " ") { arg("log-level", "debug") } val result = provider.provide(emptyMap()) result shouldContainExactly listOf("--log-level", "debug") } } context("argsMapper combines mappings and static args") { test("mappings come before static args") { val provider = argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" arg("verbose") arg("log-level", "debug") } val result = provider.provide(mapOf("database.host" to "localhost")) result shouldContainExactly listOf("--db-host=localhost", "--verbose", "--log-level=debug") } } context("empty builder") { test("returns empty list") { val provider = argsMapper { } provider.provide(mapOf("a" to "b")).shouldBeEmpty() } } context("integration with ProcessApplicationUnderTest") { test("CLI args are appended to command") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "echo received: \"$@\"", "--"), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), argsProvider = argsMapper(prefix = "--", separator = "=") { "database.host" to "db-host" arg("verbose") } ) ) runBlocking { aut.start(listOf("database.host=localhost")) aut.stop() } } test("both env vars and CLI args work together") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf( "sh", "-c", "[ \"\$DB_HOST\" = 'localhost' ] || exit 1; sleep 1" ), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), envProvider = envMapper { "database.host" to "DB_HOST" }, argsProvider = argsMapper(prefix = "--", separator = "=") { "database.port" to "db-port" } ) ) runBlocking { aut.start(listOf("database.host=localhost", "database.port=5432")) aut.stop() } } } }) ================================================ FILE: starters/process/stove-process/src/test/kotlin/com/trendyol/stove/process/EnvProviderTest.kt ================================================ package com.trendyol.stove.process import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.maps.shouldBeEmpty import io.kotest.matchers.maps.shouldContainExactly import io.kotest.matchers.shouldBe class EnvProviderTest : FunSpec({ context("EnvProvider.empty") { test("returns empty map") { val provider = EnvProvider.empty() provider.provide(mapOf("a" to "b")).shouldBeEmpty() } } context("EnvProvider fun interface") { test("can be implemented as lambda") { val provider = EnvProvider { configs -> mapOf("DB_HOST" to configs.getValue("database.host")) } provider.provide(mapOf("database.host" to "localhost")) shouldContainExactly mapOf("DB_HOST" to "localhost") } } context("envMapper builder") { test("maps config keys to env var names") { val provider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" } val result = provider.provide( mapOf("database.host" to "localhost", "database.port" to "5432") ) result shouldContainExactly mapOf("DB_HOST" to "localhost", "DB_PORT" to "5432") } test("skips missing config keys silently") { val provider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" } val result = provider.provide(mapOf("database.host" to "localhost")) result shouldContainExactly mapOf("DB_HOST" to "localhost") } test("adds static env vars") { val provider = envMapper { env("APP_ENV", "test") env("LOG_LEVEL", "debug") } val result = provider.provide(emptyMap()) result shouldContainExactly mapOf("APP_ENV" to "test", "LOG_LEVEL" to "debug") } test("adds computed env vars") { var counter = 0 val provider = envMapper { env("COMPUTED") { "value-${++counter}" } } val result = provider.provide(emptyMap()) result["COMPUTED"] shouldBe "value-1" } test("combines mappings and static vars") { val provider = envMapper { "database.host" to "DB_HOST" env("APP_ENV", "test") env("DYNAMIC") { "computed" } } val result = provider.provide(mapOf("database.host" to "localhost")) result shouldContainExactly mapOf( "DB_HOST" to "localhost", "APP_ENV" to "test", "DYNAMIC" to "computed" ) } test("static vars override mappings with same name") { val provider = envMapper { "some.key" to "MY_VAR" env("MY_VAR", "override") } val result = provider.provide(mapOf("some.key" to "original")) result["MY_VAR"] shouldBe "override" } test("empty builder returns empty map") { val provider = envMapper { } provider.provide(mapOf("a" to "b")).shouldBeEmpty() } } }) ================================================ FILE: starters/process/stove-process/src/test/kotlin/com/trendyol/stove/process/ProcessApplicationUnderTestTest.kt ================================================ package com.trendyol.stove.process import com.trendyol.stove.system.ReadinessStrategy import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class ProcessApplicationUnderTestTest : FunSpec({ context("Server target") { test("starts process and injects port env var") { val port = java.net.ServerSocket(0).use { it.localPort } val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "echo PORT=\$APP_PORT && sleep 5"), target = ProcessTarget.Server( port = port, portEnvVar = "APP_PORT", readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), envProvider = EnvProvider.empty() ) ) runBlocking { aut.start(emptyList()) aut.stop() } } test("passes Stove configurations through envProvider") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf( "sh", "-c", "[ \"\$DB_HOST\" = 'localhost' ] && [ \"\$DB_PORT\" = '5432' ] && exit 0 || exit 1" ), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), envProvider = envMapper { "database.host" to "DB_HOST" "database.port" to "DB_PORT" } ) ) runBlocking { aut.start(listOf("database.host=localhost", "database.port=5432")) aut.stop() } } test("static and computed env vars are injected") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf( "sh", "-c", "[ \"\$APP_ENV\" = 'test' ] && [ \"\$COMPUTED\" = 'hello' ] || exit 1; sleep 1" ), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), envProvider = envMapper { env("APP_ENV", "test") env("COMPUTED") { "hello" } } ) ) runBlocking { aut.start(emptyList()) aut.stop() } } } context("beforeStarted callback") { test("is called with configurations and options before process starts") { lateinit var capturedConfigs: Map lateinit var capturedOptions: ProcessApplicationOptions val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "sleep 5"), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), envProvider = envMapper { "database.host" to "DB_HOST" }, beforeStarted = { configs, opts -> capturedConfigs = configs capturedOptions = opts } ) ) runBlocking { aut.start(listOf("database.host=localhost", "database.port=5432")) capturedConfigs shouldBe mapOf("database.host" to "localhost", "database.port" to "5432") capturedOptions.command shouldBe listOf("sh", "-c", "sleep 5") capturedOptions.workingDirectory shouldBe null aut.stop() } } test("options include working directory when set") { val tempDir = kotlin.io.path.createTempDirectory("stove-test").toFile() lateinit var capturedOptions: ProcessApplicationOptions try { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "sleep 5"), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), beforeStarted = { _, opts -> capturedOptions = opts }, workingDirectory = tempDir ) ) runBlocking { aut.start(emptyList()) capturedOptions.workingDirectory shouldBe tempDir aut.stop() } } finally { tempDir.deleteRecursively() } } } context("Worker target") { test("starts without port injection") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "sleep 5"), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ) ) ) runBlocking { aut.start(emptyList()) aut.stop() } } } context("stop lifecycle") { test("stop terminates a running process") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sleep", "30"), target = ProcessTarget.Worker( readiness = ReadinessStrategy.FixedDelay(100.milliseconds) ), gracefulShutdownTimeout = 5.seconds ) ) runBlocking { aut.start(emptyList()) aut.stop() } // No exception — process was stopped successfully } test("stop is no-op when process was never started") { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "sleep 1"), target = ProcessTarget.Worker() ) ) runBlocking { aut.stop() } // No exception — success } } context("readiness") { test("fails when process exits before readiness check") { shouldThrow { val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "exit 1"), target = ProcessTarget.Server( port = 19999, readiness = ReadinessStrategy.TcpPort( port = 19999, retries = 2, retryDelay = 50.milliseconds ) ) ) ) runBlocking { aut.start(emptyList()) } } } test("readiness probe succeeds") { var ready = false val aut = ProcessApplicationUnderTest( ProcessApplicationOptions( command = listOf("sh", "-c", "sleep 30"), target = ProcessTarget.Worker( readiness = ReadinessStrategy.Probe(retries = 5, retryDelay = 50.milliseconds) { ready = true true } ) ) ) runBlocking { aut.start(emptyList()) ready shouldBe true aut.stop() } } } context("ProcessTarget defaults") { test("Server defaults to HTTP health check on /health") { val target = ProcessTarget.Server(port = 8080) val readiness = target.readiness (readiness is ReadinessStrategy.HttpGet) shouldBe true (readiness as ReadinessStrategy.HttpGet).url shouldContain "8080" readiness.url shouldContain "/health" } test("Server accepts custom portEnvVar") { val target = ProcessTarget.Server(port = 8080, portEnvVar = "MY_PORT") target.portEnvVar shouldBe "MY_PORT" } test("Worker defaults to FixedDelay") { val target = ProcessTarget.Worker() (target.readiness is ReadinessStrategy.FixedDelay) shouldBe true } test("Worker accepts custom readiness strategy") { val target = ProcessTarget.Worker( readiness = ReadinessStrategy.TcpPort(port = 9090) ) (target.readiness is ReadinessStrategy.TcpPort) shouldBe true } } }) ================================================ FILE: starters/quarkus/stove-quarkus/api/stove-quarkus.api ================================================ public final class com/trendyol/stove/quarkus/QuarkusApplicationUnderTest : com/trendyol/stove/system/abstractions/ApplicationUnderTest { public fun (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/quarkus/QuarkusApplicationUnderTestKt { public static final fun quarkus-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun quarkus-SscbJ7Y$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } ================================================ FILE: starters/quarkus/stove-quarkus/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) compileOnly(libs.quarkus.core) } ================================================ FILE: starters/quarkus/stove-quarkus/src/main/kotlin/com/trendyol/stove/quarkus/QuarkusApplicationUnderTest.kt ================================================ package com.trendyol.stove.quarkus import com.trendyol.stove.system.Runner import com.trendyol.stove.system.Stove import com.trendyol.stove.system.WithDsl import com.trendyol.stove.system.abstractions.AfterRunAwareWithContext import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.system.abstractions.ReadyStove import com.trendyol.stove.system.annotations.StoveDsl import io.quarkus.runtime.Quarkus import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import java.io.BufferedInputStream import java.io.Closeable import java.io.File import java.lang.reflect.InvocationTargetException import java.net.HttpURLConnection import java.net.URI import java.net.URL import java.net.URLClassLoader import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.atomic.AtomicReference internal fun Stove.systemUnderTest( runner: Runner, withParameters: List = listOf() ): ReadyStove { this.applicationUnderTest(QuarkusApplicationUnderTest(this, runner, withParameters)) return this } fun WithDsl.quarkus( runner: Runner, withParameters: List = listOf() ): ReadyStove = this.stove.systemUnderTest(runner, withParameters) @StoveDsl class QuarkusApplicationUnderTest( private val stove: Stove, private val runner: Runner, private val parameters: List ) : ApplicationUnderTest { private var launcher: QuarkusLauncher? = null @Suppress("TooGenericExceptionCaught") override suspend fun start(configurations: List): Unit = coroutineScope { val quarkusLauncher = createLauncher(configurations) launcher = quarkusLauncher quarkusLauncher.start() try { waitForApplication(quarkusLauncher) notifySystemsAfterRun() } catch (error: Throwable) { stopAfterStartupFailure(quarkusLauncher, error) launcher = null throw error } } override suspend fun stop() { launcher?.stop() launcher = null } private suspend fun waitForApplication(quarkusLauncher: QuarkusLauncher) { val startTime = System.currentTimeMillis() val startupTimeoutMs = resolveTimeoutMillis( propertyName = STARTUP_TIMEOUT_PROPERTY, defaultValue = DEFAULT_STARTUP_TIMEOUT_MS ) while (true) { failIfStartupFailed(quarkusLauncher) if (quarkusLauncher.isReady()) return failIfStartupTimedOut(quarkusLauncher, startTime, startupTimeoutMs) delay(POLL_INTERVAL_MS) } } private suspend fun notifySystemsAfterRun() { coroutineScope { stove.systemsOf>() .map { async { it.afterRun(Unit) } } .awaitAll() } } private fun createLauncher(configurations: List) = QuarkusLauncher( runtime = resolveLaunchRuntime(runner), configuration = QuarkusConfiguration.from(configurations + parameters) ) @Suppress("TooGenericExceptionCaught") private fun stopAfterStartupFailure(quarkusLauncher: QuarkusLauncher, error: Throwable) { try { quarkusLauncher.stop() } catch (stopError: Throwable) { error.addSuppressed(stopError) } } private fun failIfStartupFailed(quarkusLauncher: QuarkusLauncher) { quarkusLauncher.failureOrNull()?.let { failure -> throw IllegalStateException("Quarkus startup failed", failure) } } private fun failIfStartupTimedOut( quarkusLauncher: QuarkusLauncher, startTime: Long, startupTimeoutMs: Long ) { if (System.currentTimeMillis() - startTime <= startupTimeoutMs) return error(readinessTimeoutMessage(quarkusLauncher, startupTimeoutMs)) } private fun readinessTimeoutMessage(quarkusLauncher: QuarkusLauncher, startupTimeoutMs: Long) = "Timeout waiting for Quarkus application readiness after ${startupTimeoutMs}ms. " + quarkusLauncher.describeReadinessState() } private data class LaunchRuntime( val modeName: String, val launch: (Array) -> Unit, val stop: () -> Unit = {}, val close: () -> Unit = {} ) private data class QuarkusConfiguration( val properties: Map, val httpPort: Int, val readinessHosts: List ) { val readinessUrls: List = readinessHosts.map { "http://$it:$httpPort/" } companion object { fun from(configurations: List): QuarkusConfiguration { val properties = parseConfigurations(configurations) return QuarkusConfiguration( properties = properties, httpPort = properties["quarkus.http.port"]?.toIntOrNull() ?: DEFAULT_HTTP_PORT, readinessHosts = resolveReadinessHosts(properties) ) } } } private fun resolveLaunchRuntime(runner: Runner): LaunchRuntime = resolvePackagedRuntimeArtifacts() ?.let(::packagedRuntime) ?: directMainRuntime(runner) private fun directMainRuntime(runner: Runner) = LaunchRuntime( modeName = "direct-main", launch = { args -> runner(args) }, stop = { Quarkus.asyncExit() } ) private fun packagedRuntime(packagedRuntimeArtifacts: PackagedRuntimeArtifacts): LaunchRuntime = PackagedRuntimeState(packagedRuntimeArtifacts).asRuntime() private class QuarkusLauncher( private val runtime: LaunchRuntime, private val configuration: QuarkusConfiguration ) : Closeable { private val runnerArguments = emptyArray() private val startupFailure = AtomicReference(null) private var previousSystemProperties: Map = emptyMap() private var launcherThread: Thread? = null @Suppress("TooGenericExceptionCaught") fun start() { check(launcherThread == null) { "Quarkus launcher already started" } prepareStart() try { val thread = createLauncherThread() launcherThread = thread thread.start() } catch (error: Throwable) { cleanupAfterFailedStart() throw error } } fun isAlive(): Boolean = launcherThread?.isAlive == true fun failureOrNull(): Throwable? = startupFailure.get() fun isReady(): Boolean = hasStartupSignal() || configuration.isHttpReady() fun describeReadinessState(): String = "launcherMode=${runtime.modeName}, " + "signalPresent=${hasStartupSignal()}, " + "httpReady=${configuration.isHttpReady()}, " + "readinessUrls=${configuration.readinessUrls}, " + "launcherAlive=${isAlive()}, " + "launcherState=${launcherThread?.state ?: "not-started"}" @Suppress("TooGenericExceptionCaught") fun stop() { val thread = launcherThread var stopFailure: Throwable? = null try { stopFailure = stopRunningThread(thread) } finally { cleanup() } stopFailure?.let { throw it } } override fun close() { stop() } private fun hasStartupSignal(): Boolean = System.getProperty(DEFAULT_READY_SIGNAL_PROPERTY) == READY_SIGNAL_VALUE private fun prepareStart() { clearStartupSignal() previousSystemProperties = applySystemProperties(configuration.properties) } private fun createLauncherThread() = Thread( { runtime.launchCatching(runnerArguments, startupFailure) }, "quarkus-main-launcher" ).apply { isDaemon = false setUncaughtExceptionHandler { _, error -> startupFailure.compareAndSet(null, unwrap(error)) } } private fun cleanupAfterFailedStart() { restoreSystemProperties(previousSystemProperties) previousSystemProperties = emptyMap() } @Suppress("TooGenericExceptionCaught") private fun stopRunningThread(thread: Thread?): Throwable? { if (thread == null || !thread.isAlive) return null val stopFailure = try { runtime.stop() null } catch (error: Throwable) { error } thread.join(SHUTDOWN_TIMEOUT_MS) if (thread.isAlive) { error("Timeout waiting for Quarkus to shut down") } return stopFailure } private fun cleanup() { clearStartupSignal() restoreSystemProperties(previousSystemProperties) previousSystemProperties = emptyMap() runtime.close() launcherThread = null } } private fun parseConfigurations(configurations: List): Map { val properties = linkedMapOf() configurations.forEach { configuration -> val separatorIndex = configuration.indexOf('=') require(separatorIndex > 0) { "Invalid Quarkus configuration '$configuration'. Expected key=value." } val key = configuration.substring(0, separatorIndex) val value = configuration.substring(separatorIndex + 1) properties[key] = value } return properties } private fun resolveReadinessHosts(configurationProperties: Map): List { val configuredHost = configurationProperties["quarkus.http.host"] return listOfNotNull(configuredHost, "localhost", "127.0.0.1") .filterNot { it == "0.0.0.0" || it == "::" || it == "[::]" } .distinct() } private fun QuarkusConfiguration.isHttpReady(): Boolean = readinessUrls.any(::isReadyUrl) private fun isReadyUrl(readinessUrl: String): Boolean { val connection = try { URI.create(readinessUrl).toURL().openConnection() as HttpURLConnection } catch (_: Exception) { return false } return try { connection.connectTimeout = CONNECTION_TIMEOUT_MS.toInt() connection.readTimeout = CONNECTION_TIMEOUT_MS.toInt() connection.requestMethod = "GET" connection.instanceFollowRedirects = false connection.responseCode true } catch (_: Exception) { false } finally { connection.disconnect() } } private fun applySystemProperties(properties: Map): Map = buildMap { properties.forEach { (key, value) -> put(key, System.getProperty(key)) System.setProperty(key, value) } } private fun restoreSystemProperties(previousSystemProperties: Map) { previousSystemProperties.forEach { (key, previousValue) -> if (previousValue == null) { System.clearProperty(key) } else { System.setProperty(key, previousValue) } } } private fun clearStartupSignal() { System.clearProperty(DEFAULT_READY_SIGNAL_PROPERTY) } private fun resolvePackagedRuntimeArtifacts(): PackagedRuntimeArtifacts? = resolveClassPathEntries() .mapNotNull { it.findBuildDirectory() } .distinct() .firstNotNullOfOrNull(::resolvePackagedRuntimeArtifacts) private fun Path.findBuildDirectory(): Path? = generateSequence(this) { current -> current.parent } .firstOrNull { current -> current.fileName?.toString() == "build" } private fun resolveTimeoutMillis(propertyName: String, defaultValue: Long): Long = System .getProperty(propertyName) ?.toLongOrNull() ?.takeIf { it > 0 } ?: defaultValue private data class PackagedRuntimeArtifacts( val appRoot: Path, val applicationDat: Path, val bootUrls: Array ) private data class PackagedApplication( val runnerClassLoader: ClassLoader, val mainClassName: String ) private class PackagedRuntimeState( private val packagedRuntimeArtifacts: PackagedRuntimeArtifacts ) { private var runnerClassLoader: Any? = null private var bootClassLoader: URLClassLoader? = null fun asRuntime() = LaunchRuntime( modeName = "packaged-runtime", launch = ::launch, stop = ::stop, close = ::close ) fun launch(runnerArguments: Array) { withBootClassLoader { packagedBootClassLoader -> val packagedApplication = loadPackagedApplication(packagedBootClassLoader) launchPackagedApplication(packagedApplication, packagedBootClassLoader, runnerArguments) } } fun stop() { (runnerClassLoader as? ClassLoader)?.let(::asyncExit) } fun close() { runnerClassLoader?.let(::closeRunnerClassLoader) runnerClassLoader = null bootClassLoader?.close() bootClassLoader = null } private fun withBootClassLoader(block: (URLClassLoader) -> Unit) { val originalClassLoader = Thread.currentThread().contextClassLoader val packagedBootClassLoader = createBootClassLoader(originalClassLoader) try { block(packagedBootClassLoader) } finally { restoreContextClassLoader(originalClassLoader, packagedBootClassLoader) close() } } private fun createBootClassLoader(originalClassLoader: ClassLoader): URLClassLoader = URLClassLoader(packagedRuntimeArtifacts.bootUrls, originalClassLoader).also { bootClassLoader = it } private fun loadPackagedApplication(packagedBootClassLoader: URLClassLoader): PackagedApplication { val serializedApplicationClass = loadSerializedApplicationClass(packagedBootClassLoader) val serializedApplication = readSerializedApplication(serializedApplicationClass) val packagedRunnerClassLoader = serializedApplication.runnerClassLoader(serializedApplicationClass) runnerClassLoader = packagedRunnerClassLoader return PackagedApplication( runnerClassLoader = packagedRunnerClassLoader as ClassLoader, mainClassName = serializedApplication.mainClassName(serializedApplicationClass) ) } private fun launchPackagedApplication( packagedApplication: PackagedApplication, packagedBootClassLoader: URLClassLoader, runnerArguments: Array ) { Thread.currentThread().contextClassLoader = packagedApplication.runnerClassLoader setForkJoinApplicationClassLoader(packagedBootClassLoader, packagedApplication.runnerClassLoader) invokeMain(packagedApplication.runnerClassLoader, packagedApplication.mainClassName, runnerArguments) } private fun restoreContextClassLoader( originalClassLoader: ClassLoader, packagedBootClassLoader: URLClassLoader ) { clearForkJoinApplicationClassLoader(packagedBootClassLoader) Thread.currentThread().contextClassLoader = originalClassLoader } private fun loadSerializedApplicationClass(packagedBootClassLoader: URLClassLoader): Class<*> = Class.forName(SERIALIZED_APPLICATION_CLASS_NAME, true, packagedBootClassLoader) private fun readSerializedApplication(serializedApplicationClass: Class<*>): Any = BufferedInputStream(packagedRuntimeArtifacts.applicationDat.toFile().inputStream()).use { input -> serializedApplicationClass .getMethod("read", java.io.InputStream::class.java, Path::class.java) .invoke(null, input, packagedRuntimeArtifacts.appRoot) } } @Suppress("TooGenericExceptionCaught") private fun LaunchRuntime.launchCatching( runnerArguments: Array, startupFailure: AtomicReference ) { try { launch(runnerArguments) } catch (error: Throwable) { startupFailure.compareAndSet(null, unwrap(error)) } } private fun resolveClassPathEntries(): List { val pathSeparator = File.pathSeparator return System .getProperty("java.class.path") .split(pathSeparator) .map { Paths.get(it).toAbsolutePath().normalize() } } private fun resolvePackagedRuntimeArtifacts(buildDirectory: Path): PackagedRuntimeArtifacts? { val appRoot = buildDirectory.resolve("quarkus-app") val applicationDat = appRoot.resolve("quarkus/quarkus-application.dat") val bootUrls = resolveBootUrls(appRoot.resolve("lib/boot")) if (!applicationDat.toFile().exists() || bootUrls.isEmpty()) return null return PackagedRuntimeArtifacts( appRoot = appRoot, applicationDat = applicationDat, bootUrls = bootUrls.toTypedArray() ) } private fun resolveBootUrls(bootDirectory: Path): List = bootDirectory .toFile() .listFiles { file -> file.extension == "jar" } ?.sortedBy { it.name } ?.map { it.toURI().toURL() } .orEmpty() private fun Any.runnerClassLoader(serializedApplicationClass: Class<*>): Any = serializedApplicationClass.getMethod("getRunnerClassLoader").invoke(this) private fun Any.mainClassName(serializedApplicationClass: Class<*>): String = serializedApplicationClass.getMethod("getMainClass").invoke(this) as String private fun invokeMain(appClassLoader: ClassLoader, mainClassName: String, runnerArguments: Array) { appClassLoader .loadClass(mainClassName) .getMethod("main", Array::class.java) .invoke(null, runnerArguments) } private fun asyncExit(appClassLoader: ClassLoader) { appClassLoader.loadClass(QUARKUS_CLASS_NAME).getMethod("asyncExit").invoke(null) } private fun setForkJoinApplicationClassLoader(bootClassLoader: URLClassLoader, appClassLoader: ClassLoader) { val forkJoinWorkerThreadClass = Class.forName(FORK_JOIN_WORKER_THREAD_CLASS_NAME, true, bootClassLoader) forkJoinWorkerThreadClass.getMethod("setQuarkusAppClassloader", ClassLoader::class.java).invoke(null, appClassLoader) } private fun clearForkJoinApplicationClassLoader(bootClassLoader: URLClassLoader) { val forkJoinWorkerThreadClass = Class.forName(FORK_JOIN_WORKER_THREAD_CLASS_NAME, true, bootClassLoader) forkJoinWorkerThreadClass.getMethod("setQuarkusAppClassloader", ClassLoader::class.java).invoke(null, null) } private fun closeRunnerClassLoader(runnerClassLoader: Any) { runnerClassLoader.javaClass.getMethod("close").invoke(runnerClassLoader) } private fun unwrap(error: Throwable): Throwable = when (error) { is InvocationTargetException -> error.targetException ?: error else -> error.cause?.takeIf { error is RuntimeException && error.message == null } ?: error } private const val DEFAULT_HTTP_PORT = 8080 private const val DEFAULT_STARTUP_TIMEOUT_MS = 120_000L private const val SHUTDOWN_TIMEOUT_MS = 10_000L private const val POLL_INTERVAL_MS = 250L private const val CONNECTION_TIMEOUT_MS = 500L private const val DEFAULT_READY_SIGNAL_PROPERTY = "stove.quarkus.ready" private const val READY_SIGNAL_VALUE = "true" private const val STARTUP_TIMEOUT_PROPERTY = "stove.quarkus.startup.timeout.ms" private const val FORK_JOIN_WORKER_THREAD_CLASS_NAME = "io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThread" private const val SERIALIZED_APPLICATION_CLASS_NAME = "io.quarkus.bootstrap.runner.SerializedApplication" private const val QUARKUS_CLASS_NAME = "io.quarkus.runtime.Quarkus" ================================================ FILE: starters/spring/stove-spring/api/stove-spring-common.api ================================================ public final class com/trendyol/stove/testing/e2e/BridgeSystemKt { public static final fun bridge-hQma78k (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; } public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTest : com/trendyol/stove/testing/e2e/system/abstractions/ApplicationUnderTest { public static final field Companion Lcom/trendyol/stove/testing/e2e/SpringApplicationUnderTest$Companion; public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTest$Companion { } public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTestKt { public static final fun springBoot-FMzRXaI (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;)Lcom/trendyol/stove/testing/e2e/system/abstractions/ReadyTestSystem; public static synthetic fun springBoot-FMzRXaI$default (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/system/abstractions/ReadyTestSystem; } public final class com/trendyol/stove/testing/e2e/SpringBridgeSystem : com/trendyol/stove/testing/e2e/system/BridgeSystem, com/trendyol/stove/testing/e2e/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/testing/e2e/system/abstractions/PluggedSystem { public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)V public fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; public fun getTestSystem ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; } ================================================ FILE: starters/spring/stove-spring/api/stove-spring.api ================================================ public final class com/trendyol/stove/spring/BridgeSystemKt { public static final fun bridge-IDauA90 (Lcom/trendyol/stove/system/Stove;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/spring/RegistrarKt { public static final fun addTestDependencies (Lorg/springframework/boot/SpringApplication;Lkotlin/jvm/functions/Function1;)V public static final fun addTestDependencies4x (Lorg/springframework/boot/SpringApplication;Lkotlin/jvm/functions/Function1;)V public static final fun stoveSpring4xRegistrar (Lkotlin/jvm/functions/Function1;)Lorg/springframework/context/ApplicationContextInitializer; public static final fun stoveSpringRegistrar (Lkotlin/jvm/functions/Function1;)Lorg/springframework/context/ApplicationContextInitializer; } public final class com/trendyol/stove/spring/SpringApplicationUnderTest : com/trendyol/stove/system/abstractions/ApplicationUnderTest { public static final field Companion Lcom/trendyol/stove/spring/SpringApplicationUnderTest$Companion; public fun (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/trendyol/stove/spring/SpringApplicationUnderTest$Companion { } public final class com/trendyol/stove/spring/SpringApplicationUnderTestKt { public static final fun springBoot-SscbJ7Y (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;)Lcom/trendyol/stove/system/abstractions/ReadyStove; public static synthetic fun springBoot-SscbJ7Y$default (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/system/abstractions/ReadyStove; } public final class com/trendyol/stove/spring/SpringBridgeSystem : com/trendyol/stove/system/BridgeSystem, com/trendyol/stove/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/system/abstractions/PluggedSystem { public fun (Lcom/trendyol/stove/system/Stove;)V public fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; public fun getStove ()Lcom/trendyol/stove/system/Stove; } ================================================ FILE: starters/spring/stove-spring/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) // Both Spring versions as compileOnly - users bring the actual version at runtime compileOnly(libs.spring.boot) compileOnly(libs.spring.boot.four) } dependencies { testImplementation(libs.kotest.runner.junit5) testImplementation(libs.mockito.kotlin) testImplementation(libs.spring.boot) } ================================================ FILE: starters/spring/stove-spring/src/main/kotlin/com/trendyol/stove/spring/BridgeSystem.kt ================================================ package com.trendyol.stove.spring import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.springframework.context.ApplicationContext import kotlin.reflect.KClass /** * A system that provides a bridge between the test system and the application context. * * @property stove the test system to bridge. */ @StoveDsl class SpringBridgeSystem( override val stove: Stove ) : BridgeSystem(stove), PluggedSystem, AfterRunAwareWithContext { override fun get(klass: KClass): D = ctx.getBean(klass.java) } /** * Returns the bridge system associated with the test system. * * @receiver the test system. * @return the bridge system. * @throws SystemNotRegisteredException if the bridge system is not registered. */ fun WithDsl.bridge(): Stove = this.stove.withBridgeSystem(SpringBridgeSystem(this.stove)) ================================================ FILE: starters/spring/stove-spring/src/main/kotlin/com/trendyol/stove/spring/SpringApplicationUnderTest.kt ================================================ @file:Suppress("UNCHECKED_CAST") package com.trendyol.stove.spring import com.trendyol.stove.system.Runner import com.trendyol.stove.system.Stove import com.trendyol.stove.system.WithDsl import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import kotlinx.coroutines.* import org.springframework.context.ConfigurableApplicationContext internal fun Stove.systemUnderTest( runner: Runner, withParameters: List = listOf() ): ReadyStove { this.applicationUnderTest(SpringApplicationUnderTest(this, runner, withParameters)) return this } fun WithDsl.springBoot( runner: Runner, withParameters: List = listOf() ): ReadyStove { SpringBootVersionCheck.ensureSpringBootAvailable() return this.stove.systemUnderTest(runner, withParameters) } @StoveDsl class SpringApplicationUnderTest( private val stove: Stove, private val runner: Runner, private val parameters: List ) : ApplicationUnderTest { private lateinit var application: ConfigurableApplicationContext companion object { private const val DELAY = 500L } override suspend fun start(configurations: List): ConfigurableApplicationContext = coroutineScope { val allConfigurations = (configurations + defaultConfigurations() + parameters).map { "--$it" }.toTypedArray() application = runner(allConfigurations) while (!application.isRunning || !application.isActive) { delay(DELAY) continue } stove.systemsOf>() .map { async(context = Dispatchers.IO) { it.afterRun(application) } } .awaitAll() application } override suspend fun stop(): Unit = application.stop() private fun defaultConfigurations(): Array = arrayOf("test-system=true") } ================================================ FILE: starters/spring/stove-spring/src/main/kotlin/com/trendyol/stove/spring/SpringBootVersionCheck.kt ================================================ @file:Suppress("TooGenericExceptionCaught", "SwallowedException") package com.trendyol.stove.spring /** * Utility object to check Spring Boot availability and version at runtime. * Since Spring Boot is a `compileOnly` dependency, users must bring their own version. */ internal object SpringBootVersionCheck { private const val SPRING_APPLICATION_CLASS = "org.springframework.boot.SpringApplication" private const val SPRING_BOOT_VERSION_CLASS = "org.springframework.boot.SpringBootVersion" /** * Checks if Spring Boot is available on the classpath. * @throws IllegalStateException if Spring Boot is not found */ fun ensureSpringBootAvailable() { try { Class.forName(SPRING_APPLICATION_CLASS) } catch (e: ClassNotFoundException) { throw IllegalStateException( """ | |═══════════════════════════════════════════════════════════════════════════════ | Spring Boot Not Found on Classpath! |═══════════════════════════════════════════════════════════════════════════════ | | stove-spring-testing-e2e requires Spring Boot to be on your classpath. | Spring Boot is declared as a 'compileOnly' dependency, so you must add it | to your project. | | Add one of the following to your build.gradle.kts: | | For Spring Boot 2.x: | testImplementation("org.springframework.boot:spring-boot-starter:2.7.x") | | For Spring Boot 3.x: | testImplementation("org.springframework.boot:spring-boot-starter:3.x.x") | | For Spring Boot 4.x: | testImplementation("org.springframework.boot:spring-boot-starter:4.x.x") | |═══════════════════════════════════════════════════════════════════════════════ """.trimMargin(), e ) } } /** * Gets the Spring Boot version if available. * @return the Spring Boot version string, or "unknown" if not determinable */ fun getSpringBootVersion(): String = try { val versionClass = Class.forName(SPRING_BOOT_VERSION_CLASS) val getVersionMethod = versionClass.getMethod("getVersion") getVersionMethod.invoke(null) as? String ?: "unknown" } catch (_: Exception) { "unknown" } /** * Gets the major version of Spring Boot. * @return the major version (2, 3, 4, etc.) or -1 if not determinable */ fun getSpringBootMajorVersion(): Int = try { val version = getSpringBootVersion() version.split(".").firstOrNull()?.toIntOrNull() ?: -1 } catch (e: Exception) { -1 } } ================================================ FILE: starters/spring/stove-spring/src/main/kotlin/com/trendyol/stove/spring/registrar.kt ================================================ @file:Suppress("DEPRECATION") package com.trendyol.stove.spring import org.springframework.beans.factory.BeanRegistrarDsl import org.springframework.boot.SpringApplication import org.springframework.context.ApplicationContextInitializer import org.springframework.context.support.* // ============================================================================= // Spring Boot 3.x (uses BeanDefinitionDsl - deprecated but still works) // ============================================================================= /** * Creates an [ApplicationContextInitializer] that registers beans using the [BeanDefinitionDsl]. * * **For Spring Boot 3.x applications.** * * Example usage: * ```kotlin * TestAppRunner.run(params) { * addInitializers( * stoveSpringRegistrar { * bean() * bean { MyRepositoryImpl() } * } * ) * } * ``` * * @param registration A lambda with [BeanDefinitionDsl] receiver to define beans. * @return An [ApplicationContextInitializer] that can be added to a [SpringApplication]. */ fun stoveSpringRegistrar( registration: BeanDefinitionDsl.() -> Unit ): ApplicationContextInitializer = ApplicationContextInitializer { context -> val beansDsl = beans(registration) beansDsl.initialize(context) } /** * Extension function to easily add test dependencies to a [SpringApplication]. * * **For Spring Boot 3.x applications.** * * Example usage: * ```kotlin * TestAppRunner.run(params) { * addTestDependencies { * bean() * bean { MyRepositoryImpl() } * } * } * ``` * * @param registration A lambda with [BeanDefinitionDsl] receiver to define beans. */ fun SpringApplication.addTestDependencies( registration: BeanDefinitionDsl.() -> Unit ): Unit = this.addInitializers(stoveSpringRegistrar(registration)) // ============================================================================= // Spring Boot 4.x (uses BeanRegistrarDsl - the new recommended approach) // ============================================================================= /** * Creates an [ApplicationContextInitializer] that registers beans using the [BeanRegistrarDsl]. * * **For Spring Boot 4.x applications.** * * Example usage: * ```kotlin * TestAppRunner.run(params) { * addInitializers( * stoveSpring4xRegistrar { * registerBean() * registerBean { MyRepositoryImpl() } * } * ) * } * ``` * * @param registration A lambda with [BeanRegistrarDsl] receiver to define beans. * @return An [ApplicationContextInitializer] that can be added to a [SpringApplication]. */ fun stoveSpring4xRegistrar( registration: BeanRegistrarDsl.() -> Unit ): ApplicationContextInitializer<*> = ApplicationContextInitializer { context -> context.register(BeanRegistrarDsl(registration)) } /** * Extension function to easily add test dependencies to a [SpringApplication]. * * **For Spring Boot 4.x applications.** * * Example usage: * ```kotlin * TestAppRunner.run(params) { * addTestDependencies4x { * registerBean() * registerBean { MyRepositoryImpl() } * } * } * ``` * * @param registration A lambda with [BeanRegistrarDsl] receiver to define beans. */ fun SpringApplication.addTestDependencies4x( registration: BeanRegistrarDsl.() -> Unit ): Unit = this.addInitializers(stoveSpring4xRegistrar(registration)) ================================================ FILE: starters/spring/stove-spring/src/test/kotlin/com/trendyol/stove/SpringApplicationUnderTestTests.kt ================================================ package com.trendyol.stove import com.trendyol.stove.spring.SpringApplicationUnderTest import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe import org.mockito.kotlin.* import org.springframework.context.ConfigurableApplicationContext class SpringApplicationUnderTestTests : FunSpec({ test("should include default test-system configuration") { val testSystem = Stove() var capturedArgs: Array = emptyArray() val runner: (Array) -> ConfigurableApplicationContext = { args -> capturedArgs = args mock { on { isRunning } doReturn true on { isActive } doReturn true } } val applicationUnderTest = SpringApplicationUnderTest( stove = testSystem, runner = runner, parameters = listOf() ) applicationUnderTest.start(listOf()) capturedArgs.toList().shouldContain("--test-system=true") } test("should include custom parameters") { val testSystem = Stove() var capturedArgs: Array = emptyArray() val runner: (Array) -> ConfigurableApplicationContext = { args -> capturedArgs = args mock { on { isRunning } doReturn true on { isActive } doReturn true } } val applicationUnderTest = SpringApplicationUnderTest( stove = testSystem, runner = runner, parameters = listOf("custom.param=value") ) applicationUnderTest.start(listOf()) capturedArgs.toList().shouldContain("--custom.param=value") } test("should include provided configurations") { val testSystem = Stove() var capturedArgs: Array = emptyArray() val runner: (Array) -> ConfigurableApplicationContext = { args -> capturedArgs = args mock { on { isRunning } doReturn true on { isActive } doReturn true } } val applicationUnderTest = SpringApplicationUnderTest( stove = testSystem, runner = runner, parameters = listOf() ) applicationUnderTest.start(listOf("server.port=8080", "spring.profiles.active=test")) capturedArgs.toList().shouldContain("--server.port=8080") capturedArgs.toList().shouldContain("--spring.profiles.active=test") } test("should combine all configurations with -- prefix") { val testSystem = Stove() var capturedArgs: Array = emptyArray() val runner: (Array) -> ConfigurableApplicationContext = { args -> capturedArgs = args mock { on { isRunning } doReturn true on { isActive } doReturn true } } val applicationUnderTest = SpringApplicationUnderTest( stove = testSystem, runner = runner, parameters = listOf("param1=val1") ) applicationUnderTest.start(listOf("config1=val1")) capturedArgs.all { it.startsWith("--") } shouldBe true } test("should stop application context") { val mockContext = mock { on { isRunning } doReturn true on { isActive } doReturn true } val testSystem = Stove() val runner: (Array) -> ConfigurableApplicationContext = { mockContext } val applicationUnderTest = SpringApplicationUnderTest( stove = testSystem, runner = runner, parameters = listOf() ) applicationUnderTest.start(listOf()) applicationUnderTest.stop() verify(mockContext).stop() } }) ================================================ FILE: starters/spring/stove-spring/src/test/kotlin/com/trendyol/stove/SpringBridgeSystemTests.kt ================================================ package com.trendyol.stove import com.trendyol.stove.spring.SpringBridgeSystem import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import org.mockito.kotlin.* import org.springframework.context.ApplicationContext class SpringBridgeSystemTests : FunSpec({ test("SpringBridgeSystem should return bean from application context") { val testSystem = Stove() val bridgeSystem = SpringBridgeSystem(testSystem) val mockContext = mock() val testBean = TestBean("test-value") whenever(mockContext.getBean(TestBean::class.java)).thenReturn(testBean) // Set the context via reflection since afterRun is protected val ctxField = bridgeSystem.javaClass.superclass.getDeclaredField("ctx") ctxField.isAccessible = true ctxField.set(bridgeSystem, mockContext) val result = bridgeSystem.get(TestBean::class) result shouldBe testBean verify(mockContext).getBean(TestBean::class.java) } test("SpringBridgeSystem should be associated with test system") { val testSystem = Stove() val bridgeSystem = SpringBridgeSystem(testSystem) bridgeSystem.stove shouldBe testSystem } test("SpringBridgeSystem should implement required interfaces") { val testSystem = Stove() val bridgeSystem = SpringBridgeSystem(testSystem) bridgeSystem.shouldBeInstanceOf() } }) data class TestBean( val value: String ) ================================================ FILE: starters/spring/stove-spring/src/test/kotlin/com/trendyol/stove/spring/SpringBootVersionCheckTest.kt ================================================ package com.trendyol.stove.spring import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.shouldNotBe class SpringBootVersionCheckTest : FunSpec({ test("ensureSpringBootAvailable should not throw when Spring Boot is on classpath") { SpringBootVersionCheck.ensureSpringBootAvailable() } test("getSpringBootVersion should return a non-blank value") { SpringBootVersionCheck.getSpringBootVersion().shouldNotBe("unknown") } test("getSpringBootMajorVersion should parse major version") { SpringBootVersionCheck.getSpringBootMajorVersion().shouldBeGreaterThanOrEqual(2) } }) ================================================ FILE: starters/spring/stove-spring-kafka/api/stove-spring-kafka-common.api ================================================ public final class com/trendyol/stove/testing/e2e/kafka/Caching { public static final field INSTANCE Lcom/trendyol/stove/testing/e2e/kafka/Caching; public final fun of ()Lcom/github/benmanes/caffeine/cache/Cache; } public final class com/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde { public fun ()V public fun (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;)V public synthetic fun (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lorg/apache/kafka/common/serialization/Serializer; public final fun component2 ()Lorg/apache/kafka/common/serialization/Serializer; public final fun copy (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;)Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde; public fun equals (Ljava/lang/Object;)Z public final fun getKeySerializer ()Lorg/apache/kafka/common/serialization/Serializer; public final fun getValueSerializer ()Lorg/apache/kafka/common/serialization/Serializer; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions : com/trendyol/stove/testing/e2e/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaContext { public fun (Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)V public final fun component1 ()Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; public final fun copy (Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext;Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext; public fun equals (Ljava/lang/Object;)Z public final fun getOptions ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface annotation class com/trendyol/stove/testing/e2e/kafka/KafkaDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration : com/trendyol/stove/testing/e2e/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getBootstrapServers ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext { public fun (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)V public final fun component1 ()Lorg/apache/kafka/clients/admin/Admin; public final fun component2 ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; public final fun copy (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext;Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getAdmin ()Lorg/apache/kafka/clients/admin/Admin; public final fun getOptions ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaOps { public fun (Lkotlin/jvm/functions/Function3;)V public final fun component1 ()Lkotlin/jvm/functions/Function3; public final fun copy (Lkotlin/jvm/functions/Function3;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; public fun equals (Ljava/lang/Object;)Z public final fun getSend ()Lkotlin/jvm/functions/Function3; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystem : com/trendyol/stove/testing/e2e/system/abstractions/ExposesConfiguration, com/trendyol/stove/testing/e2e/system/abstractions/PluggedSystem, com/trendyol/stove/testing/e2e/system/abstractions/RunnableSystemWithContext { public static final field Companion Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem$Companion; public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext;)V public final fun adminOperations (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public synthetic fun afterRun (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun afterRun (Lorg/springframework/context/ApplicationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun beforeRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getGetInterceptor ()Lkotlin/jvm/functions/Function0; public fun getTestSystem ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; public final fun pause ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem; public final fun publish (Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun publish$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem;Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBeConsumedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBeFailedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBePublishedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; public final fun unpause ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystem$Companion { public final fun kafkaTemplate (Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem;)Lorg/springframework/kafka/core/KafkaTemplate; } public class com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions : com/trendyol/stove/testing/e2e/database/migrations/SupportsMigrations, com/trendyol/stove/testing/e2e/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/testing/e2e/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Companion; public fun (Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainerOptions ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions; public fun getFallbackSerde ()Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde; public fun getMigrationCollection ()Lcom/trendyol/stove/testing/e2e/database/migrations/MigrationCollection; public fun getOps ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; public fun getPorts ()Ljava/util/List; public fun getRegistry ()Ljava/lang/String; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; } public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Companion { public final fun getDEFAULT_KAFKA_PORTS ()Ljava/util/List; public final fun provided (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/kafka/ProvidedKafkaSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/ProvidedKafkaSystemOptions; } public abstract interface class com/trendyol/stove/testing/e2e/kafka/MessageProperties { public abstract fun getKey ()Ljava/lang/String; public abstract fun getMetadata ()Lcom/trendyol/stove/testing/e2e/messaging/MessageMetadata; public abstract fun getPartition ()Ljava/lang/Integer; public abstract fun getTimestamp ()Ljava/lang/Long; public abstract fun getTopic ()Ljava/lang/String; public abstract fun getValue ()[B public abstract fun getValueAsString ()Ljava/lang/String; } public final class com/trendyol/stove/testing/e2e/kafka/OptionsKt { public static final fun kafka-E6EcY7A (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun kafka-PmNtuJU (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; } public final class com/trendyol/stove/testing/e2e/kafka/ProvidedKafkaSystemOptions : com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions, com/trendyol/stove/testing/e2e/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/testing/e2e/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/testing/e2e/kafka/StoveKafkaContainer : org/testcontainers/kafka/ConfluentKafkaContainer, com/trendyol/stove/testing/e2e/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/testing/e2e/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/testing/e2e/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public final class com/trendyol/stove/testing/e2e/kafka/TestSystemKafkaInterceptor : org/springframework/kafka/listener/CompositeRecordInterceptor, org/springframework/kafka/support/ProducerListener { public fun (Lcom/trendyol/stove/testing/e2e/serialization/StoveSerde;)V public fun failure (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Exception;Lorg/apache/kafka/clients/consumer/Consumer;)V public fun onError (Lorg/apache/kafka/clients/producer/ProducerRecord;Lorg/apache/kafka/clients/producer/RecordMetadata;Ljava/lang/Exception;)V public fun onSuccess (Lorg/apache/kafka/clients/producer/ProducerRecord;Lorg/apache/kafka/clients/producer/RecordMetadata;)V public fun success (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V } ================================================ FILE: starters/spring/stove-spring-kafka/api/stove-spring-kafka.api ================================================ public final class com/trendyol/stove/kafka/Caching { public static final field INSTANCE Lcom/trendyol/stove/kafka/Caching; public final fun of ()Lcom/github/benmanes/caffeine/cache/Cache; } public final class com/trendyol/stove/kafka/FallbackTemplateSerde { public fun ()V public fun (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;)V public synthetic fun (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lorg/apache/kafka/common/serialization/Serializer; public final fun component2 ()Lorg/apache/kafka/common/serialization/Serializer; public final fun copy (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;)Lcom/trendyol/stove/kafka/FallbackTemplateSerde; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/FallbackTemplateSerde; public fun equals (Ljava/lang/Object;)Z public final fun getKeySerializer ()Lorg/apache/kafka/common/serialization/Serializer; public final fun getValueSerializer ()Lorg/apache/kafka/common/serialization/Serializer; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaContainerOptions : com/trendyol/stove/containers/ContainerOptions { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/kafka/KafkaContainerOptions; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaContainerOptions; public fun equals (Ljava/lang/Object;)Z public fun getCompatibleSubstitute ()Ljava/lang/String; public fun getContainerFn ()Lkotlin/jvm/functions/Function1; public fun getImage ()Ljava/lang/String; public fun getImageWithTag ()Ljava/lang/String; public fun getRegistry ()Ljava/lang/String; public fun getTag ()Ljava/lang/String; public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaContext { public fun (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;)V public final fun component1 ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public final fun component2 ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public final fun copy (Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/kafka/KafkaContext; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaContext;Lcom/trendyol/stove/system/abstractions/SystemRuntime;Lcom/trendyol/stove/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaContext; public fun equals (Ljava/lang/Object;)Z public final fun getOptions ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public final fun getRuntime ()Lcom/trendyol/stove/system/abstractions/SystemRuntime; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public abstract interface annotation class com/trendyol/stove/kafka/KafkaDsl : java/lang/annotation/Annotation { } public final class com/trendyol/stove/kafka/KafkaExposedConfiguration : com/trendyol/stove/system/abstractions/ExposedConfiguration { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun copy (Ljava/lang/String;)Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaExposedConfiguration;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getBootstrapServers ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaMigrationContext { public fun (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/kafka/KafkaSystemOptions;)V public final fun component1 ()Lorg/apache/kafka/clients/admin/Admin; public final fun component2 ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public final fun copy (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/kafka/KafkaMigrationContext; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaMigrationContext;Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaMigrationContext; public fun equals (Ljava/lang/Object;)Z public final fun getAdmin ()Lorg/apache/kafka/clients/admin/Admin; public final fun getOptions ()Lcom/trendyol/stove/kafka/KafkaSystemOptions; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaOps { public fun (Lkotlin/jvm/functions/Function3;)V public final fun component1 ()Lkotlin/jvm/functions/Function3; public final fun copy (Lkotlin/jvm/functions/Function3;)Lcom/trendyol/stove/kafka/KafkaOps; public static synthetic fun copy$default (Lcom/trendyol/stove/kafka/KafkaOps;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/KafkaOps; public fun equals (Ljava/lang/Object;)Z public final fun getSend ()Lkotlin/jvm/functions/Function3; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/KafkaSystem : com/trendyol/stove/reporting/Reports, com/trendyol/stove/system/abstractions/ExposesConfiguration, com/trendyol/stove/system/abstractions/PluggedSystem, com/trendyol/stove/system/abstractions/RunnableSystemWithContext { public static final field Companion Lcom/trendyol/stove/kafka/KafkaSystem$Companion; public fun (Lcom/trendyol/stove/system/Stove;Lcom/trendyol/stove/kafka/KafkaContext;)V public final fun adminOperations (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public synthetic fun afterRun (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun afterRun (Lorg/springframework/context/ApplicationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun assertKafkaMessage-WPi__2c (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun beforeRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun close ()V public fun configuration ()Ljava/util/List; public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getGetInterceptor ()Lkotlin/jvm/functions/Function0; public fun getReportSystemName ()Ljava/lang/String; public fun getReporter ()Lcom/trendyol/stove/reporting/StoveReporter; public fun getStove ()Lcom/trendyol/stove/system/Stove; public final fun pause ()Lcom/trendyol/stove/kafka/KafkaSystem; public final fun publish (Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun publish$default (Lcom/trendyol/stove/kafka/KafkaSystem;Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun report (Ljava/lang/String;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBeConsumedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBeFailedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun shouldBePublishedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun snapshot ()Lcom/trendyol/stove/reporting/SystemSnapshot; public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun then ()Lcom/trendyol/stove/system/Stove; public final fun unpause ()Lcom/trendyol/stove/kafka/KafkaSystem; } public final class com/trendyol/stove/kafka/KafkaSystem$Companion { public final fun kafkaTemplate (Lcom/trendyol/stove/kafka/KafkaSystem;)Lorg/springframework/kafka/core/KafkaTemplate; } public class com/trendyol/stove/kafka/KafkaSystemOptions : com/trendyol/stove/database/migrations/SupportsMigrations, com/trendyol/stove/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/system/abstractions/SystemOptions { public static final field Companion Lcom/trendyol/stove/kafka/KafkaSystemOptions$Companion; public fun (Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/kafka/KafkaContainerOptions;Lcom/trendyol/stove/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Ljava/util/Map;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/kafka/KafkaContainerOptions;Lcom/trendyol/stove/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Ljava/util/Map;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getCleanup ()Lkotlin/jvm/functions/Function2; public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; public fun getContainerOptions ()Lcom/trendyol/stove/kafka/KafkaContainerOptions; public fun getFallbackSerde ()Lcom/trendyol/stove/kafka/FallbackTemplateSerde; public fun getMigrationCollection ()Lcom/trendyol/stove/database/migrations/MigrationCollection; public fun getOps ()Lcom/trendyol/stove/kafka/KafkaOps; public fun getPorts ()Ljava/util/List; public fun getProperties ()Ljava/util/Map; public fun getRegistry ()Ljava/lang/String; public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/database/migrations/SupportsMigrations; public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/kafka/KafkaSystemOptions; } public final class com/trendyol/stove/kafka/KafkaSystemOptions$Companion { public final fun getDEFAULT_KAFKA_PORTS ()Ljava/util/List; public final fun provided (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/kafka/KafkaOps;Ljava/util/Map;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/kafka/ProvidedKafkaSystemOptions; public static synthetic fun provided$default (Lcom/trendyol/stove/kafka/KafkaSystemOptions$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/kafka/KafkaOps;Ljava/util/Map;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/kafka/ProvidedKafkaSystemOptions; } public final class com/trendyol/stove/kafka/KafkaTemplateCompatibilityKt { public static final fun defaultKafkaOps ()Lcom/trendyol/stove/kafka/KafkaOps; public static final fun sendCompatible (Lorg/springframework/kafka/core/KafkaTemplate;Lorg/apache/kafka/clients/producer/ProducerRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/trendyol/stove/kafka/MessageProperties { public abstract fun getKey ()Ljava/lang/String; public abstract fun getMetadata ()Lcom/trendyol/stove/messaging/MessageMetadata; public abstract fun getPartition ()Ljava/lang/Integer; public abstract fun getTimestamp ()Ljava/lang/Long; public abstract fun getTopic ()Ljava/lang/String; public abstract fun getValue ()[B public abstract fun getValueAsString ()Ljava/lang/String; } public final class com/trendyol/stove/kafka/OptionsKt { public static final fun kafka-JSkDyPw (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun kafka-ypJx7X8 (Lcom/trendyol/stove/system/Stove;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/system/Stove; } public final class com/trendyol/stove/kafka/ProvidedKafkaSystemOptions : com/trendyol/stove/kafka/KafkaSystemOptions, com/trendyol/stove/system/abstractions/ProvidedSystemOptions { public fun (Lcom/trendyol/stove/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Ljava/util/Map;ZLkotlin/jvm/functions/Function1;)V public synthetic fun (Lcom/trendyol/stove/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Ljava/util/Map;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfig ()Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public fun getProvidedConfig ()Lcom/trendyol/stove/kafka/KafkaExposedConfiguration; public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/system/abstractions/ExposedConfiguration; public final fun getRunMigrations ()Z public fun getRunMigrationsForProvided ()Z } public class com/trendyol/stove/kafka/StoveKafkaContainer : org/testcontainers/kafka/ConfluentKafkaContainer, com/trendyol/stove/containers/StoveContainer { public fun (Lorg/testcontainers/utility/DockerImageName;)V public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/containers/ExecResult; public fun getContainerIdAccess ()Ljava/lang/String; public fun getDockerClientAccess ()Lkotlin/Lazy; public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; public fun inspect ()Lcom/trendyol/stove/containers/StoveContainerInspectInformation; public fun pause ()V public fun unpause ()V } public final class com/trendyol/stove/kafka/TestSystemKafkaInterceptor : org/springframework/kafka/listener/CompositeRecordInterceptor, org/springframework/kafka/support/ProducerListener { public fun (Lcom/trendyol/stove/serialization/StoveSerde;)V public fun failure (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Exception;Lorg/apache/kafka/clients/consumer/Consumer;)V public fun onError (Lorg/apache/kafka/clients/producer/ProducerRecord;Lorg/apache/kafka/clients/producer/RecordMetadata;Ljava/lang/Exception;)V public fun onSuccess (Lorg/apache/kafka/clients/producer/ProducerRecord;Lorg/apache/kafka/clients/producer/RecordMetadata;)V public fun success (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V } ================================================ FILE: starters/spring/stove-spring-kafka/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(libs.testcontainers.kafka) compileOnly(libs.spring.boot.kafka) implementation(libs.caffeine) implementation(libs.pprint) } dependencies { testImplementation(libs.kotest.runner.junit5) } ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/Caching.kt ================================================ package com.trendyol.stove.kafka import com.github.benmanes.caffeine.cache.* object Caching { fun of(): Cache = Caffeine.newBuilder().build() } ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/Extensions.kt ================================================ package com.trendyol.stove.kafka import arrow.core.Option import com.trendyol.stove.messaging.MessageMetadata import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.tracing.TraceContext import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.producer.ProducerRecord internal fun ProducerRecord.toMetadata(): MessageMetadata = MessageMetadata( this.topic(), this.key().toString(), this.headers().associate { h -> Pair(h.key(), String(h.value())) } ) internal fun ConsumerRecord.toMetadata(): MessageMetadata = MessageMetadata( this.topic(), this.key().toString(), this.headers().associate { h -> Pair(h.key(), String(h.value())) } ) internal fun ConsumerRecord.toStoveMessage( serde: StoveSerde ): StoveMessage.Consumed = StoveMessage.consumed( this.topic(), serializeIfNotYet(this.value(), serde), this.toMetadata(), this.partition(), this.key()?.toString() ?: "", this.timestamp(), this.offset() ) internal fun ConsumerRecord.toFailedStoveMessage( serde: StoveSerde, exception: Exception ): StoveMessage.Failed = StoveMessage.failed( this.topic(), serializeIfNotYet(this.value(), serde), this.toMetadata(), exception, this.partition(), this.key()?.toString() ?: "", this.timestamp() ) internal fun ProducerRecord.toStoveMessage( serde: StoveSerde ): StoveMessage.Published = StoveMessage.published( this.topic(), serializeIfNotYet(this.value(), serde), this.toMetadata(), this.partition(), this.key()?.toString() ?: "", this.timestamp() ) internal fun ProducerRecord.toFailedStoveMessage( serde: StoveSerde, exception: Exception ): StoveMessage.Failed = StoveMessage.failed( this.topic(), serializeIfNotYet(this.value(), serde), this.toMetadata(), exception, this.partition(), this.key()?.toString() ?: "", this.timestamp() ) private fun serializeIfNotYet( value: V, serde: StoveSerde ): ByteArray = when (value) { is ByteArray -> value else -> serde.serialize(value as Any) } internal fun (MutableMap).addTestCase(testCase: Option): MutableMap = if (this.containsKey("testCase")) this else testCase.map { this["testCase"] = it }.let { this } internal fun (MutableMap).addTraceContext( traceContext: TraceContext? ): MutableMap = traceContext?.let { this[TraceContext.TRACEPARENT_HEADER] = it.toTraceparent() this[TraceContext.STOVE_TEST_ID_HEADER] = it.testId this } ?: this ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaDsl.kt ================================================ package com.trendyol.stove.kafka @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) annotation class KafkaDsl ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaSystem.kt ================================================ package com.trendyol.stove.kafka import arrow.core.* import com.trendyol.stove.functional.* import com.trendyol.stove.messaging.* import com.trendyol.stove.reporting.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.tracing.TraceContext import kotlinx.coroutines.* import org.apache.kafka.clients.admin.* import org.apache.kafka.clients.producer.* import org.apache.kafka.common.header.internals.RecordHeader import org.slf4j.* import org.springframework.beans.factory.* import org.springframework.context.ApplicationContext import org.springframework.kafka.core.* import org.springframework.kafka.listener.RecordInterceptor import kotlin.reflect.KClass import kotlin.time.* import kotlin.time.Duration.Companion.seconds @KafkaDsl @Suppress("TooManyFunctions", "unused", "TooGenericExceptionCaught") class KafkaSystem( override val stove: Stove, private val context: KafkaContext ) : PluggedSystem, RunnableSystemWithContext, ExposesConfiguration, Reports { private val logger: Logger = LoggerFactory.getLogger(javaClass) private lateinit var applicationContext: ApplicationContext private lateinit var kafkaTemplate: KafkaTemplate private lateinit var exposedConfiguration: KafkaExposedConfiguration private lateinit var admin: Admin val getInterceptor: () -> TestSystemKafkaInterceptor = { applicationContext.getBean() } override fun snapshot(): SystemSnapshot { val currentTestId = reporter.currentTestId() val store = getInterceptor().getStore() val belongsToTest: (Map) -> Boolean = { headers -> val testId = headers[TraceContext.STOVE_TEST_ID_HEADER].toOption() testId.isNone() || testId.isSome { it.toString() == currentTestId } } val consumed = store.consumedRecords().filter { belongsToTest(it.metadata.headers) } val produced = store.producedRecords().filter { belongsToTest(it.metadata.headers) } val failed = store.failedRecords().filter { belongsToTest(it.metadata.headers) } return SystemSnapshot( system = reportSystemName, state = mapOf( "consumed" to consumed.map { it.toReportMap() }, "produced" to produced.map { it.toReportMap() }, "failed" to failed.map { it.toReportMap() } ), summary = listOf( "Consumed (this test)" to consumed.size, "Produced (this test)" to produced.size, "Failed (this test)" to failed.size ).joinToString("\n") { (label, count) -> "$label: $count" } ) } private fun StoveMessage.Consumed.toReportMap(): Map = buildMap { put("topic", topic) put("key", metadata.key) put("offset", offset ?: 0L) put("headers", metadata.headers) put("value", String(value)) } private fun StoveMessage.Published.toReportMap(): Map = buildMap { put("topic", topic) put("key", metadata.key) put("headers", metadata.headers) put("value", String(value)) } private fun StoveMessage.Failed.toReportMap(): Map = buildMap { put("topic", topic) put("key", metadata.key) put("headers", metadata.headers) put("reason", reason.message ?: "Unknown error") put("value", String(value)) } private val state: StateStorage = stove.createStateStorage() /** * Publishes a message to the given topic. * The message will be serialized using the provided serde. * * If the KafkaTemplate of the application is desired to be used, then [BridgeSystem] functionality can be used. * For example: * ```kotlin * stove { * using> { * this.send(ProducerRecord("topic", "message")) * } * } * ``` * [BridgeSystem] should be enabled while configuring the [TestSystem]. * @param topic The topic to publish the message to. * @param message The message to publish. * @param key The key of the message. * @param partition The partition to publish the message to. * @param headers The headers of the message. * @param serde The serde to serialize the message. * @param testCase The test case of the message. * @return KafkaSystem */ suspend fun publish( topic: String, message: Any, key: Option = None, partition: Option = None, headers: Map = mapOf(), serde: Option> = None, testCase: Option = None ): KafkaSystem { report( action = "Publish to '$topic'", input = arrow.core.Some(message), metadata = mapOf( "key" to (key.getOrNull() ?: ""), "headers" to headers, "partition" to (partition.getOrNull()?.toString() ?: "") ) ) { val record = ProducerRecord( topic, partition.getOrNull(), key.getOrNull(), message, headers .toMutableMap() .addTestCase(testCase) .addTraceContext(TraceContext.current()) .map { RecordHeader(it.key, it.value.toByteArray()) } ) context.options.ops.send(kafkaTemplate, record) } return this } /** * Admin operations for Kafka. */ suspend fun adminOperations(block: suspend Admin.() -> Unit) = block(admin) /** * Asserts that a message is consumed. */ suspend inline fun shouldBeConsumed( atLeastIn: Duration = 5.seconds, crossinline condition: ObservedMessage.() -> Boolean ): KafkaSystem = assertKafkaMessage( assertionName = "shouldBeConsumed", typeName = T::class.simpleName ?: "Unknown", timeout = atLeastIn, expected = "Message matching condition within $atLeastIn" ) { onMatch -> shouldBeConsumedInternal(T::class, atLeastIn) { parsed -> parsed.message.isSome { o -> val result = condition(ObservedMessage(o, parsed.metadata)) if (result) onMatch(o) result } } } /** * Asserts that a message is failed. */ suspend inline fun shouldBeFailed( atLeastIn: Duration = 5.seconds, crossinline condition: FailedObservedMessage.() -> Boolean ): KafkaSystem = assertKafkaMessage( assertionName = "shouldBeFailed", typeName = T::class.simpleName ?: "Unknown", timeout = atLeastIn, expected = "Failed message matching condition within $atLeastIn" ) { onMatch -> shouldBeFailedInternal(T::class, atLeastIn) { parsed -> parsed as FailedParsedMessage parsed.message.isSome { o -> val result = condition(FailedObservedMessage(o, parsed.metadata, parsed.reason)) if (result) onMatch(o) result } } } /** * Asserts that a message is published. */ suspend inline fun shouldBePublished( atLeastIn: Duration = 5.seconds, crossinline condition: ObservedMessage.() -> Boolean ): KafkaSystem = assertKafkaMessage( assertionName = "shouldBePublished", typeName = T::class.simpleName ?: "Unknown", timeout = atLeastIn, expected = "Message matching condition within $atLeastIn" ) { onMatch -> shouldBePublishedInternal(T::class, atLeastIn) { parsed -> parsed.message.isSome { o -> val result = condition(ObservedMessage(o, parsed.metadata)) if (result) onMatch(o) result } } } /** * Helper to reduce boilerplate in Kafka assertion methods. * Handles try-catch, recording, and re-throwing. */ @PublishedApi internal suspend inline fun assertKafkaMessage( assertionName: String, typeName: String, timeout: Duration, expected: String, crossinline block: suspend ((T) -> Unit) -> Unit ): KafkaSystem { var matchedMessage: T? = null val result = runCatching { coroutineScope { block { matchedMessage = it } } } val failure = result.exceptionOrNull()?.let { e -> e as? AssertionError ?: AssertionError( "Expected $assertionName<$typeName> matching condition within $timeout, but none was found", e ) } if (result.isSuccess) { reporter.record( ReportEntry.success( system = reportSystemName, testId = reporter.currentTestId(), action = "$assertionName<$typeName>", output = matchedMessage.toOption(), metadata = mapOf("timeout" to timeout.toString()) ) ) } else { reporter.record( ReportEntry.failure( system = reportSystemName, testId = reporter.currentTestId(), action = "$assertionName<$typeName>", error = failure?.message ?: "No matching message found", expected = expected.some(), actual = (matchedMessage ?: "No matching message found").some() ) ) } failure?.let { throw it } return this } @PublishedApi internal suspend fun shouldBeConsumedInternal( clazz: KClass, atLeastIn: Duration, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { getInterceptor().waitUntilConsumed(atLeastIn, clazz, condition) } @PublishedApi internal suspend fun shouldBeFailedInternal( clazz: KClass, atLeastIn: Duration, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { getInterceptor().waitUntilFailed(atLeastIn, clazz, condition) } @PublishedApi internal suspend fun shouldBePublishedInternal( clazz: KClass, atLeastIn: Duration, condition: (message: ParsedMessage) -> Boolean ): Unit = coroutineScope { getInterceptor().waitUntilPublished(atLeastIn, clazz, condition) } override fun configuration(): List = context.options.configureExposedConfiguration(exposedConfiguration) /** * Pauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return KafkaSystem */ fun pause(): KafkaSystem = withContainerOrWarn("pause") { it.pause() } /** * Unpauses the container. Use with care, as it will pause the container which might affect other tests. * This operation is not supported when using a provided instance. * @return KafkaSystem */ fun unpause(): KafkaSystem = withContainerOrWarn("unpause") { it.unpause() } override suspend fun stop(): Unit = whenContainer { it.stop() } override fun close(): Unit = runBlocking { Try { context.options.cleanup(admin) kafkaTemplate.destroy() executeWithReuseCheck { stop() } }.recover { logger.warn("got an error while closing KafkaSystem", it) } } override suspend fun beforeRun() = Unit override suspend fun run() { exposedConfiguration = obtainExposedConfiguration() } override suspend fun afterRun(context: ApplicationContext) { applicationContext = context checkIfInterceptorConfiguredProperly(context) kafkaTemplate = createKafkaTemplate(context, exposedConfiguration) admin = createAdminClient(exposedConfiguration) runMigrationsIfNeeded() } private suspend fun obtainExposedConfiguration(): KafkaExposedConfiguration = when { context.options is ProvidedKafkaSystemOptions -> context.options.config context.runtime is StoveKafkaContainer -> startKafkaContainer(context.runtime) else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private suspend fun startKafkaContainer(container: StoveKafkaContainer): KafkaExposedConfiguration = state.capture { container.start() KafkaExposedConfiguration(container.bootstrapServers) } private suspend fun runMigrationsIfNeeded() { if (shouldRunMigrations()) { context.options.migrationCollection.run(KafkaMigrationContext(admin, context.options)) } } private fun shouldRunMigrations(): Boolean = when { context.options is ProvidedKafkaSystemOptions -> context.options.runMigrations context.runtime is StoveKafkaContainer -> !state.isSubsequentRun() || stove.runMigrationsAlways else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") } private fun createAdminClient( exposedConfiguration: KafkaExposedConfiguration ): Admin = Admin.create( buildMap { putAll(context.options.properties) put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, exposedConfiguration.bootstrapServers) put(AdminClientConfig.CLIENT_ID_CONFIG, "stove-kafka-admin-client") } ) private fun createKafkaTemplate( context: ApplicationContext, exposedConfiguration: KafkaExposedConfiguration ): KafkaTemplate { val kafkaTemplates: Map> = context.getBeansOfType() return kafkaTemplates .values .onEach { it.setProducerListener(getInterceptor()) it.setCloseTimeout(1.seconds.toJavaDuration()) }.firstOrNone { safeContains(it, exposedConfiguration) } .getOrElse { logger.warn("No KafkaTemplate found for the configured bootstrap servers, using a fallback KafkaTemplate") createFallbackTemplate(exposedConfiguration) } } @Suppress("UNCHECKED_CAST") private fun safeContains( kafkaTemplate: KafkaTemplate, exposedConfiguration: KafkaExposedConfiguration ): Boolean = kafkaTemplate.producerFactory.configurationProperties[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] .toOption() .map { when (it) { is String -> it is Iterable<*> -> (it as Iterable).joinToString(",") else -> it.toString() } }.isSome { it.contains(exposedConfiguration.bootstrapServers) } private fun createFallbackTemplate(exposedConfiguration: KafkaExposedConfiguration): KafkaTemplate { val producerFactory = DefaultKafkaProducerFactory( buildMap { putAll(context.options.properties) put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, exposedConfiguration.bootstrapServers) put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, context.options.fallbackSerde.keySerializer::class.java) put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, context.options.fallbackSerde.valueSerializer::class.java) } ) val fallbackTemplate = KafkaTemplate(producerFactory).also { it.setProducerListener(getInterceptor()) it.setCloseTimeout(1.seconds.toJavaDuration()) } return fallbackTemplate } private fun checkIfInterceptorConfiguredProperly(context: ApplicationContext) { val interceptors: Map> = context.getBeansOfType() fun stoveInterceptionPresent(): Boolean = interceptors.values.any { it is TestSystemKafkaInterceptor<*, *> } if (!stoveInterceptionPresent()) { throw AssertionError( "Kafka interceptor is not an instance of TestSystemKafkaInterceptor, " + "please make sure that you have configured the Stove Kafka interceptor in your Spring Application properly." + "You can use stoveSpringRegistrar to add the interceptor to your Spring Application: " + """ TestAppRunner.run(params) { addInitializers( stoveSpringRegistrar { bean>(isPrimary = true) } ) } """.trimIndent() ) } } private inline fun withContainerOrWarn( operation: String, action: (StoveKafkaContainer) -> Unit ): KafkaSystem = when (val runtime = context.runtime) { is StoveKafkaContainer -> { action(runtime) this } is ProvidedRuntime -> { logger.warn("$operation() is not supported when using a provided instance") this } else -> { throw UnsupportedOperationException("Unsupported runtime type: ${runtime::class}") } } private inline fun whenContainer(action: (StoveKafkaContainer) -> Unit) { if (context.runtime is StoveKafkaContainer) { action(context.runtime) } } companion object { /** * Exposes the [KafkaTemplate] to the [KafkaSystem]. * Use this for advanced Kafka operations not covered by the DSL. */ fun KafkaSystem.kafkaTemplate(): KafkaTemplate = kafkaTemplate } } ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/KafkaTemplateCompatibility.kt ================================================ package com.trendyol.stove.kafka import kotlinx.coroutines.future.await import org.apache.kafka.clients.producer.ProducerRecord import org.springframework.kafka.core.KafkaTemplate import java.util.concurrent.CompletableFuture /** * Sends a [ProducerRecord] using the [KafkaTemplate] and waits for the result. * This method is used to send a record to Kafka in a compatible way with different versions of Spring Kafka. * * Supports: * - Spring Kafka 2.x (ListenableFuture) * - Spring Kafka 3.x (CompletableFuture with ListenableFuture backward compatibility) * - Spring Kafka 4.x (CompletableFuture only) * * Uses reflection to avoid compile-time dependency on ListenableFuture which doesn't exist in Spring 4.x. */ suspend fun KafkaTemplate<*, *>.sendCompatible(record: ProducerRecord<*, *>) { val method = this::class.java.getDeclaredMethod("send", ProducerRecord::class.java).apply { isAccessible = true } val returnType = method.returnType val result = method.invoke(this, record) when { CompletableFuture::class.java.isAssignableFrom(returnType) -> { (result as CompletableFuture<*>).await() } returnType.name == "org.springframework.util.concurrent.ListenableFuture" -> { // Use reflection to call completable() method for Spring Kafka 2.x/3.x ListenableFuture val completableMethod = result.javaClass.getMethod("completable") (completableMethod.invoke(result) as CompletableFuture<*>).await() } else -> { error("Unsupported return type for KafkaTemplate.send method: $returnType") } } } /** * Default KafkaOps that uses the compatible send method. * Works with Spring Kafka 2.x, 3.x, and 4.x. */ fun defaultKafkaOps(): KafkaOps = KafkaOps( send = { kafkaTemplate, record -> kafkaTemplate.sendCompatible(record) } ) ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/MessageStore.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.messaging.Failure import io.exoquery.pprint import java.util.* internal class MessageStore { private val consumed = Caching.of() private val produced = Caching.of() private val failures = Caching.of>() fun record(record: StoveMessage.Consumed) { consumed.put(UUID.randomUUID(), record) } fun record(record: StoveMessage.Published) { produced.put(UUID.randomUUID(), record) } fun record(failure: Failure) { failures.put(UUID.randomUUID(), failure) } fun consumedRecords(): List = consumed.asMap().values.toList() fun producedRecords(): List = produced.asMap().values.toList() fun failedRecords(): List = failures .asMap() .values .map { failure -> failure.message.actual } .toList() override fun toString(): String = """ |Consumed: ${pprint(consumedRecords().map { it.copy(value = ByteArray(0)) })} |Published: ${pprint(producedRecords().map { it.copy(value = ByteArray(0)) })} |Failed: ${pprint(failedRecords().map { it.copy(value = ByteArray(0)) })} """.trimIndent().trimMargin() } ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/Options.kt ================================================ package com.trendyol.stove.kafka import arrow.core.getOrElse import com.trendyol.stove.containers.* import com.trendyol.stove.database.migrations.* import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.* import com.trendyol.stove.system.annotations.StoveDsl import org.apache.kafka.clients.admin.Admin import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.common.serialization.* import org.springframework.kafka.core.KafkaTemplate import org.testcontainers.kafka.ConfluentKafkaContainer import org.testcontainers.utility.DockerImageName open class StoveKafkaContainer( override val imageNameAccess: DockerImageName ) : ConfluentKafkaContainer(imageNameAccess), StoveContainer @StoveDsl data class KafkaExposedConfiguration( val bootstrapServers: String ) : ExposedConfiguration @StoveDsl data class KafkaContainerOptions( override val registry: String = DEFAULT_REGISTRY, override val image: String = "confluentinc/cp-kafka", override val tag: String = "latest", override val compatibleSubstitute: String? = null, override val useContainerFn: UseContainerFn = { StoveKafkaContainer(it) }, override val containerFn: ContainerFn = { } ) : ContainerOptions /** * Operations for Kafka. It is used to customize the operations of Kafka. * The reason why this exists is to provide a way to interact with lower versions of Spring-Kafka dependencies. */ data class KafkaOps( val send: suspend ( KafkaTemplate<*, *>, ProducerRecord<*, *> ) -> Unit ) data class FallbackTemplateSerde( val keySerializer: Serializer<*> = StringSerializer(), val valueSerializer: Serializer<*> = StringSerializer() ) /** * Context provided to Kafka migrations. * Contains the Admin client and options for performing setup operations. * * @property admin The Kafka Admin client for managing topics, ACLs, etc. * @property options The Kafka system options */ @StoveDsl data class KafkaMigrationContext( val admin: Admin, val options: KafkaSystemOptions ) /** * Options for configuring the Spring Kafka system in container mode. */ @StoveDsl open class KafkaSystemOptions( /** * The registry of the Kafka image. The default value is `DEFAULT_REGISTRY`. */ open val registry: String = DEFAULT_REGISTRY, /** * The ports of the Kafka container. The default value is `DEFAULT_KAFKA_PORTS`. */ open val ports: List = DEFAULT_KAFKA_PORTS, /** * The fallback serde for Kafka. It is used to serialize and deserialize the messages before sending them to Kafka. * If no [KafkaTemplate] is provided, it will be used to create a new [KafkaTemplate]. * Most of the time you won't need this. */ open val fallbackSerde: FallbackTemplateSerde = FallbackTemplateSerde(), /** * Container options for Kafka. */ open val containerOptions: KafkaContainerOptions = KafkaContainerOptions(), /** * Operations for Kafka. It is used to customize the operations of Kafka. * Defaults to [defaultKafkaOps] which works with Spring Kafka 2.x, 3.x, and 4.x. * @see KafkaOps * @see defaultKafkaOps */ open val ops: KafkaOps = defaultKafkaOps(), /** * A suspend function to clean up data after tests complete. */ open val cleanup: suspend (Admin) -> Unit = {}, /** * Additional Kafka client properties applied to all internal clients (admin, fallback producer). * Use this to pass security configs (SASL_SSL, truststore, etc.) when connecting to a secured cluster. * * Example: * ```kotlin * properties = mapOf( * "security.protocol" to "SASL_SSL", * "sasl.mechanism" to "PLAIN", * "sasl.jaas.config" to "...PlainLoginModule required username=\"user\" password=\"pass\";" * ) * ``` */ open val properties: Map = emptyMap(), /** * The configuration of the Kafka settings that is exposed to the Application Under Test(AUT). */ override val configureExposedConfiguration: (KafkaExposedConfiguration) -> List ) : SystemOptions, ConfiguresExposedConfiguration, SupportsMigrations { override val migrationCollection: MigrationCollection = MigrationCollection() companion object { val DEFAULT_KAFKA_PORTS = listOf(9092, 9093) /** * Creates options configured to use an externally provided Kafka instance * instead of a testcontainer. * * @param bootstrapServers The Kafka bootstrap servers (e.g., "localhost:9092") * @param registry The registry for the container (not used for provided instances) * @param ports The ports for the container (not used for provided instances) * @param fallbackSerde The fallback serde for serialization * @param ops Operations for Kafka * @param runMigrations Whether to run migrations on the external instance (default: true) * @param cleanup A suspend function to clean up data after tests complete * @param configureExposedConfiguration Function to map exposed config to application properties */ fun provided( bootstrapServers: String, registry: String = DEFAULT_REGISTRY, ports: List = DEFAULT_KAFKA_PORTS, fallbackSerde: FallbackTemplateSerde = FallbackTemplateSerde(), ops: KafkaOps = defaultKafkaOps(), properties: Map = emptyMap(), runMigrations: Boolean = true, cleanup: suspend (Admin) -> Unit = {}, configureExposedConfiguration: (KafkaExposedConfiguration) -> List ): ProvidedKafkaSystemOptions = ProvidedKafkaSystemOptions( config = KafkaExposedConfiguration(bootstrapServers = bootstrapServers), registry = registry, ports = ports, fallbackSerde = fallbackSerde, ops = ops, properties = properties, runMigrations = runMigrations, cleanup = cleanup, configureExposedConfiguration = configureExposedConfiguration ) } } /** * Options for using an externally provided Kafka instance. * This class holds the configuration for the external instance directly (non-nullable). */ @StoveDsl class ProvidedKafkaSystemOptions( /** * The configuration for the provided Kafka instance. */ val config: KafkaExposedConfiguration, registry: String = DEFAULT_REGISTRY, ports: List = DEFAULT_KAFKA_PORTS, fallbackSerde: FallbackTemplateSerde = FallbackTemplateSerde(), ops: KafkaOps = defaultKafkaOps(), cleanup: suspend (Admin) -> Unit = {}, properties: Map = emptyMap(), /** * Whether to run migrations on the external instance. */ val runMigrations: Boolean = true, configureExposedConfiguration: (KafkaExposedConfiguration) -> List ) : KafkaSystemOptions( registry = registry, ports = ports, fallbackSerde = fallbackSerde, containerOptions = KafkaContainerOptions(), ops = ops, cleanup = cleanup, properties = properties, configureExposedConfiguration = configureExposedConfiguration ), ProvidedSystemOptions { override val providedConfig: KafkaExposedConfiguration = config override val runMigrationsForProvided: Boolean = runMigrations } @StoveDsl data class KafkaContext( val runtime: SystemRuntime, val options: KafkaSystemOptions ) internal fun Stove.withKafka( options: KafkaSystemOptions, runtime: SystemRuntime ): Stove { getOrRegister(KafkaSystem(this, KafkaContext(runtime, options))) return this } internal fun Stove.kafka(): KafkaSystem = getOrNone().getOrElse { throw SystemNotRegisteredException(KafkaSystem::class) } /** * Configures Spring Kafka system. * * For container-based setup: * ```kotlin * kafka { * KafkaSystemOptions( * cleanup = { admin -> admin.deleteTopics(...).all().get() }, * configureExposedConfiguration = { cfg -> listOf(...) } * ).migrations { * register() * } * } * ``` * * For provided (external) instance: * ```kotlin * kafka { * KafkaSystemOptions.provided( * bootstrapServers = "localhost:9092", * runMigrations = true, * cleanup = { admin -> admin.deleteTopics(...).all().get() }, * configureExposedConfiguration = { cfg -> listOf(...) } * ).migrations { * register() * } * } * ``` */ fun WithDsl.kafka( configure: () -> KafkaSystemOptions ): Stove { SpringKafkaVersionCheck.ensureSpringKafkaAvailable() val options = configure() val runtime: SystemRuntime = if (options is ProvidedKafkaSystemOptions) { ProvidedRuntime } else { withProvidedRegistry( options.containerOptions.imageWithTag, registry = options.registry, compatibleSubstitute = options.containerOptions.compatibleSubstitute ) { options.containerOptions .useContainerFn(it) .withExposedPorts(*options.ports.toTypedArray()) .withReuse(stove.keepDependenciesRunning) .let { c -> c as StoveKafkaContainer } .apply(options.containerOptions.containerFn) } } return stove.withKafka(options, runtime) } suspend fun ValidationDsl.kafka(validation: suspend KafkaSystem.() -> Unit): Unit = validation(this.stove.kafka()) ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/SpringKafkaVersionCheck.kt ================================================ package com.trendyol.stove.kafka /** * Utility object to check Spring Kafka availability at runtime. * Since Spring Kafka is a `compileOnly` dependency, users must bring their own version. */ internal object SpringKafkaVersionCheck { private const val KAFKA_TEMPLATE_CLASS = "org.springframework.kafka.core.KafkaTemplate" /** * Checks if Spring Kafka is available on the classpath. * @throws IllegalStateException if Spring Kafka is not found */ fun ensureSpringKafkaAvailable() { try { Class.forName(KAFKA_TEMPLATE_CLASS) } catch (e: ClassNotFoundException) { throw IllegalStateException( """ | |═══════════════════════════════════════════════════════════════════════════════ | Spring Kafka Not Found on Classpath! |═══════════════════════════════════════════════════════════════════════════════ | | stove-spring-testing-e2e-kafka requires Spring Kafka to be on your classpath. | Spring Kafka is declared as a 'compileOnly' dependency, so you must add it | to your project. | | Add one of the following to your build.gradle.kts: | | For Spring Boot 2.x: | testImplementation("org.springframework.kafka:spring-kafka:2.9.x") | // or use the starter: | testImplementation("org.springframework.boot:spring-boot-starter-kafka:2.7.x") | | For Spring Boot 3.x: | testImplementation("org.springframework.kafka:spring-kafka:3.x.x") | // or use the starter: | testImplementation("org.springframework.boot:spring-boot-starter-kafka:3.x.x") | | For Spring Boot 4.x: | testImplementation("org.springframework.kafka:spring-kafka:4.x.x") | // or use the starter: | testImplementation("org.springframework.boot:spring-boot-starter-kafka:4.x.x") | |═══════════════════════════════════════════════════════════════════════════════ """.trimMargin(), e ) } } } ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/StoveMessage.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.messaging.MessageMetadata import io.exoquery.pprint sealed interface MessageProperties { val topic: String val value: ByteArray val valueAsString: String val metadata: MessageMetadata val partition: Int? val key: String? val timestamp: Long? } internal sealed class StoveMessage : MessageProperties { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as StoveMessage if (topic != other.topic) return false if (!value.contentEquals(other.value)) return false if (metadata != other.metadata) return false if (partition != other.partition) return false if (key != other.key) return false if (timestamp != other.timestamp) return false return true } override fun hashCode(): Int { var result = topic.hashCode() result = 31 * result + value.contentHashCode() result = 31 * result + metadata.hashCode() result = 31 * result + (partition ?: 0) result = 31 * result + (key?.hashCode() ?: 0) result = 31 * result + (timestamp?.hashCode() ?: 0) return result } data class Consumed( override val topic: String, override val value: ByteArray, override val metadata: MessageMetadata, override val partition: Int?, override val key: String?, override val timestamp: Long?, val offset: Long?, override val valueAsString: String = String(value) ) : StoveMessage() { override fun hashCode(): Int = super.hashCode() + offset.hashCode() override fun equals(other: Any?): Boolean = super.equals(other) && other is Consumed && offset == other.offset override fun toString(): String = pprint(this.copy(value = ByteArray(0))).toString() } data class Published( override val topic: String, override val value: ByteArray, override val metadata: MessageMetadata, override val partition: Int?, override val key: String?, override val timestamp: Long?, override val valueAsString: String = String(value) ) : StoveMessage() { override fun hashCode(): Int = super.hashCode() override fun equals(other: Any?): Boolean = super.equals(other) override fun toString(): String = pprint(this.copy(value = ByteArray(0))).toString() } data class Failed( override val topic: String, override val value: ByteArray, override val metadata: MessageMetadata, override val partition: Int?, override val key: String?, override val timestamp: Long?, val reason: Throwable, override val valueAsString: String = String(value) ) : StoveMessage() { override fun hashCode(): Int = super.hashCode() + reason.hashCode() override fun equals(other: Any?): Boolean = super.equals(other) && other is Failed && reason == other.reason override fun toString(): String = pprint(this.copy(value = ByteArray(0))).toString() } companion object { fun consumed( topic: String, value: ByteArray, metadata: MessageMetadata, partition: Int? = null, key: String? = null, timestamp: Long? = null, offset: Long? = null ): Consumed = Consumed(topic, value, metadata, partition, key, timestamp, offset) fun published( topic: String, value: ByteArray, metadata: MessageMetadata, partition: Int? = null, key: String? = null, timestamp: Long? = null ): Published = Published(topic, value, metadata, partition, key, timestamp) fun failed( topic: String, value: ByteArray, metadata: MessageMetadata, reason: Throwable, partition: Int? = null, key: String? = null, timestamp: Long? = null ): Failed = Failed(topic, value, metadata, partition, key, timestamp, reason) } } ================================================ FILE: starters/spring/stove-spring-kafka/src/main/kotlin/com/trendyol/stove/kafka/TestSystemKafkaInterceptor.kt ================================================ package com.trendyol.stove.kafka import arrow.core.toOption import com.trendyol.stove.messaging.* import com.trendyol.stove.serialization.StoveSerde import kotlinx.coroutines.* import org.apache.kafka.clients.consumer.* import org.apache.kafka.clients.producer.* import org.slf4j.* import org.springframework.kafka.listener.* import org.springframework.kafka.support.ProducerListener import kotlin.reflect.KClass import kotlin.time.Duration /** * This is the main actor between your Kafka Spring Boot application and the test system. * It is responsible for intercepting the messages that are produced and consumed by the application. * It also provides a way to wait until a message is consumed or produced. * * @param serde The serializer/deserializer that will be used to serialize/deserialize the messages. * It is important to use the same serde that is used in the application. * For example, if the application uses Avro, then you should use Avro serde here. * Target of the serialization is ByteArray, so the serde should be able to serialize the message to ByteArray. */ class TestSystemKafkaInterceptor( private val serde: StoveSerde ) : CompositeRecordInterceptor(), ProducerListener { private val logger: Logger = LoggerFactory.getLogger(javaClass) private val store = MessageStore() /** * Get access to the message store for reporting purposes. */ internal fun getStore(): MessageStore = store override fun onSuccess( record: ProducerRecord, recordMetadata: RecordMetadata ) { val message = record.toStoveMessage(serde) store.record(message) logger.info("Successfully produced:\n{}", message) } override fun onError( record: ProducerRecord, recordMetadata: RecordMetadata?, exception: Exception ) { val underlyingReason = extractCause(exception) val message = record.toFailedStoveMessage(serde, underlyingReason) store.record(Failure(ObservedMessage(message, record.toMetadata()), underlyingReason)) logger.error("Error while producing:\n{}", message, exception) } override fun success(record: ConsumerRecord, consumer: Consumer) { val message = record.toStoveMessage(serde) store.record(message) logger.info("Successfully consumed:\n{}", message) } override fun failure( record: ConsumerRecord, exception: Exception, consumer: Consumer ) { val underlyingReason = extractCause(exception) val message = record.toFailedStoveMessage(serde, underlyingReason) store.record(Failure(ObservedMessage(message, record.toMetadata()), underlyingReason)) logger.error("Error while consuming:\n{}", message, exception) } internal suspend fun waitUntilConsumed( atLeastIn: Duration, clazz: KClass, condition: (metadata: ParsedMessage) -> Boolean ) { val getRecords = { store.consumedRecords() } getRecords.waitUntilConditionMet(atLeastIn, "While expecting the consume of '${clazz.java.simpleName}'") { val outcome = deserializeCatching(it.value, clazz) outcome.isSuccess && condition(SuccessfulParsedMessage(outcome.getOrNull().toOption(), it.metadata)) } throwIfFailed(clazz, condition) } internal suspend fun waitUntilFailed( atLeastIn: Duration, clazz: KClass, condition: (metadata: ParsedMessage) -> Boolean ) { val getRecords = { store.failedRecords() } getRecords.waitUntilConditionMet(atLeastIn, "While expecting the failure of '${clazz.java.simpleName}'") { val outcome = deserializeCatching(it.value, clazz) outcome.isSuccess && condition(FailedParsedMessage(outcome.getOrNull().toOption(), it.metadata, it.reason)) } throwIfSucceeded(clazz, condition) } internal suspend fun waitUntilPublished( atLeastIn: Duration, clazz: KClass, condition: (message: ParsedMessage) -> Boolean ) { val getRecords = { store.producedRecords() } getRecords.waitUntilConditionMet(atLeastIn, "While expecting the publish of '${clazz.java.simpleName}'") { val outcome = deserializeCatching(it.value, clazz) outcome.isSuccess && condition(SuccessfulParsedMessage(outcome.getOrNull().toOption(), it.metadata)) } } private fun extractCause( listenerException: Exception ): Exception = when (listenerException) { is ListenerExecutionFailedException -> { listenerException.cause ?: AssertionError("No cause found: Listener was not able to capture the cause") } else -> { listenerException } } as Exception private fun deserializeCatching( value: ByteArray, clazz: KClass ): Result = runCatching { serde.deserialize(value, clazz.java) } .onFailure { logger.debug("[Stove#deserializeCatching] Error while deserializing: '{}'", String(value), it) } private fun throwIfFailed( clazz: KClass, selector: (message: ParsedMessage) -> Boolean ) = store .failedRecords() .filter { selector( FailedParsedMessage( deserializeCatching(it.value, clazz).getOrNull().toOption(), MessageMetadata(it.metadata.topic, it.metadata.key, it.metadata.headers), it.reason ) ) }.forEach { throw AssertionError( "Message was expected to be consumed successfully, but failed: $it \n ${dumpMessages()}" ) } private fun throwIfSucceeded( clazz: KClass, selector: (ParsedMessage) -> Boolean ): Unit = store .consumedRecords() .filter { record -> selector( FailedParsedMessage( deserializeCatching(record.value, clazz).getOrNull().toOption(), record.metadata, getExceptionFor(clazz, selector) ) ) }.forEach { throw AssertionError("Expected to fail but succeeded: $it") } private fun getExceptionFor( clazz: KClass, selector: (message: FailedParsedMessage) -> Boolean ): Throwable = store .failedRecords() .first { selector(FailedParsedMessage(deserializeCatching(it.value, clazz).getOrNull().toOption(), it.metadata, it.reason)) }.reason private suspend fun (() -> Collection).waitUntilConditionMet( duration: Duration, subject: String, delayMs: Long = 50L, condition: (T) -> Boolean ): Collection = runCatching { val collectionFunc = this withTimeout(duration) { while (!collectionFunc().any { condition(it) }) delay(delayMs) } collectionFunc().filter { condition(it) } }.fold( onSuccess = { it }, onFailure = { throw AssertionError("GOT A TIMEOUT: $subject. ${dumpMessages()}") } ) private fun dumpMessages(): String = "Messages so far:\n$store" } ================================================ FILE: starters/spring/stove-spring-kafka/src/test/kotlin/com/trendyol/stove/kafka/CachingTests.kt ================================================ package com.trendyol.stove.kafka import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe class CachingTests : FunSpec({ test("should create cache that stores and retrieves values") { val cache = Caching.of() cache.put("key1", 100) cache.put("key2", 200) cache.getIfPresent("key1") shouldBe 100 cache.getIfPresent("key2") shouldBe 200 } test("should return null for non-existent keys") { val cache = Caching.of() cache.getIfPresent("non-existent").shouldBeNull() } test("should overwrite existing values") { val cache = Caching.of() cache.put("key", "original") cache.put("key", "updated") cache.getIfPresent("key") shouldBe "updated" } test("should support complex key types") { data class ComplexKey( val id: Int, val name: String ) val cache = Caching.of() val key = ComplexKey(1, "test") cache.put(key, "value") cache.getIfPresent(key) shouldBe "value" } test("should support any value types") { data class ComplexValue( val data: List, val count: Int ) val cache = Caching.of() val value = ComplexValue(listOf("a", "b", "c"), 3) cache.put("key", value) cache.getIfPresent("key") shouldBe value } test("asMap should return all cached entries") { val cache = Caching.of() cache.put("one", 1) cache.put("two", 2) cache.put("three", 3) val map = cache.asMap() map.size shouldBe 3 map["one"] shouldBe 1 map["two"] shouldBe 2 map["three"] shouldBe 3 } test("should handle invalidation") { val cache = Caching.of() cache.put("key", "value") cache.invalidate("key") cache.getIfPresent("key").shouldBeNull() } }) ================================================ FILE: starters/spring/stove-spring-kafka/src/test/kotlin/com/trendyol/stove/kafka/ExtensionsTests.kt ================================================ package com.trendyol.stove.kafka import arrow.core.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class ExtensionsTests : FunSpec({ test("addTestCase should add testCase to map when not present") { val map = mutableMapOf("existingKey" to "existingValue") map.addTestCase("myTestCase".some()) map["testCase"] shouldBe "myTestCase" map["existingKey"] shouldBe "existingValue" } test("addTestCase should not overwrite existing testCase") { val map = mutableMapOf("testCase" to "existingTestCase") map.addTestCase("newTestCase".some()) map["testCase"] shouldBe "existingTestCase" } test("addTestCase should do nothing when Option is None") { val map = mutableMapOf("key" to "value") map.addTestCase(none()) map.containsKey("testCase") shouldBe false map["key"] shouldBe "value" } test("addTestCase should return the same map") { val map = mutableMapOf() val result = map.addTestCase("test".some()) result shouldBe map } test("addTestCase with empty map and Some value") { val map = mutableMapOf() map.addTestCase("firstTest".some()) map.size shouldBe 1 map["testCase"] shouldBe "firstTest" } test("addTestCase should preserve all existing entries") { val map = mutableMapOf( "header1" to "value1", "header2" to "value2", "header3" to "value3" ) map.addTestCase("myTest".some()) map.size shouldBe 4 map["header1"] shouldBe "value1" map["header2"] shouldBe "value2" map["header3"] shouldBe "value3" map["testCase"] shouldBe "myTest" } }) ================================================ FILE: starters/spring/stove-spring-kafka/src/test/kotlin/com/trendyol/stove/kafka/KafkaOptionsTest.kt ================================================ package com.trendyol.stove.kafka import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe class KafkaOptionsTest : FunSpec({ test("provided options should expose config and runMigrations flag") { val kafkaAvailable = runCatching { Class.forName("org.apache.kafka.common.serialization.StringSerializer") }.isSuccess if (!kafkaAvailable) return@test val options = KafkaSystemOptions.provided( bootstrapServers = "localhost:9092", runMigrations = false, configureExposedConfiguration = { listOf("bootstrap=${it.bootstrapServers}") } ) options.providedConfig.bootstrapServers shouldBe "localhost:9092" options.runMigrationsForProvided shouldBe false options.configureExposedConfiguration(options.providedConfig) shouldBe listOf("bootstrap=localhost:9092") } test("default kafka ports should include 9092 and 9093") { KafkaSystemOptions.DEFAULT_KAFKA_PORTS shouldBe listOf(9092, 9093) } }) ================================================ FILE: starters/spring/stove-spring-kafka/src/test/kotlin/com/trendyol/stove/kafka/MessageStoreTests.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.messaging.* import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.* import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain class MessageStoreTests : FunSpec({ test("should record and retrieve consumed messages") { val store = MessageStore() val metadata = MessageMetadata("test-topic", "key", emptyMap()) val message = StoveMessage.consumed( topic = "test-topic", value = "test-value".toByteArray(), metadata = metadata, offset = 1L ) store.record(message) val records = store.consumedRecords() records shouldHaveSize 1 records.first() shouldBe message } test("should record and retrieve multiple consumed messages") { val store = MessageStore() val metadata = MessageMetadata("topic", "key", emptyMap()) repeat(5) { i -> store.record( StoveMessage.consumed( topic = "topic-$i", value = "value-$i".toByteArray(), metadata = metadata, offset = i.toLong() ) ) } store.consumedRecords() shouldHaveSize 5 } test("should record and retrieve published messages") { val store = MessageStore() val metadata = MessageMetadata("pub-topic", "pub-key", emptyMap()) val message = StoveMessage.published( topic = "pub-topic", value = "published-value".toByteArray(), metadata = metadata ) store.record(message) val records = store.producedRecords() records shouldHaveSize 1 records.first() shouldBe message } test("should record and retrieve failed messages") { val store = MessageStore() val metadata = MessageMetadata("fail-topic", "fail-key", emptyMap()) val failedMessage = StoveMessage.failed( topic = "fail-topic", value = "failed-value".toByteArray(), metadata = metadata, reason = RuntimeException("Test error") ) val failure = Failure( message = ObservedMessage(failedMessage, metadata), reason = failedMessage.reason ) store.record(failure) val records = store.failedRecords() records shouldHaveSize 1 records.first().topic shouldBe "fail-topic" records.first().reason.message shouldBe "Test error" } test("should maintain separate stores for consumed, produced, and failed") { val store = MessageStore() val metadata = MessageMetadata("topic", "key", emptyMap()) store.record( StoveMessage.consumed( topic = "consumed-topic", value = "consumed".toByteArray(), metadata = metadata ) ) store.record( StoveMessage.published( topic = "published-topic", value = "published".toByteArray(), metadata = metadata ) ) val failedMessage = StoveMessage.failed( topic = "failed-topic", value = "failed".toByteArray(), metadata = metadata, reason = RuntimeException("Error") ) store.record( Failure( message = ObservedMessage(failedMessage, metadata), reason = failedMessage.reason ) ) store.consumedRecords() shouldHaveSize 1 store.producedRecords() shouldHaveSize 1 store.failedRecords() shouldHaveSize 1 store.consumedRecords().first().topic shouldBe "consumed-topic" store.producedRecords().first().topic shouldBe "published-topic" store.failedRecords().first().topic shouldBe "failed-topic" } test("toString should include all message types") { val store = MessageStore() val metadata = MessageMetadata("topic", "key", emptyMap()) store.record( StoveMessage.consumed( topic = "consumed-topic", value = "consumed".toByteArray(), metadata = metadata ) ) store.record( StoveMessage.published( topic = "published-topic", value = "published".toByteArray(), metadata = metadata ) ) val output = store.toString() output shouldContain "Consumed" output shouldContain "Published" output shouldContain "Failed" } test("should return empty lists when no messages recorded") { val store = MessageStore() store.consumedRecords() shouldBe emptyList() store.producedRecords() shouldBe emptyList() store.failedRecords() shouldBe emptyList() } }) ================================================ FILE: starters/spring/stove-spring-kafka/src/test/kotlin/com/trendyol/stove/kafka/SpringKafkaVersionCheckTest.kt ================================================ package com.trendyol.stove.kafka import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.string.shouldContain class SpringKafkaVersionCheckTest : FunSpec({ test("ensureSpringKafkaAvailable should throw when Spring Kafka is missing") { val error = shouldThrow { SpringKafkaVersionCheck.ensureSpringKafkaAvailable() } error.message shouldContain "Spring Kafka Not Found on Classpath" } }) ================================================ FILE: starters/spring/stove-spring-kafka/src/test/kotlin/com/trendyol/stove/kafka/StoveMessageTests.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.messaging.MessageMetadata import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe class StoveMessageTests : FunSpec({ test("consumed message should be created with factory method") { val metadata = MessageMetadata("test-topic", "test-key", mapOf("header1" to "value1")) val message = StoveMessage.consumed( topic = "test-topic", value = "test-value".toByteArray(), metadata = metadata, partition = 0, key = "test-key", timestamp = 1234567890L, offset = 100L ) message.topic shouldBe "test-topic" message.valueAsString shouldBe "test-value" message.metadata shouldBe metadata message.partition shouldBe 0 message.key shouldBe "test-key" message.timestamp shouldBe 1234567890L message.offset shouldBe 100L } test("published message should be created with factory method") { val metadata = MessageMetadata("test-topic", "test-key", emptyMap()) val message = StoveMessage.published( topic = "test-topic", value = "published-value".toByteArray(), metadata = metadata, partition = 1, key = "pub-key", timestamp = 9876543210L ) message.topic shouldBe "test-topic" message.valueAsString shouldBe "published-value" message.metadata shouldBe metadata message.partition shouldBe 1 message.key shouldBe "pub-key" message.timestamp shouldBe 9876543210L } test("failed message should be created with factory method") { val metadata = MessageMetadata("error-topic", "error-key", mapOf("error" to "true")) val exception = RuntimeException("Test failure") val message = StoveMessage.failed( topic = "error-topic", value = "failed-value".toByteArray(), metadata = metadata, reason = exception, partition = 2, key = "error-key", timestamp = 1111111111L ) message.topic shouldBe "error-topic" message.valueAsString shouldBe "failed-value" message.metadata shouldBe metadata message.partition shouldBe 2 message.key shouldBe "error-key" message.timestamp shouldBe 1111111111L message.reason shouldBe exception } test("consumed messages with same content should be equal") { val metadata = MessageMetadata("topic", "key", emptyMap()) val value = "same-value".toByteArray() val message1 = StoveMessage.consumed( topic = "topic", value = value, metadata = metadata, partition = 0, key = "key", timestamp = 123L, offset = 1L ) val message2 = StoveMessage.consumed( topic = "topic", value = value.copyOf(), metadata = metadata, partition = 0, key = "key", timestamp = 123L, offset = 1L ) message1 shouldBe message2 message1.hashCode() shouldBe message2.hashCode() } test("consumed messages with different offsets should not be equal") { val metadata = MessageMetadata("topic", "key", emptyMap()) val value = "same-value".toByteArray() val message1 = StoveMessage.consumed( topic = "topic", value = value, metadata = metadata, offset = 1L ) val message2 = StoveMessage.consumed( topic = "topic", value = value.copyOf(), metadata = metadata, offset = 2L ) message1 shouldNotBe message2 } test("failed messages with different reasons should not be equal") { val metadata = MessageMetadata("topic", "key", emptyMap()) val value = "value".toByteArray() val message1 = StoveMessage.failed( topic = "topic", value = value, metadata = metadata, reason = RuntimeException("Error 1") ) val message2 = StoveMessage.failed( topic = "topic", value = value.copyOf(), metadata = metadata, reason = RuntimeException("Error 2") ) message1 shouldNotBe message2 } test("message with null optional fields should be created successfully") { val metadata = MessageMetadata("topic", "null", emptyMap()) val message = StoveMessage.consumed( topic = "topic", value = "value".toByteArray(), metadata = metadata ) message.partition shouldBe null message.key shouldBe null message.timestamp shouldBe null message.offset shouldBe null } }) ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.spring.stoveSpringKafka) implementation(libs.spring.boot.kafka) } dependencies { testImplementation(testFixtures(projects.starters.spring.tests.springTestFixtures)) testImplementation(libs.spring.boot.autoconfigure) testImplementation(projects.starters.spring.tests.spring2xTests) testImplementation(libs.logback.classic) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.kafka.Setup") } ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt ================================================ package com.trendyol.stove.kafka.protobufserde import com.trendyol.stove.kafka.* import com.trendyol.stove.spring.springBoot import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import org.springframework.context.support.beans /** * Spring Boot 2.x Protobuf Serde Kafka tests. * Test cases are inherited from [ProtobufSerdeKafkaSystemTests] in fixtures. */ class Boot2xProtobufSerdeKafkaSystemTest : ProtobufSerdeKafkaSystemTests() { init { beforeSpec { Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=test-group", "kafka.offset=earliest", "kafka.schemaRegistryUrl=mock://mock-registry" ) }, containerOptions = KafkaContainerOptions(tag = "8.0.3") ) } springBoot( runner = { params -> KafkaTestSpringBotApplicationForProtobufSerde.run(params) { addInitializers( beans { bean>() bean { StoveProtobufSerde() } } ) } }, withParameters = listOf( "spring.lifecycle.timeout-per-shutdown-phase=0s" ) ) }.run() } afterSpec { Stove.stop() } } } ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/protobufserde/app.kt ================================================ package com.trendyol.stove.kafka.protobufserde import com.google.protobuf.Message import com.trendyol.stove.kafka.KafkaRegistry // From fixtures import com.trendyol.stove.kafka.StoveBusinessException // From fixtures import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.slf4j.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.* import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean import org.springframework.kafka.annotation.* import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff class ProtobufValueSerializer : Serializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun serialize( topic: String, data: T ): ByteArray = when (data) { is ByteArray -> data else -> protobufSerde.serializer().serialize(topic, data as Message) } } class ProtobufValueDeserializer : Deserializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun deserialize( topic: String, data: ByteArray ): Message = protobufSerde.deserializer().deserialize(topic, data) } @SpringBootApplication(scanBasePackages = ["com.trendyol.stove.kafka.protobufserde"]) @EnableKafka @EnableConfigurationProperties(KafkaTestSpringBotApplicationForProtobufSerde.ProtobufSerdeKafkaConf::class) open class KafkaTestSpringBotApplicationForProtobufSerde { companion object { fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") return runApplication(args = args) { webApplicationType = WebApplicationType.NONE init() } } } private val logger: Logger = LoggerFactory.getLogger(javaClass) @ConfigurationProperties(prefix = "kafka") @ConstructorBinding data class ProtobufSerdeKafkaConf( val bootstrapServers: String, val groupId: String, val offset: String, val schemaRegistryUrl: String ) @Bean open fun createConfiguredSerdeForRecordValues(config: ProtobufSerdeKafkaConf): KafkaProtobufSerde { val registry = when { config.schemaRegistryUrl.contains("mock://") -> KafkaRegistry.Mock else -> KafkaRegistry.Defined(config.schemaRegistryUrl) } return KafkaRegistry.createSerde(registry) } @Bean open fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor, recoverer: DeadLetterPublishingRecoverer ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.consumerFactory = consumerFactory factory.setCommonErrorHandler( DefaultErrorHandler( recoverer, FixedBackOff(20, 1) ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } ) factory.setRecordInterceptor(interceptor) return factory } @Bean open fun recoverer( kafkaTemplate: KafkaTemplate<*, *> ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) @Bean open fun consumerFactory( config: ProtobufSerdeKafkaConf ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ProtobufValueDeserializer().javaClass, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 10000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 30000 ) ) @Bean open fun kafkaTemplate( config: ProtobufSerdeKafkaConf ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ProtobufValueSerializer().javaClass, ProducerConfig.ACKS_CONFIG to "1" ) ) ) @KafkaListener(topics = ["topic-protobuf"], groupId = "group_id") fun listen(message: Message) { logger.info("Received Message in consumer: $message") } } ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/shared.kt ================================================ package com.trendyol.stove.kafka /** Spring Boot 2.x Kafka test setup - uses shared fixtures */ class Setup : KafkaTestSetup() ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/stringserde/StringSerdeKafkaSystemTest.kt ================================================ package com.trendyol.stove.kafka.stringserde import com.trendyol.stove.kafka.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.springBoot import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import org.springframework.context.support.beans /** * Spring Boot 2.x String Serde Kafka tests. * Test cases are inherited from [StringSerdeKafkaSystemTests] in fixtures. */ class Boot2xStringSerdeKafkaSystemTests : StringSerdeKafkaSystemTests() { init { beforeSpec { Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=test-group", "kafka.offset=earliest" ) }, containerOptions = KafkaContainerOptions(tag = "8.0.3") ) } springBoot( runner = { params -> KafkaTestSpringBotApplicationForStringSerde.run(params) { addInitializers( beans { bean>() bean { StoveSerde.jackson.anyByteArraySerde() } } ) } }, withParameters = listOf( "spring.lifecycle.timeout-per-shutdown-phase=0s" ) ) }.run() } afterSpec { Stove.stop() } } } ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/stringserde/app.kt ================================================ package com.trendyol.stove.kafka.stringserde import com.trendyol.stove.kafka.StoveBusinessException // From fixtures import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.Serdes import org.slf4j.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.* import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean import org.springframework.kafka.annotation.* import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff @SpringBootApplication(scanBasePackages = ["com.trendyol.stove.kafka.stringserde"]) @EnableKafka @EnableConfigurationProperties(KafkaTestSpringBotApplicationForStringSerde.StringSerdeKafkaConf::class) open class KafkaTestSpringBotApplicationForStringSerde { companion object { fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") return runApplication(args = args) { webApplicationType = WebApplicationType.NONE init() } } } private val logger: Logger = LoggerFactory.getLogger(javaClass) @ConfigurationProperties(prefix = "kafka") @ConstructorBinding data class StringSerdeKafkaConf( val bootstrapServers: String, val groupId: String, val offset: String ) @Bean open fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor, recoverer: DeadLetterPublishingRecoverer ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.consumerFactory = consumerFactory factory.setCommonErrorHandler( DefaultErrorHandler( recoverer, FixedBackOff(20, 1) ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } ) factory.setRecordInterceptor(interceptor) return factory } @Bean open fun recoverer( kafkaTemplate: KafkaTemplate<*, *> ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) @Bean open fun consumerFactory( config: StringSerdeKafkaConf ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 10000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 30000 ) ) @Bean open fun kafkaTemplate( config: StringSerdeKafkaConf ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.ACKS_CONFIG to "1" ) ) ) @KafkaListener(topics = ["topic"], groupId = "group_id") fun listen(message: String) { logger.info("Received Message in consumer: \n$message") } @KafkaListener(topics = ["topic-failed"], groupId = "group_id") fun listenFailed(message: String) { logger.info("Received Message in failed consumer: \n$message") throw StoveBusinessException("This exception is thrown intentionally for testing purposes.") } @KafkaListener(topics = ["topic-failed.DLT"], groupId = "group_id") fun listenDeadLetter(message: String) { logger.info("Received Message in the lead letter, and allowing the fail by just logging: \n$message") } } ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.kafka.Setup ================================================ FILE: starters/spring/tests/spring-2x-kafka-tests/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: starters/spring/tests/spring-2x-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.spring.stoveSpring) implementation(libs.spring.boot) testImplementation(projects.testExtensions.stoveExtensionsKotest) testImplementation(testFixtures(projects.starters.spring.tests.springTestFixtures)) testImplementation(libs.spring.boot.autoconfigure) testImplementation(libs.slf4j.simple) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.Stove") } ================================================ FILE: starters/spring/tests/spring-2x-tests/src/test/kotlin/com/trendyol/stove/StoveConfig.kt ================================================ package com.trendyol.stove import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.* import com.trendyol.stove.system.Stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication open class TestSpringBootApp /** * Spring Boot 2.x test setup. * Uses [com.trendyol.stove.spring.stoveSpringRegistrar] with `bean()` DSL. */ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { bridge() springBoot( runner = { params -> runApplication(args = params) { addInitializers( stoveSpringRegistrar { bean() bean() bean { StoveSerde.jackson.default } bean { SystemTimeGetUtcNow() } } ) } }, withParameters = listOf("context=SetupOfBridgeSystemTests") ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } /** Concrete test class for Spring Boot 2.x - inherits all tests from fixtures */ class Boot2xBridgeSystemTests : BridgeSystemTests() ================================================ FILE: starters/spring/tests/spring-2x-tests/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.StoveConfig ================================================ FILE: starters/spring/tests/spring-2x-tests/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.spring.stoveSpringKafka) implementation(libs.spring.boot.three.kafka) } dependencies { testImplementation(testFixtures(projects.starters.spring.tests.springTestFixtures)) testImplementation(libs.spring.boot.three.autoconfigure) testImplementation(projects.starters.spring.tests.spring3xTests) testImplementation(libs.logback.classic) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.kafka.Setup") } ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt ================================================ package com.trendyol.stove.kafka.protobufserde import com.trendyol.stove.kafka.* import com.trendyol.stove.spring.springBoot import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import org.springframework.context.support.beans /** * Spring Boot 3.x Protobuf Serde Kafka tests. * Test cases are inherited from [ProtobufSerdeKafkaSystemTests] in fixtures. */ class Boot3xProtobufSerdeKafkaSystemTest : ProtobufSerdeKafkaSystemTests() { init { beforeSpec { Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=test-group", "kafka.offset=earliest", "kafka.schemaRegistryUrl=mock://mock-registry" ) }, containerOptions = KafkaContainerOptions(tag = "8.0.3") ) } springBoot( runner = { params -> KafkaTestSpringBotApplicationForProtobufSerde.run(params) { addInitializers( beans { bean>() bean { StoveProtobufSerde() } } ) } }, withParameters = listOf( "spring.lifecycle.timeout-per-shutdown-phase=0s" ) ) }.run() } afterSpec { Stove.stop() } } } ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/protobufserde/app.kt ================================================ package com.trendyol.stove.kafka.protobufserde import com.google.protobuf.Message import com.trendyol.stove.kafka.KafkaRegistry // From fixtures import com.trendyol.stove.kafka.StoveBusinessException // From fixtures import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.slf4j.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.* import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean import org.springframework.kafka.annotation.* import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff class ProtobufValueSerializer : Serializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun serialize( topic: String, data: T ): ByteArray = when (data) { is ByteArray -> data else -> protobufSerde.serializer().serialize(topic, data as Message) } } class ProtobufValueDeserializer : Deserializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun deserialize( topic: String, data: ByteArray ): Message = protobufSerde.deserializer().deserialize(topic, data) } @SpringBootApplication(scanBasePackages = ["com.trendyol.stove.kafka.protobufserde"]) @EnableKafka @EnableConfigurationProperties(KafkaTestSpringBotApplicationForProtobufSerde.ProtobufSerdeKafkaConf::class) open class KafkaTestSpringBotApplicationForProtobufSerde { companion object { fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") return runApplication(args = args) { webApplicationType = WebApplicationType.NONE init() } } } private val logger: Logger = LoggerFactory.getLogger(javaClass) @ConfigurationProperties(prefix = "kafka") data class ProtobufSerdeKafkaConf( val bootstrapServers: String, val groupId: String, val offset: String, val schemaRegistryUrl: String ) @Bean open fun createConfiguredSerdeForRecordValues(config: ProtobufSerdeKafkaConf): KafkaProtobufSerde { val registry = when { config.schemaRegistryUrl.contains("mock://") -> KafkaRegistry.Mock else -> KafkaRegistry.Defined(config.schemaRegistryUrl) } return KafkaRegistry.createSerde(registry) } @Bean open fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor, recoverer: DeadLetterPublishingRecoverer ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.consumerFactory = consumerFactory factory.setCommonErrorHandler( DefaultErrorHandler( recoverer, FixedBackOff(20, 1) ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } ) factory.setRecordInterceptor(interceptor) return factory } @Bean open fun recoverer( kafkaTemplate: KafkaTemplate<*, *> ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) @Bean open fun consumerFactory( config: ProtobufSerdeKafkaConf ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ProtobufValueDeserializer().javaClass, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 10000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 30000 ) ) @Bean open fun kafkaTemplate( config: ProtobufSerdeKafkaConf ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ProtobufValueSerializer().javaClass, ProducerConfig.ACKS_CONFIG to "1" ) ) ) @KafkaListener(topics = ["topic-protobuf"], groupId = "group_id") fun listen(message: Message) { logger.info("Received Message in consumer: $message") } } ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/shared.kt ================================================ package com.trendyol.stove.kafka /** Spring Boot 3.x Kafka test setup - uses shared fixtures */ class Setup : KafkaTestSetup() ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/stringserde/StringSerdeKafkaSystemTest.kt ================================================ package com.trendyol.stove.kafka.stringserde import com.trendyol.stove.kafka.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.springBoot import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove import org.springframework.context.support.beans /** * Spring Boot 3.x String Serde Kafka tests. * Test cases are inherited from [StringSerdeKafkaSystemTests] in fixtures. */ class Boot3xStringSerdeKafkaSystemTests : StringSerdeKafkaSystemTests(dltTopicSuffix = "-dlt") { init { beforeSpec { Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=test-group", "kafka.offset=earliest" ) }, containerOptions = KafkaContainerOptions(tag = "8.0.3") ) } springBoot( runner = { params -> KafkaTestSpringBotApplicationForStringSerde.run(params) { addInitializers( beans { bean>() bean { StoveSerde.jackson.anyByteArraySerde() } } ) } }, withParameters = listOf( "spring.lifecycle.timeout-per-shutdown-phase=0s" ) ) }.run() } afterSpec { Stove.stop() } } } ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/stringserde/app.kt ================================================ package com.trendyol.stove.kafka.stringserde import com.trendyol.stove.kafka.StoveBusinessException // From fixtures import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.Serdes import org.slf4j.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.* import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean import org.springframework.kafka.annotation.* import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff @SpringBootApplication(scanBasePackages = ["com.trendyol.stove.kafka.stringserde"]) @EnableKafka @EnableConfigurationProperties(KafkaTestSpringBotApplicationForStringSerde.StringSerdeKafkaConf::class) open class KafkaTestSpringBotApplicationForStringSerde { companion object { fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") return runApplication(args = args) { webApplicationType = WebApplicationType.NONE init() } } } private val logger: Logger = LoggerFactory.getLogger(javaClass) @ConfigurationProperties(prefix = "kafka") data class StringSerdeKafkaConf( val bootstrapServers: String, val groupId: String, val offset: String ) @Bean open fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor, recoverer: DeadLetterPublishingRecoverer ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.consumerFactory = consumerFactory factory.setCommonErrorHandler( DefaultErrorHandler( recoverer, FixedBackOff(20, 1) ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } ) factory.setRecordInterceptor(interceptor) return factory } @Bean open fun recoverer( kafkaTemplate: KafkaTemplate<*, *> ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) @Bean open fun consumerFactory( config: StringSerdeKafkaConf ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 10000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 30000 ) ) @Bean open fun kafkaTemplate( config: StringSerdeKafkaConf ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.ACKS_CONFIG to "1" ) ) ) @KafkaListener(topics = ["topic"], groupId = "group_id") fun listen(message: String) { logger.info("Received Message in consumer: \n$message") } @KafkaListener(topics = ["topic-failed"], groupId = "group_id") fun listenFailed(message: String) { logger.info("Received Message in failed consumer: \n$message") throw StoveBusinessException("This exception is thrown intentionally for testing purposes.") } @KafkaListener(topics = ["topic-failed-dlt"], groupId = "group_id") fun listenDeadLetter(message: String) { logger.info("Received Message in the lead letter, and allowing the fail by just logging: \n$message") } } ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.kafka.Setup ================================================ FILE: starters/spring/tests/spring-3x-kafka-tests/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: starters/spring/tests/spring-3x-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.spring.stoveSpring) implementation(libs.spring.boot.three) testImplementation(projects.testExtensions.stoveExtensionsKotest) testImplementation(testFixtures(projects.starters.spring.tests.springTestFixtures)) testImplementation(libs.spring.boot.three.autoconfigure) testImplementation(libs.slf4j.simple) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.Stove") } ================================================ FILE: starters/spring/tests/spring-3x-tests/src/test/kotlin/com/trendyol/stove/StoveConfig.kt ================================================ package com.trendyol.stove import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.* import com.trendyol.stove.system.Stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication open class TestSpringBootApp /** * Spring Boot 3.x test setup. * Uses [com.trendyol.stove.spring.stoveSpringRegistrar] with `bean()` DSL. */ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { bridge() springBoot( runner = { params -> runApplication(args = params) { addInitializers( stoveSpringRegistrar { bean() bean() bean { StoveSerde.jackson.default } bean { SystemTimeGetUtcNow() } } ) } }, withParameters = listOf("context=SetupOfBridgeSystemTests") ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } /** Concrete test class for Spring Boot 3.x - inherits all tests from fixtures */ class Boot3xBridgeSystemTests : BridgeSystemTests() ================================================ FILE: starters/spring/tests/spring-3x-tests/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.StoveConfig ================================================ FILE: starters/spring/tests/spring-3x-tests/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.spring.stoveSpringKafka) implementation(libs.spring.boot.four.kafka) } dependencies { testImplementation(testFixtures(projects.starters.spring.tests.springTestFixtures)) testImplementation(libs.spring.boot.four.autoconfigure) testImplementation(projects.starters.spring.tests.spring4xTests) testImplementation(libs.logback.classic) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.kafka.Setup") } ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt ================================================ package com.trendyol.stove.kafka.protobufserde import com.trendyol.stove.kafka.* import com.trendyol.stove.spring.springBoot import com.trendyol.stove.spring.stoveSpring4xRegistrar import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove /** * Spring Boot 4.x Protobuf Serde Kafka tests. * Test cases are inherited from [ProtobufSerdeKafkaSystemTests] in fixtures. */ class Boot4xProtobufSerdeKafkaSystemTest : ProtobufSerdeKafkaSystemTests() { init { beforeSpec { Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=test-group", "kafka.offset=earliest", "kafka.schemaRegistryUrl=mock://mock-registry" ) }, containerOptions = KafkaContainerOptions(tag = "8.0.3") ) } springBoot( runner = { params -> KafkaTestSpringBotApplicationForProtobufSerde.run(params) { addInitializers( stoveSpring4xRegistrar { registerBean>(primary = true) registerBean { StoveProtobufSerde() } } ) } }, withParameters = listOf( "spring.lifecycle.timeout-per-shutdown-phase=0s" ) ) }.run() } afterSpec { Stove.stop() } } } ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/protobufserde/app.kt ================================================ package com.trendyol.stove.kafka.protobufserde import com.google.protobuf.Message import com.trendyol.stove.kafka.KafkaRegistry // From fixtures import com.trendyol.stove.kafka.StoveBusinessException // From fixtures import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.* import org.slf4j.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.* import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean import org.springframework.kafka.annotation.* import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff class ProtobufValueSerializer : Serializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun serialize( topic: String, data: T ): ByteArray = when (data) { is ByteArray -> data else -> protobufSerde.serializer().serialize(topic, data as Message) } } class ProtobufValueDeserializer : Deserializer { private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun deserialize( topic: String, data: ByteArray ): Message = protobufSerde.deserializer().deserialize(topic, data) } @SpringBootApplication(scanBasePackages = ["com.trendyol.stove.kafka.protobufserde"]) @EnableKafka @EnableConfigurationProperties(KafkaTestSpringBotApplicationForProtobufSerde.ProtobufSerdeKafkaConf::class) open class KafkaTestSpringBotApplicationForProtobufSerde { companion object { fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") return runApplication(args = args) { setWebApplicationType(WebApplicationType.NONE) init() } } } private val logger: Logger = LoggerFactory.getLogger(javaClass) @ConfigurationProperties(prefix = "kafka") data class ProtobufSerdeKafkaConf( val bootstrapServers: String, val groupId: String, val offset: String, val schemaRegistryUrl: String ) @Bean open fun createConfiguredSerdeForRecordValues(config: ProtobufSerdeKafkaConf): KafkaProtobufSerde { val registry = when { config.schemaRegistryUrl.contains("mock://") -> KafkaRegistry.Mock else -> KafkaRegistry.Defined(config.schemaRegistryUrl) } return KafkaRegistry.createSerde(registry) } @Bean open fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor, recoverer: DeadLetterPublishingRecoverer ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConsumerFactory(consumerFactory) factory.setCommonErrorHandler( DefaultErrorHandler( recoverer, FixedBackOff(20, 1) ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } ) factory.setRecordInterceptor(interceptor) return factory } @Bean open fun recoverer( kafkaTemplate: KafkaTemplate<*, *> ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) @Bean open fun consumerFactory( config: ProtobufSerdeKafkaConf ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ProtobufValueDeserializer().javaClass, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 10000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 30000 ) ) @Bean open fun kafkaTemplate( config: ProtobufSerdeKafkaConf ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ProtobufValueSerializer().javaClass, ProducerConfig.ACKS_CONFIG to "1" ) ) ) @KafkaListener(topics = ["topic-protobuf"], groupId = "group_id") fun listen(message: Message) { logger.info("Received Message in consumer: $message") } } ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/shared.kt ================================================ package com.trendyol.stove.kafka /** Spring Boot 4.x Kafka test setup - uses shared fixtures */ class Setup : KafkaTestSetup() ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/stringserde/StringSerdeKafkaSystemTest.kt ================================================ package com.trendyol.stove.kafka.stringserde import com.trendyol.stove.kafka.* import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.springBoot import com.trendyol.stove.spring.stoveSpring4xRegistrar import com.trendyol.stove.system.Stove import com.trendyol.stove.system.stove /** * Spring Boot 4.x String Serde Kafka tests. * Test cases are inherited from [StringSerdeKafkaSystemTests] in fixtures. * Uses [com.trendyol.stove.spring.stoveSpring4xRegistrar] with `registerBean()` DSL. */ class Boot4xStringSerdeKafkaSystemTests : StringSerdeKafkaSystemTests(dltTopicSuffix = "-dlt") { init { beforeSpec { Stove() .with { kafka { KafkaSystemOptions( configureExposedConfiguration = { listOf( "kafka.bootstrapServers=${it.bootstrapServers}", "kafka.groupId=test-group", "kafka.offset=earliest" ) }, containerOptions = KafkaContainerOptions(tag = "8.0.3") ) } springBoot( runner = { params -> KafkaTestSpringBotApplicationForStringSerde.run(params) { addInitializers( stoveSpring4xRegistrar { registerBean>(primary = true) registerBean { StoveSerde.jackson.anyByteArraySerde() } } ) } }, withParameters = listOf( "spring.lifecycle.timeout-per-shutdown-phase=0s" ) ) }.run() } afterSpec { Stove.stop() } } } ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/kafka/stringserde/app.kt ================================================ package com.trendyol.stove.kafka.stringserde import com.trendyol.stove.kafka.StoveBusinessException // From fixtures import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.Serdes import org.slf4j.* import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.* import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean import org.springframework.kafka.annotation.* import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.* import org.springframework.kafka.listener.* import org.springframework.util.backoff.FixedBackOff @SpringBootApplication(scanBasePackages = ["com.trendyol.stove.kafka.stringserde"]) @EnableKafka @EnableConfigurationProperties(KafkaTestSpringBotApplicationForStringSerde.StringSerdeKafkaConf::class) open class KafkaTestSpringBotApplicationForStringSerde { companion object { fun run( args: Array, init: SpringApplication.() -> Unit = {} ): ConfigurableApplicationContext { System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") return runApplication(args = args) { setWebApplicationType(WebApplicationType.NONE) init() } } } private val logger: Logger = LoggerFactory.getLogger(javaClass) @ConfigurationProperties(prefix = "kafka") data class StringSerdeKafkaConf( val bootstrapServers: String, val groupId: String, val offset: String ) @Bean open fun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory, interceptor: RecordInterceptor, recoverer: DeadLetterPublishingRecoverer ): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() factory.setConsumerFactory(consumerFactory) factory.setCommonErrorHandler( DefaultErrorHandler( recoverer, FixedBackOff(20, 1) ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } ) factory.setRecordInterceptor(interceptor) return factory } @Bean open fun recoverer( kafkaTemplate: KafkaTemplate<*, *> ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) @Bean open fun consumerFactory( config: StringSerdeKafkaConf ): ConsumerFactory = DefaultKafkaConsumerFactory( mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to config.groupId, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 10000, ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 30000, ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 30000 ) ) @Bean open fun kafkaTemplate( config: StringSerdeKafkaConf ): KafkaTemplate = KafkaTemplate( DefaultKafkaProducerFactory( mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, ProducerConfig.ACKS_CONFIG to "1" ) ) ) @KafkaListener(topics = ["topic"], groupId = "group_id") fun listen(message: String) { logger.info("Received Message in consumer: \n$message") } @KafkaListener(topics = ["topic-failed"], groupId = "group_id") fun listenFailed(message: String) { logger.info("Received Message in failed consumer: \n$message") throw StoveBusinessException("This exception is thrown intentionally for testing purposes.") } @KafkaListener(topics = ["topic-failed-dlt"], groupId = "group_id") fun listenDeadLetter(message: String) { logger.info("Received Message in the lead letter, and allowing the fail by just logging: \n$message") } } ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.kafka.Setup ================================================ FILE: starters/spring/tests/spring-4x-kafka-tests/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: starters/spring/tests/spring-4x-tests/build.gradle.kts ================================================ dependencies { api(projects.starters.spring.stoveSpring) implementation(libs.spring.boot.four) testImplementation(projects.testExtensions.stoveExtensionsKotest) testImplementation(testFixtures(projects.starters.spring.tests.springTestFixtures)) testImplementation(libs.spring.boot.four.autoconfigure) testImplementation(libs.slf4j.simple) } tasks.test.configure { systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.Stove") } ================================================ FILE: starters/spring/tests/spring-4x-tests/src/test/kotlin/com/trendyol/stove/StoveConfig.kt ================================================ package com.trendyol.stove import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.extensions.kotest.StoveKotestExtension import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.spring.* import com.trendyol.stove.system.Stove import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication open class TestSpringBootApp /** * Spring Boot 4.x test setup. * Uses [com.trendyol.stove.spring.stoveSpring4xRegistrar] with `registerBean()` DSL. */ class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { bridge() springBoot( runner = { params -> runApplication(args = params) { addInitializers( stoveSpring4xRegistrar { registerBean() registerBean() registerBean { StoveSerde.jackson.default } registerBean { SystemTimeGetUtcNow() } } ) } }, withParameters = listOf("context=SetupOfBridgeSystemTests") ) }.run() override suspend fun afterProject(): Unit = Stove.stop() } /** Concrete test class for Spring Boot 4.x - inherits all tests from fixtures */ class Boot4xBridgeSystemTests : BridgeSystemTests() ================================================ FILE: starters/spring/tests/spring-4x-tests/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.StoveConfig ================================================ FILE: starters/spring/tests/spring-4x-tests/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: starters/spring/tests/spring-test-fixtures/build.gradle.kts ================================================ import com.google.protobuf.gradle.id plugins { `java-test-fixtures` alias(libs.plugins.protobuf) } dependencies { testFixturesApi(projects.starters.spring.stoveSpring) testFixturesApi(projects.starters.spring.stoveSpringKafka) testFixturesApi(libs.kotest.runner.junit5) testFixturesApi(libs.google.protobuf.kotlin) testFixturesApi(libs.kafka.streams.protobuf.serde) // Spring Boot as compileOnly - version provided by consuming module testFixturesCompileOnly(libs.spring.boot) testFixturesCompileOnly(libs.spring.boot.autoconfigure) testFixturesCompileOnly(libs.spring.boot.kafka) } protobuf { protoc { artifact = libs.protoc.get().toString() } generateProtoTasks { all().forEach { it.descriptorSetOptions.includeSourceInfo = true it.descriptorSetOptions.includeImports = true it.builtins { id("kotlin") } } } } ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/BridgeSystemTests.kt ================================================ package com.trendyol.stove import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.system.stove import com.trendyol.stove.system.using import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds /** * Shared bridge system tests that work across all Spring Boot versions. * Each version module should create their own test class that extends this. */ abstract class BridgeSystemTests : ShouldSpec({ should("bridge to application") { stove { using { whatIsTheTime() shouldBe GetUtcNow.frozenTime } using { parameters shouldBe listOf( "--test-system=true", "--context=SetupOfBridgeSystemTests" ) } delay(5.seconds) using { appReady shouldBe true onEvent shouldBe true } } } should("resolve multiple") { stove { using { getUtcNow: GetUtcNow, testAppInitializers: TestAppInitializers -> getUtcNow() shouldBe GetUtcNow.frozenTime testAppInitializers.appReady shouldBe true testAppInitializers.onEvent shouldBe true } using { getUtcNow: GetUtcNow, testAppInitializers: TestAppInitializers, parameterCollectorOfSpringBoot: ParameterCollectorOfSpringBoot -> getUtcNow() shouldBe GetUtcNow.frozenTime testAppInitializers.appReady shouldBe true testAppInitializers.onEvent shouldBe true parameterCollectorOfSpringBoot.parameters shouldBe listOf( "--test-system=true", "--context=SetupOfBridgeSystemTests" ) } using { getUtcNow: GetUtcNow, testAppInitializers: TestAppInitializers, parameterCollectorOfSpringBoot: ParameterCollectorOfSpringBoot, exampleService: ExampleService -> getUtcNow() shouldBe GetUtcNow.frozenTime testAppInitializers.appReady shouldBe true testAppInitializers.onEvent shouldBe true parameterCollectorOfSpringBoot.parameters shouldBe listOf( "--test-system=true", "--context=SetupOfBridgeSystemTests" ) exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime } using { getUtcNow: GetUtcNow, testAppInitializers: TestAppInitializers, parameterCollectorOfSpringBoot: ParameterCollectorOfSpringBoot, exampleService: ExampleService, objectMapper: ObjectMapper -> getUtcNow() shouldBe GetUtcNow.frozenTime testAppInitializers.appReady shouldBe true testAppInitializers.onEvent shouldBe true parameterCollectorOfSpringBoot.parameters shouldBe listOf( "--test-system=true", "--context=SetupOfBridgeSystemTests" ) exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime objectMapper.writeValueAsString(mapOf("a" to "b")) shouldBe """{"a":"b"}""" } } } }) ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/TestDomain.kt ================================================ package com.trendyol.stove import org.springframework.boot.ApplicationArguments import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener import org.springframework.stereotype.Component import java.time.Instant /** * Common test domain classes shared across Spring Boot version tests. */ fun interface GetUtcNow { companion object { val frozenTime: Instant = Instant.parse("2021-01-01T00:00:00Z") } operator fun invoke(): Instant } class SystemTimeGetUtcNow : GetUtcNow { override fun invoke(): Instant = GetUtcNow.frozenTime } class TestAppInitializers { var onEvent: Boolean = false var appReady: Boolean = false @EventListener(ApplicationReadyEvent::class) fun applicationReady() { onEvent = true appReady = true } } @Component class ExampleService( private val getUtcNow: GetUtcNow ) { fun whatIsTheTime(): Instant = getUtcNow() } class ParameterCollectorOfSpringBoot( private val applicationArguments: ApplicationArguments ) { val parameters: List get() = applicationArguments.sourceArgs.toList() } ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/kafka/KafkaTestDomain.kt ================================================ package com.trendyol.stove.kafka import io.kotest.assertions.* import io.kotest.assertions.print.Printed import io.kotest.common.reflection.bestName import io.kotest.core.config.AbstractProjectConfig import io.kotest.engine.concurrency.SpecExecutionMode /** * Shared Kafka test configuration - runs tests sequentially. */ abstract class KafkaTestSetup : AbstractProjectConfig() { override val specExecutionMode: SpecExecutionMode = SpecExecutionMode.Sequential } /** * Test exception thrown to verify error handling in Kafka consumers. */ class StoveBusinessException( message: String ) : Exception(message) /** * Asserts that a block either completes successfully or throws the expected exception type. * Unlike shouldThrow, this doesn't fail if no exception is thrown. */ inline fun shouldThrowMaybe(block: () -> Any) { val expectedExceptionClass = T::class val thrownThrowable = try { block() null } catch (thrown: Throwable) { thrown } when (thrownThrowable) { null -> Unit is T -> Unit is AssertionError -> errorCollector.collectOrThrow(thrownThrowable) else -> errorCollector.collectOrThrow( createAssertionError( "Expected exception ${expectedExceptionClass.bestName()} but a ${thrownThrowable::class.simpleName} was thrown instead.", cause = thrownThrowable, expected = Expected(Printed(expectedExceptionClass.bestName())), actual = Actual(Printed(thrownThrowable::class.simpleName ?: "null")) ) ) } } ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/kafka/ProtobufSerdeKafkaSystemTests.kt ================================================ package com.trendyol.stove.kafka import com.trendyol.stove.spring.testing.e2e.kafka.v1.* import com.trendyol.stove.spring.testing.e2e.kafka.v1.Example.* import com.trendyol.stove.system.stove import io.kotest.core.spec.style.ShouldSpec import kotlin.random.Random import kotlin.time.Duration.Companion.seconds /** * Shared Kafka protobuf serde tests that work across all Spring Boot versions. * Each version module should create their own test class that extends this * and provides the TestSystem setup. */ abstract class ProtobufSerdeKafkaSystemTests : ShouldSpec({ should("publish and consume") { stove { kafka { val userId = Random.nextInt().toString() val productId = Random.nextInt().toString() val testProduct = product { id = productId name = "product-${Random.nextInt()}" price = Random.nextDouble() currency = "eur" description = "description-${Random.nextInt()}" } val headers = mapOf("x-user-id" to userId) publish("topic-protobuf", testProduct, headers = headers) shouldBePublished(20.seconds) { actual == testProduct && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" } shouldBeConsumed(20.seconds) { actual == testProduct && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" } val orderId = Random.nextInt().toString() val testOrder = order { id = orderId customerId = userId products += testProduct } publish("topic-protobuf", testOrder, headers = headers) shouldBePublished(20.seconds) { actual == testOrder && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" } shouldBeConsumed(20.seconds) { actual == testOrder && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" } } } } }) ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/kafka/ProtobufTestUtils.kt ================================================ package com.trendyol.stove.kafka import com.google.protobuf.Message import com.trendyol.stove.serialization.StoveSerde import io.confluent.kafka.schemaregistry.protobuf.ProtobufSchemaProvider import io.confluent.kafka.schemaregistry.testutil.MockSchemaRegistry import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde /** * Schema registry abstraction for protobuf tests. */ sealed class KafkaRegistry( open val url: String ) { object Mock : KafkaRegistry("mock://mock-registry") data class Defined( override val url: String ) : KafkaRegistry(url) companion object { fun createSerde(registry: KafkaRegistry = Mock): KafkaProtobufSerde { val schemaRegistryClient = when (registry) { is Mock -> MockSchemaRegistry.getClientForScope("mock-registry", listOf(ProtobufSchemaProvider())) is Defined -> MockSchemaRegistry.getClientForScope(registry.url, listOf(ProtobufSchemaProvider())) } val serde: KafkaProtobufSerde = KafkaProtobufSerde(schemaRegistryClient) val serdeConfig: MutableMap = HashMap() serdeConfig[SCHEMA_REGISTRY_URL_CONFIG] = registry.url serde.configure(serdeConfig, false) return serde } } } /** * Shared protobuf serde for Stove Kafka tests. */ @Suppress("UNCHECKED_CAST") class StoveProtobufSerde : StoveSerde { private val parseFromMethod = "parseFrom" private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() override fun serialize(value: Any): ByteArray = protobufSerde.serializer().serialize("any", value as Message) override fun deserialize(value: ByteArray, clazz: Class): T { val incoming: Message = protobufSerde.deserializer().deserialize("any", value) incoming.isAssignableFrom(clazz).also { isAssignableFrom -> require(isAssignableFrom) { "Expected '${clazz.simpleName}' but got '${incoming.descriptorForType.name}'. " + "This could be transient ser/de problem since the message stream is constantly checked if the expected message is arrived, " + "so you can ignore this error if you are sure that the message is the expected one." } } val parseFromMethod = clazz.getDeclaredMethod(parseFromMethod, ByteArray::class.java) val parsed = parseFromMethod(incoming, incoming.toByteArray()) as T return parsed } } private fun Message.isAssignableFrom(clazz: Class<*>): Boolean = this.descriptorForType.name == clazz.simpleName ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/kafka/StringSerdeKafkaSystemTests.kt ================================================ package com.trendyol.stove.kafka import arrow.core.some import com.trendyol.stove.serialization.StoveSerde import com.trendyol.stove.system.stove import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe import org.apache.kafka.clients.admin.NewTopic import kotlin.random.Random import kotlin.time.Duration.Companion.seconds /** * Shared Kafka string serde tests that work across all Spring Boot versions. * Each version module should create their own test class that extends this. * * @param dltTopicSuffix Dead Letter Topic suffix - ".DLT" for Spring Boot 2.x, "-dlt" for 3.x/4.x */ abstract class StringSerdeKafkaSystemTests( private val dltTopicSuffix: String = ".DLT" ) : ShouldSpec({ should("publish and consume") { stove { kafka { val userId = Random.nextInt().toString() val message = "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" val headers = mapOf("x-user-id" to userId) publish("topic", message, headers = headers) shouldBePublished(20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" } shouldBeConsumed(20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" } } } } should("publish and consume with failed consumer") { shouldThrowMaybe { stove { kafka { val userId = Random.nextInt().toString() val message = "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" val headers = mapOf("x-user-id" to userId) publish("topic-failed", message, headers = headers) shouldBePublished(20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed" } shouldBeFailed(20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed" && reason is StoveBusinessException } shouldBePublished(20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed$dltTopicSuffix" } } } } } should("admin operations") { stove { kafka { adminOperations { val topic = "admin-test-topic-${Random.nextInt()}" createTopics(listOf(NewTopic(topic, 1, 1))) listTopics().names().get().contains(topic) shouldBe true deleteTopics(listOf(topic)) listTopics().names().get().contains(topic) shouldBe false } } } } should("publish with ser/de") { stove { kafka { val userId = Random.nextInt().toString() val message = "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" val headers = mapOf("x-user-id" to userId) publish("topic", message, serde = StoveSerde.jackson.anyJsonStringSerde().some(), headers = headers) shouldBePublished(atLeastIn = 20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" } shouldBeConsumed(atLeastIn = 20.seconds) { actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" } } } } }) ================================================ FILE: starters/spring/tests/spring-test-fixtures/src/testFixtures/proto/example.proto ================================================ syntax = "proto3"; // buf:lint:ignore PACKAGE_DIRECTORY_MATCH package com.trendyol.stove.spring.testing.e2e.kafka.v1; message Product { string id = 1; string name = 2; string description = 3; double price = 4; string currency = 5; } message Order { string id = 1; string customerId = 2; repeated Product products = 3; } ================================================ FILE: test-extensions/stove-extensions-junit/api/stove-extensions-junit.api ================================================ public final class com/trendyol/stove/extensions/junit/StoveJUnitExtension : org/junit/jupiter/api/extension/AfterEachCallback, org/junit/jupiter/api/extension/BeforeEachCallback, org/junit/jupiter/api/extension/TestExecutionExceptionHandler { public fun ()V public fun afterEach (Lorg/junit/jupiter/api/extension/ExtensionContext;)V public fun beforeEach (Lorg/junit/jupiter/api/extension/ExtensionContext;)V public fun handleTestExecutionException (Lorg/junit/jupiter/api/extension/ExtensionContext;Ljava/lang/Throwable;)V } ================================================ FILE: test-extensions/stove-extensions-junit/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(projects.lib.stoveTracing) api(libs.junit.jupiter.api) } dependencies { testImplementation(projects.lib.stoveHttp) testImplementation(projects.lib.stoveWiremock) testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.assertions.core) testImplementation(libs.logback.classic) testImplementation(testFixtures(projects.lib.stove)) } kover { currentProject { sources { excludedSourceSets.addAll("test") } } reports { filters { excludes { classes( "*Test", "*Tests", "*Config" ) } } } } ================================================ FILE: test-extensions/stove-extensions-junit/src/main/kotlin/com/trendyol/stove/extensions/junit/StoveJUnitExtension.kt ================================================ package com.trendyol.stove.extensions.junit import com.trendyol.stove.reporting.StoveTestContext import com.trendyol.stove.reporting.StoveTestContextHolder import com.trendyol.stove.reporting.StoveTestFailureException import com.trendyol.stove.system.Stove import com.trendyol.stove.tracing.TraceContext import com.trendyol.stove.tracing.TraceReportBuilder import com.trendyol.stove.tracing.TraceReportBuilder.shouldEnrichFailures import org.junit.jupiter.api.extension.AfterEachCallback import org.junit.jupiter.api.extension.BeforeEachCallback import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.api.extension.TestExecutionExceptionHandler /** * JUnit extension that automatically manages test context and enriches test failures * with Stove's execution report. * * When a test fails, the report is included in the exception message so that JUnit's * test engine displays what happened during the test execution. * * This extension works with both JUnit 5 and JUnit 6, as both use the JUnit Jupiter API. * * Register this extension on your test class: * ```kotlin * @ExtendWith(StoveJUnitExtension::class) * class MyE2ETests { ... } * ``` * * Or globally via @RegisterExtension: * ```kotlin * companion object { * @JvmField * @RegisterExtension * val stove = StoveJUnitExtension() * } * ``` */ class StoveJUnitExtension : BeforeEachCallback, AfterEachCallback, TestExecutionExceptionHandler { override fun beforeEach(context: ExtensionContext) { if (!Stove.instanceInitialized()) return val ctx = context.toStoveContext() StoveTestContextHolder.set(ctx) Stove.reporter().startTest(ctx) TraceContext.start(ctx.testId) } override fun handleTestExecutionException(context: ExtensionContext, throwable: Throwable) { if (!Stove.instanceInitialized()) throw throwable Stove.reporter().reportFailure(throwable.message ?: throwable::class.simpleName ?: "Test failed") val options = Stove.options() if (!options.shouldEnrichFailures()) throw throwable val fullReport = TraceReportBuilder.buildFullReport() if (fullReport.isNotEmpty()) { throw StoveTestFailureException( originalMessage = throwable.message ?: TraceReportBuilder.DEFAULT_ERROR_MESSAGE, stoveReport = fullReport, cause = throwable ) } throw throwable } override fun afterEach(context: ExtensionContext) { if (!Stove.instanceInitialized()) return TraceContext.clear() Stove.reporter().run { endTest() clear() } StoveTestContextHolder.clear() } private fun ExtensionContext.toStoveContext(): StoveTestContext { val path = buildTestPath() val fullName = path.joinToString(" / ") val rootClass = findRootTestClass() return StoveTestContext( testId = TraceContext.sanitizeToAscii("$rootClass::$fullName"), testName = displayName, specName = rootClass, testPath = path ) } /** * Builds a test path by traversing the [ExtensionContext] parent chain. * For `@Nested` classes, each nested class display name becomes a path segment. * For flat tests, the path contains just the test method display name. */ private fun ExtensionContext.buildTestPath(): List { val segments = mutableListOf() var ctx: ExtensionContext? = this while (ctx != null) { when { // Test method — always include ctx.testMethod.isPresent -> segments.add(0, ctx.displayName) // @Nested class — include if its parent also has a test class (meaning it's nested, not root) ctx.testClass.isPresent && ctx.parent.flatMap { it.testClass }.isPresent -> segments.add(0, ctx.displayName) } ctx = ctx.parent.orElse(null) } return segments } /** * Finds the outermost (root) test class name by traversing the parent chain. */ private fun ExtensionContext.findRootTestClass(): String { var rootClass = requiredTestClass.simpleName var ctx: ExtensionContext? = parent.orElse(null) while (ctx != null) { if (ctx.testClass.isPresent) { rootClass = ctx.requiredTestClass.simpleName } ctx = ctx.parent.orElse(null) } return rootClass } } ================================================ FILE: test-extensions/stove-extensions-junit/src/test/kotlin/com/trendyol/stove/extensions/junit/StoveJUnitExtensionTest.kt ================================================ package com.trendyol.stove.extensions.junit import arrow.core.some import com.trendyol.stove.http.* import com.trendyol.stove.reporting.ReportEntry import com.trendyol.stove.reporting.StoveTestContextHolder import com.trendyol.stove.reporting.StoveTestFailureException import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.wiremock.* import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.* import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtensionContext private val WIREMOCK_PORT = PortFinder.findAvailablePort() class NoApplication : ApplicationUnderTest { override suspend fun start(configurations: List) { // do nothing } override suspend fun stop() { // do nothing } } data class TestDto( val name: String ) @ExtendWith(StoveJUnitExtension::class) class StoveJUnitExtensionTest { companion object { @JvmStatic @BeforeAll fun setup() = runBlocking { Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$WIREMOCK_PORT" ) } wiremock { WireMockSystemOptions(WIREMOCK_PORT) } applicationUnderTest(NoApplication()) }.run() } @JvmStatic @AfterAll fun teardown() = runBlocking { Stove.stop() } } @Test fun `beforeEach should set up test context correctly`() { val context = StoveTestContextHolder.get() context.shouldNotBeNull() context.testId shouldContain "StoveJUnitExtensionTest" context.testName shouldContain "beforeEach should set up test context correctly" context.specName shouldBe "StoveJUnitExtensionTest" } @Test fun `afterEach should clear test context`() { val context = StoveTestContextHolder.get() context.shouldNotBeNull() // Context should be cleared after test execution } @Test suspend fun `extension should integrate with WireMock and HTTP systems`() { val expectedName = "test-integration" stove { wiremock { mockGet("/test", statusCode = 200, responseBody = TestDto(expectedName).some()) } http { get("/test") { actual -> actual.name shouldBe expectedName } } } } @Test suspend fun `handleTestExecutionException should enrich failures with Stove report`() { val exception = shouldThrow { stove { wiremock { mockGet("/failing-endpoint", statusCode = 200, responseBody = TestDto("expected").some()) } http { get("/failing-endpoint") { actual -> actual.name shouldBe "wrong-value" // This will fail } } } } // The exception should be enriched with Stove report exception.message.shouldNotBeNull() exception.message shouldContain "wrong-value" } @Test suspend fun `extension should handle multiple sequential HTTP calls`() { stove { wiremock { mockGet("/first", statusCode = 200, responseBody = TestDto("first").some()) mockGet("/second", statusCode = 200, responseBody = TestDto("second").some()) } http { get("/first") { actual -> actual.name shouldBe "first" } get("/second") { actual -> actual.name shouldBe "second" } } } } @Test suspend fun `extension should work with reporter to track test execution`() { val reporter = Stove.reporter() val testId = reporter.currentTestId() testId.shouldNotBeNull() testId shouldContain "StoveJUnitExtensionTest" testId shouldContain "extension should work with reporter to track test execution" stove { wiremock { mockGet("/reporter-test", statusCode = 200, responseBody = TestDto("reporter").some()) } http { get("/reporter-test") { actual -> actual.name shouldBe "reporter" } } } } @Test suspend fun `extension should handle test context isolation between tests`() { // Each test should have its own isolated context val context = StoveTestContextHolder.get() context.shouldNotBeNull() context.testId shouldContain "extension should handle test context isolation between tests" stove { wiremock { mockGet("/isolation-test", statusCode = 200, responseBody = TestDto("isolated").some()) } http { get("/isolation-test") { actual -> actual.name shouldBe "isolated" } } } } @Test fun `handleTestExecutionException should enrich failures when reporter has recorded failures`() { // Record a failure in the reporter so buildFullReport() returns non-empty val reporter = Stove.reporter() reporter.record( ReportEntry.failure( system = "TestSystem", testId = reporter.currentTestId(), action = "simulated action", error = "simulated failure for enrichment test" ) ) val extension = StoveJUnitExtension() val originalError = AssertionError("Original test assertion failure") // Create a stub ExtensionContext via Proxy since handleTestExecutionException // doesn't use the context parameter val stubContext = java.lang.reflect.Proxy.newProxyInstance( ExtensionContext::class.java.classLoader, arrayOf(ExtensionContext::class.java) ) { _, _, _ -> null } as ExtensionContext // handleTestExecutionException always throws - verify it wraps with StoveTestFailureException val thrown = shouldThrow { extension.handleTestExecutionException(stubContext, originalError) } thrown.message shouldContain "Original test assertion failure" } @Test fun `handleTestExecutionException should rethrow original when report is empty`() { // Don't record any failures, so buildFullReport() returns empty // Clear the current test report to ensure no prior failures Stove.reporter().clear() val extension = StoveJUnitExtension() val originalError = IllegalStateException("Original error without enrichment") val stubContext = java.lang.reflect.Proxy.newProxyInstance( ExtensionContext::class.java.classLoader, arrayOf(ExtensionContext::class.java) ) { _, _, _ -> null } as ExtensionContext // When report is empty, it should rethrow the original exception val thrown = shouldThrow { extension.handleTestExecutionException(stubContext, originalError) } thrown.message shouldBe "Original error without enrichment" } } ================================================ FILE: test-extensions/stove-extensions-junit/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: test-extensions/stove-extensions-kotest/api/stove-extensions-kotest.api ================================================ public final class com/trendyol/stove/extensions/kotest/StoveKotestExtension : io/kotest/core/extensions/TestCaseExtension { public fun ()V public fun intercept (Lio/kotest/core/test/TestCase;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } ================================================ FILE: test-extensions/stove-extensions-kotest/build.gradle.kts ================================================ dependencies { api(projects.lib.stove) api(projects.lib.stoveTracing) api(libs.kotest.framework.engine) } dependencies { testImplementation(projects.lib.stoveHttp) testImplementation(projects.lib.stoveWiremock) testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotest.assertions.core) testImplementation(libs.logback.classic) testImplementation(testFixtures(projects.lib.stove)) } kover { currentProject { sources { excludedSourceSets.addAll("test") } } reports { filters { excludes { classes( "*Test", "*Tests", "*Config" ) } } } } ================================================ FILE: test-extensions/stove-extensions-kotest/src/main/kotlin/com/trendyol/stove/extensions/kotest/StoveKotestExtension.kt ================================================ package com.trendyol.stove.extensions.kotest import com.trendyol.stove.reporting.StoveReporter import com.trendyol.stove.reporting.StoveTestContext import com.trendyol.stove.reporting.StoveTestErrorException import com.trendyol.stove.reporting.StoveTestFailureException import com.trendyol.stove.system.Stove import com.trendyol.stove.tracing.TraceContext import com.trendyol.stove.tracing.TraceReportBuilder import com.trendyol.stove.tracing.TraceReportBuilder.shouldEnrichFailures import io.kotest.core.extensions.TestCaseExtension import io.kotest.core.test.TestCase import io.kotest.core.test.TestType import io.kotest.engine.test.TestResult /** * Kotest extension that automatically manages test context and enriches test failures * with Stove's execution report. * * When a test fails, the report is included in the exception message so that Kotest's * test engine displays what happened during the test execution. * * Register this extension in your Kotest project config: * ```kotlin * class TestConfig : AbstractProjectConfig() { * override fun extensions() = listOf(StoveKotestExtension()) * } * ``` */ class StoveKotestExtension : TestCaseExtension { override suspend fun intercept( testCase: TestCase, execute: suspend (TestCase) -> TestResult ): TestResult { if (!Stove.instanceInitialized()) { return execute(testCase) } // Only wrap leaf tests in test context — containers (context/given/when/describe blocks) // should pass through without starting/ending a test report. if (testCase.type != TestType.Test) { return execute(testCase) } return Stove.reporter().withTestContext(testCase.toStoveContext()) { execute(testCase) .reportFailureIfNeeded() .enrichIfFailed() } } private fun TestCase.toStoveContext(): StoveTestContext { val path = buildDisplayPath() val fullName = path.joinToString(" / ") return StoveTestContext( testId = TraceContext.sanitizeToAscii("${spec::class.simpleName}::$fullName"), testName = name.name, specName = spec::class.simpleName, testPath = path ) } /** * Builds a display path by traversing the parent chain and prepending * each test case's prefix (if any) to its name. * * For BehaviourSpec: ["Given: valid request", "When: creating", "Then: should succeed"] * For FunSpec with context: ["context order creation", "should create order"] * For flat FunSpec: ["should create order"] */ private fun TestCase.buildDisplayPath(): List { val chain = mutableListOf() var current: TestCase? = this while (current != null) { chain.add(0, current) current = current.parent } return chain.map { tc -> val prefix = tc.name.prefix ?: "" "$prefix${tc.name.name}" } } private fun TestResult.enrichIfFailed(): TestResult { if (!Stove.options().shouldEnrichFailures()) return this return when (this) { is TestResult.Failure -> enrichFailure() is TestResult.Error -> enrichError() else -> this } } private fun TestResult.reportFailureIfNeeded(): TestResult { when (this) { is TestResult.Failure -> Stove.reporter().reportFailure(cause.toFailureMessage()) is TestResult.Error -> Stove.reporter().reportFailure(cause.toFailureMessage()) else -> Unit } return this } private fun TestResult.Failure.enrichFailure(): TestResult { val fullReport = TraceReportBuilder.buildFullReport() return if (fullReport.isNotEmpty()) { TestResult.Failure( duration, StoveTestFailureException(cause.toFailureMessage(), fullReport, cause) ) } else { this } } private fun TestResult.Error.enrichError(): TestResult { val fullReport = TraceReportBuilder.buildFullReport() return if (fullReport.isNotEmpty()) { TestResult.Error( duration, StoveTestErrorException(cause.toFailureMessage(), fullReport, cause) ) } else { this } } private fun Throwable.toFailureMessage(): String = message ?: this::class.simpleName ?: TraceReportBuilder.DEFAULT_ERROR_MESSAGE } /** * Executes the block within a test context, ensuring proper setup and cleanup. * Also starts/ends tracing automatically for the test. */ private suspend fun StoveReporter.withTestContext( ctx: StoveTestContext, block: suspend () -> T ): T { startTest(ctx) TraceContext.start(ctx.testId) return try { TraceContext.withCurrentPropagation { block() } } finally { TraceContext.clear() endTest() clear(ctx.testId) } } ================================================ FILE: test-extensions/stove-extensions-kotest/src/test/kotlin/com/trendyol/stove/extensions/kotest/KotestHierarchyExplorationTest.kt ================================================ package com.trendyol.stove.extensions.kotest import io.kotest.core.extensions.TestCaseExtension import io.kotest.core.spec.style.BehaviorSpec import io.kotest.core.spec.style.DescribeSpec import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.StringSpec import io.kotest.core.test.TestCase import io.kotest.core.test.TestType import io.kotest.engine.test.TestResult import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import java.util.concurrent.CopyOnWriteArrayList /** * Data captured by the [HierarchyCaptureExtension] for each intercepted test case. */ data class CapturedTestInfo( val specSimpleName: String?, val testParts: List, val displayPath: List, val leafName: String, val prefix: String?, val type: TestType, val hasParent: Boolean, val parentType: TestType? ) /** * A [TestCaseExtension] that captures hierarchy metadata from every intercepted test case * into a shared list for later assertion. */ class HierarchyCaptureExtension( private val captured: MutableList ) : TestCaseExtension { override suspend fun intercept( testCase: TestCase, execute: suspend (TestCase) -> TestResult ): TestResult { // Build display path by traversing parents and prepending prefix to each name val displayPath = buildDisplayPath(testCase) captured.add( CapturedTestInfo( specSimpleName = testCase.spec::class.simpleName, testParts = testCase.descriptor.testParts(), displayPath = displayPath, leafName = testCase.name.name, prefix = testCase.name.prefix, type = testCase.type, hasParent = testCase.parent != null, parentType = testCase.parent?.type ) ) return execute(testCase) } private fun buildDisplayPath(testCase: TestCase): List { val chain = mutableListOf() var current: TestCase? = testCase while (current != null) { chain.add(0, current) current = current.parent } return chain.map { tc -> val prefix = tc.name.prefix ?: "" "$prefix${tc.name.name}" } } } // -- FunSpec flat tests -- private val funSpecFlatCaptures = CopyOnWriteArrayList() class FunSpecFlatHierarchyTest : FunSpec({ extensions(HierarchyCaptureExtension(funSpecFlatCaptures)) test("flat test one") { val mine = funSpecFlatCaptures.first { it.leafName == "flat test one" } mine.testParts shouldContainExactly listOf("flat test one") mine.displayPath shouldContainExactly listOf("flat test one") mine.type shouldBe TestType.Test mine.hasParent shouldBe false mine.parentType shouldBe null mine.prefix shouldBe null mine.specSimpleName shouldBe "FunSpecFlatHierarchyTest" } }) // -- FunSpec with context blocks -- private val funSpecContextCaptures = CopyOnWriteArrayList() class FunSpecContextHierarchyTest : FunSpec({ extensions(HierarchyCaptureExtension(funSpecContextCaptures)) context("order creation") { test("should create order") { val mine = funSpecContextCaptures.first { it.leafName == "should create order" } // testParts() uses raw DescriptorId values (no prefixes) mine.testParts shouldContainExactly listOf("order creation", "should create order") // displayPath includes prefix+name for each level (FunSpec context has "context " prefix) mine.displayPath shouldContainExactly listOf("context order creation", "should create order") mine.type shouldBe TestType.Test mine.hasParent shouldBe true mine.parentType shouldBe TestType.Container } test("should validate order") { val mine = funSpecContextCaptures.first { it.leafName == "should validate order" } mine.testParts shouldContainExactly listOf("order creation", "should validate order") } } test("top level test") { val mine = funSpecContextCaptures.first { it.leafName == "top level test" } mine.testParts shouldContainExactly listOf("top level test") mine.hasParent shouldBe false } // This test runs last and verifies container interception test("intercept is called for container test cases") { val containers = funSpecContextCaptures.filter { it.type == TestType.Container } containers shouldHaveSize 1 containers.first().leafName shouldBe "order creation" containers.first().testParts shouldContainExactly listOf("order creation") } }) // -- BehaviourSpec -- private val behaviourSpecCaptures = CopyOnWriteArrayList() class BehaviourSpecHierarchyTest : BehaviorSpec({ extensions(HierarchyCaptureExtension(behaviourSpecCaptures)) given("a valid order request") { `when`("creating an order") { then("should succeed") { val mine = behaviourSpecCaptures.first { it.leafName == "should succeed" } // testParts() returns raw names without prefixes mine.testParts shouldContainExactly listOf( "a valid order request", "creating an order", "should succeed" ) // displayPath includes the style-specific prefixes mine.displayPath shouldContainExactly listOf( "Given: a valid order request", "When: creating an order", "Then: should succeed" ) mine.type shouldBe TestType.Test mine.hasParent shouldBe true mine.parentType shouldBe TestType.Container mine.prefix shouldBe "Then: " } then("should publish event") { val mine = behaviourSpecCaptures.first { it.leafName == "should publish event" } mine.displayPath shouldContainExactly listOf( "Given: a valid order request", "When: creating an order", "Then: should publish event" ) } } } // Verify containers were intercepted and have correct prefixes given("container interception check") { then("given and when containers are intercepted") { val containers = behaviourSpecCaptures.filter { it.type == TestType.Container } // given("a valid order request") + when("creating an order") + given("container interception check") containers.size shouldBe 3 val givenContainer = containers.first { it.leafName == "a valid order request" } givenContainer.prefix shouldBe "Given: " givenContainer.displayPath shouldContainExactly listOf("Given: a valid order request") val whenContainer = containers.first { it.leafName == "creating an order" } whenContainer.prefix shouldBe "When: " } } }) // -- StringSpec -- private val stringSpecCaptures = CopyOnWriteArrayList() class StringSpecHierarchyTest : StringSpec({ extensions(HierarchyCaptureExtension(stringSpecCaptures)) "should work as a flat test" { val mine = stringSpecCaptures.first { it.leafName == "should work as a flat test" } mine.testParts shouldContainExactly listOf("should work as a flat test") mine.displayPath shouldContainExactly listOf("should work as a flat test") mine.type shouldBe TestType.Test mine.hasParent shouldBe false mine.parentType shouldBe null mine.prefix shouldBe null mine.specSimpleName shouldBe "StringSpecHierarchyTest" } "another flat test" { val mine = stringSpecCaptures.first { it.leafName == "another flat test" } mine.testParts shouldContainExactly listOf("another flat test") mine.displayPath shouldContainExactly listOf("another flat test") mine.hasParent shouldBe false } }) // -- DescribeSpec -- private val describeSpecCaptures = CopyOnWriteArrayList() class DescribeSpecHierarchyTest : DescribeSpec({ extensions(HierarchyCaptureExtension(describeSpecCaptures)) describe("OrderService") { it("should create order") { val mine = describeSpecCaptures.first { it.leafName == "should create order" } // testParts() returns raw names without prefixes mine.testParts shouldContainExactly listOf("OrderService", "should create order") // displayPath includes "Describe: " prefix for describe blocks mine.displayPath shouldContainExactly listOf("Describe: OrderService", "should create order") mine.type shouldBe TestType.Test mine.hasParent shouldBe true mine.parentType shouldBe TestType.Container } describe("with invalid input") { it("should fail validation") { val mine = describeSpecCaptures.first { it.leafName == "should fail validation" } mine.testParts shouldContainExactly listOf( "OrderService", "with invalid input", "should fail validation" ) mine.displayPath shouldContainExactly listOf( "Describe: OrderService", "Describe: with invalid input", "should fail validation" ) } } } }) ================================================ FILE: test-extensions/stove-extensions-kotest/src/test/kotlin/com/trendyol/stove/extensions/kotest/StoveKotestExtensionTest.kt ================================================ package com.trendyol.stove.extensions.kotest import arrow.core.some import com.trendyol.stove.http.* import com.trendyol.stove.reporting.ReportEntry import com.trendyol.stove.reporting.ReportEventListener import com.trendyol.stove.reporting.StoveTestErrorException import com.trendyol.stove.reporting.StoveTestFailureException import com.trendyol.stove.system.* import com.trendyol.stove.system.abstractions.ApplicationUnderTest import com.trendyol.stove.wiremock.* import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.FunSpec import io.kotest.engine.test.TestResult import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf import kotlin.time.Duration.Companion.milliseconds private val WIREMOCK_PORT = PortFinder.findAvailablePort() class NoApplication : ApplicationUnderTest { override suspend fun start(configurations: List) { // do nothing } override suspend fun stop() { // do nothing } } class StoveConfig : AbstractProjectConfig() { override val extensions: List = listOf(StoveKotestExtension()) override suspend fun beforeProject(): Unit = Stove() .with { httpClient { HttpClientSystemOptions( baseUrl = "http://localhost:$WIREMOCK_PORT" ) } wiremock { WireMockSystemOptions(WIREMOCK_PORT) } applicationUnderTest(NoApplication()) }.run() override suspend fun afterProject(): Unit = Stove.stop() } data class TestDto( val name: String ) class StoveKotestExtensionTest : FunSpec({ test("extension should set test context during test execution") { // The extension sets context via reporter, verify it's accessible val reporter = Stove.reporter() val testId = reporter.currentTestId() testId.shouldNotBeNull() testId shouldContain "StoveKotestExtensionTest" testId shouldContain "extension should set test context during test execution" } test("extension should integrate with WireMock and HTTP systems") { val expectedName = "test-integration" stove { wiremock { mockGet("/test", statusCode = 200, responseBody = TestDto(expectedName).some()) } http { get("/test") { actual -> actual.name shouldBe expectedName } } } } test("extension should enrich test failures with Stove report") { // This test verifies that failures are enriched with Stove reports // The enrichment is visible in the test output when the test fails shouldThrow { stove { wiremock { mockGet("/failing-endpoint", statusCode = 200, responseBody = TestDto("expected").some()) } http { get("/failing-endpoint") { actual -> actual.name shouldBe "wrong-value" // This will fail } } } } // If we reach here, an exception was thrown (enrichment verified by test output) } test("extension should enrich test errors with Stove report") { // This test verifies that errors are enriched with Stove reports // The enrichment is visible in the test output when the test fails // Note: HTTP errors may throw IllegalStateException when deserialization fails shouldThrow { stove { wiremock { mockGet("/error-endpoint", statusCode = 500) } http { get("/error-endpoint") { _ -> // This will throw an error due to 500 status } } } } // If we reach here, an exception was thrown (enrichment verified by test output) } test("extension should clear test context after test execution") { // Context should be available during test via reporter val reporter = Stove.reporter() val testId = reporter.currentTestId() testId.shouldNotBeNull() // After this test, context should be cleared by the extension } test("extension should handle multiple sequential HTTP calls") { stove { wiremock { mockGet("/first", statusCode = 200, responseBody = TestDto("first").some()) mockGet("/second", statusCode = 200, responseBody = TestDto("second").some()) } http { get("/first") { actual -> actual.name shouldBe "first" } get("/second") { actual -> actual.name shouldBe "second" } } } } test("extension should work with reporter to track test execution") { val reporter = Stove.reporter() val testId = reporter.currentTestId() testId.shouldNotBeNull() testId shouldContain "StoveKotestExtensionTest" testId shouldContain "extension should work with reporter to track test execution" stove { wiremock { mockGet("/reporter-test", statusCode = 200, responseBody = TestDto("reporter").some()) } http { get("/reporter-test") { actual -> actual.name shouldBe "reporter" } } } } test("enrichFailure should wrap test failures with Stove report") { val extension = StoveKotestExtension() val result = extension.intercept(testCase) { tc -> // Record a failure in the reporter so buildFullReport() returns non-empty val reporter = Stove.reporter() reporter.record( ReportEntry.failure( system = "TestSystem", testId = reporter.currentTestId(), action = "simulated action", error = "simulated failure" ) ) TestResult.Failure(1.milliseconds, AssertionError("Original assertion failure")) } result.shouldBeInstanceOf() result.errorOrNull.shouldNotBeNull() result.errorOrNull.shouldBeInstanceOf() result.errorOrNull!!.message shouldContain "Original assertion failure" } test("enrichError should wrap test errors with Stove report") { val extension = StoveKotestExtension() val result = extension.intercept(testCase) { tc -> // Record a failure in the reporter so buildFullReport() returns non-empty val reporter = Stove.reporter() reporter.record( ReportEntry.failure( system = "TestSystem", testId = reporter.currentTestId(), action = "simulated action", error = "simulated error" ) ) TestResult.Error(1.milliseconds, RuntimeException("Original runtime error")) } result.shouldBeInstanceOf() result.errorOrNull.shouldNotBeNull() result.errorOrNull.shouldBeInstanceOf() result.errorOrNull!!.message shouldContain "Original runtime error" } test("enrichIfFailed should not enrich successful tests") { val extension = StoveKotestExtension() val result = extension.intercept(testCase) { TestResult.Success(1.milliseconds) } result.shouldBeInstanceOf() } test("intercept should notify reporter listeners for failed test results") { val extension = StoveKotestExtension() val reporter = Stove.reporter() val failures = mutableListOf>() val listener = object : ReportEventListener { override fun onTestFailed(testId: String, error: String) { failures += testId to error } } reporter.addListener(listener) try { extension.intercept(testCase) { TestResult.Failure(1.milliseconds, AssertionError("Original assertion failure")) }.shouldBeInstanceOf() } finally { reporter.removeListener(listener) } failures shouldHaveSize 1 failures.single().first shouldContain "StoveKotestExtensionTest" failures.single().second shouldBe "Original assertion failure" } test("intercept should pass through when Stove is not initialized") { // This test verifies the early return path when Stove IS initialized // (since we can't easily uninitialize Stove in this test context, // we verify the normal path works correctly) val extension = StoveKotestExtension() val result = extension.intercept(testCase) { TestResult.Success(2.milliseconds) } result.shouldBeInstanceOf() } test("withTestContext should clear report state between repeated executions") { val extension = StoveKotestExtension() val reporter = Stove.reporter() reporter.clear() extension.intercept(testCase) { reporter.record( ReportEntry.success( system = "TestSystem", testId = reporter.currentTestId(), action = "first execution" ) ) TestResult.Success(1.milliseconds) } extension.intercept(testCase) { reporter.currentTest().entries() shouldHaveSize 0 TestResult.Success(1.milliseconds) }.shouldBeInstanceOf() } }) ================================================ FILE: test-extensions/stove-extensions-kotest/src/test/resources/kotest.properties ================================================ kotest.framework.config.fqn=com.trendyol.stove.extensions.kotest.StoveConfig ================================================ FILE: test-extensions/stove-extensions-kotest/src/test/resources/logback-test.xml ================================================ %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - %yellow(%m) %n ================================================ FILE: tools/stove-cli/.gitignore ================================================ # Rust /target/ # SPA /spa/node_modules/ /spa/dist/ ================================================ FILE: tools/stove-cli/.idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml # Ignored default folder with query files /queries/ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml # Editor-based HTTP Client requests /httpRequests/ ================================================ FILE: tools/stove-cli/.idea/copilot.data.migration.ask2agent.xml ================================================ ================================================ FILE: tools/stove-cli/.idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: tools/stove-cli/.idea/modules.xml ================================================ ================================================ FILE: tools/stove-cli/.idea/vcs.xml ================================================ ================================================ FILE: tools/stove-cli/Cargo.toml ================================================ [package] name = "stove-cli" version = "0.0.0" edition = "2024" description = "CLI for Stove — local observability dashboard for e2e test runs" [lib] name = "stove" path = "src/lib.rs" [[bin]] name = "stove" path = "src/main.rs" [dependencies] # Async runtime tokio = { version = "1", features = ["full"] } # gRPC server (receives events from Stove test process) tonic = "0.14" tonic-prost = "0.14" prost = "0.14" prost-types = "0.14" # HTTP server (serves REST API + SPA to browser) axum = { version = "0.8", features = ["json"] } tower-http = { version = "0.6", features = ["cors"] } # SSE (live updates to browser) tokio-stream = { version = "0.1", features = ["sync"] } # Database rusqlite = { version = "0.39", features = ["bundled"] } # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" # CLI argument parsing clap = { version = "4", features = ["derive"] } # Embed SPA static files into binary rust-embed = "8" mime_guess = "2" # Error handling thiserror = "2" anyhow = "1" # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Utilities chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } # HTTP client (used for the Stove agent skills sync against GitHub) reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "http2"] } [dev-dependencies] tempfile = "3" [build-dependencies] tonic-build = "0.14" tonic-prost-build = "0.14" ================================================ FILE: tools/stove-cli/Formula/stove.rb ================================================ # Homebrew formula for Stove CLI. # Managed by the stove-cli-release workflow — do not edit checksums manually. # # Install: # brew install Trendyol/trendyol-tap/stove class Stove < Formula desc "Local observability dashboard for Stove e2e test runs" homepage "https://github.com/Trendyol/stove" version "__VERSION__" license "Apache-2.0" on_macos do if Hardware::CPU.arm? url "https://github.com/Trendyol/stove/releases/download/v#{version}/stove-#{version}-darwin-arm64.tar.gz" sha256 "__SHA256_DARWIN_ARM64__" end if Hardware::CPU.intel? url "https://github.com/Trendyol/stove/releases/download/v#{version}/stove-#{version}-darwin-amd64.tar.gz" sha256 "__SHA256_DARWIN_AMD64__" end end on_linux do if Hardware::CPU.intel? url "https://github.com/Trendyol/stove/releases/download/v#{version}/stove-#{version}-linux-amd64.tar.gz" sha256 "__SHA256_LINUX_AMD64__" end end def install bin.install "stove" end test do assert_match version.to_s, shell_output("#{bin}/stove --version") end end ================================================ FILE: tools/stove-cli/build.rs ================================================ use std::path::Path; use std::process::Command; fn main() -> Result<(), Box> { // ── Proto codegen ────────────────────────────────────────────── tonic_prost_build::configure() .build_server(true) .build_client(false) .compile_protos( &[ "../../lib/stove-dashboard-api/src/main/proto/stove/dashboard/v1/dashboard_events.proto", "../../lib/stove-dashboard-api/src/main/proto/stove/dashboard/v1/dashboard_service.proto", ], &["../../lib/stove-dashboard-api/src/main/proto/"], )?; // ── Version from gradle.properties ───────────────────────────── let gradle_props = std::fs::read_to_string("../../gradle.properties") .expect("Failed to read gradle.properties — is this running from tools/stove-cli?"); let version = gradle_props .lines() .find_map(|line| line.strip_prefix("version=")) .expect("No 'version=' line found in gradle.properties"); println!("cargo:rustc-env=STOVE_VERSION={version}"); println!("cargo:rerun-if-changed=../../gradle.properties"); // ── Build SPA if needed ──────────────────────────────────────── build_spa(); Ok(()) } /// Build the SPA when `spa/dist/index.html` is missing or SPA sources changed. /// Skipped if `SKIP_SPA_BUILD=1` (useful for CI when SPA is pre-built). fn build_spa() { if std::env::var("SKIP_SPA_BUILD").unwrap_or_default() == "1" { return; } let spa_dir = Path::new("spa"); // Rebuild when any SPA source file changes println!("cargo:rerun-if-changed=spa/src"); println!("cargo:rerun-if-changed=spa/index.html"); println!("cargo:rerun-if-changed=spa/package.json"); if !spa_dir.join("package.json").exists() { eprintln!("cargo:warning=spa/package.json not found — skipping SPA build"); return; } // Install deps if node_modules is missing if !spa_dir.join("node_modules").exists() { run_npm(spa_dir, &["install"]); } // Always rebuild — cargo only re-runs build.rs when spa/src changes, // and Vite's own caching keeps no-op builds fast. run_npm(spa_dir, &["run", "build"]); } fn run_npm(dir: &Path, args: &[&str]) { let status = Command::new("npm") .args(args) .current_dir(dir) .status() .unwrap_or_else(|e| panic!("Failed to run npm {}: {e}", args.join(" "))); assert!(status.success(), "npm {} failed", args.join(" ")); } ================================================ FILE: tools/stove-cli/clippy.toml ================================================ too-many-arguments-threshold = 8 ================================================ FILE: tools/stove-cli/install.sh ================================================ #!/usr/bin/env sh # Stove CLI installer (https://github.com/Trendyol/stove) # # Usage: # curl -fsSL https://raw.githubusercontent.com/Trendyol/stove/main/tools/stove-cli/install.sh | sh # curl -fsSL ... | sh -s -- --version 0.23.0 # curl -fsSL ... | sh -s -- --dir /usr/local/bin set -eu REPO="Trendyol/stove" BINARY_NAME="stove" INSTALL_DIR="${STOVE_INSTALL_DIR:-}" VERSION="" # ── Parse arguments ───────────────────────────────────────────────── while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2 ;; --dir) INSTALL_DIR="$2"; shift 2 ;; *) echo "Unknown option: $1"; exit 1 ;; esac done # ── Detect platform ──────────────────────────────────────────────── detect_platform() { OS="$(uname -s)" ARCH="$(uname -m)" case "$OS" in Darwin) OS_LABEL="darwin" ;; Linux) OS_LABEL="linux" ;; *) echo "Error: Unsupported OS: $OS"; exit 1 ;; esac case "$ARCH" in arm64|aarch64) ARCH_LABEL="arm64" ;; x86_64|amd64) ARCH_LABEL="amd64" ;; *) echo "Error: Unsupported architecture: $ARCH"; exit 1 ;; esac # Linux arm64 is not currently built if [ "$OS_LABEL" = "linux" ] && [ "$ARCH_LABEL" = "arm64" ]; then echo "Error: Linux arm64 binaries are not available yet." echo "Supported platforms: macOS (arm64, amd64), Linux (amd64)" exit 1 fi PLATFORM="${OS_LABEL}-${ARCH_LABEL}" } # ── Resolve latest version ───────────────────────────────────────── resolve_version() { if [ -n "$VERSION" ]; then return fi echo "Fetching latest release..." VERSION=$( curl -fsSL "https://api.github.com/repos/${REPO}/releases" \ | grep -o '"tag_name":\s*"v[^"]*"' \ | head -1 \ | sed 's/.*"v\([^"]*\)".*/\1/' ) if [ -z "$VERSION" ]; then echo "Error: Could not determine latest version. Specify --version manually." exit 1 fi } # ── Resolve install directory ────────────────────────────────────── resolve_install_dir() { if [ -n "$INSTALL_DIR" ]; then return fi if [ -d "/usr/local/bin" ] && [ -w "/usr/local/bin" ]; then INSTALL_DIR="/usr/local/bin" elif [ -d "$HOME/.local/bin" ]; then INSTALL_DIR="$HOME/.local/bin" else mkdir -p "$HOME/.local/bin" INSTALL_DIR="$HOME/.local/bin" fi } # ── Download and install ─────────────────────────────────────────── install() { ARCHIVE="stove-${VERSION}-${PLATFORM}.tar.gz" DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${ARCHIVE}" CHECKSUM_URL="${DOWNLOAD_URL}.sha256" TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT echo "Downloading ${BINARY_NAME} v${VERSION} for ${PLATFORM}..." curl -fsSL -o "${TMPDIR}/${ARCHIVE}" "$DOWNLOAD_URL" curl -fsSL -o "${TMPDIR}/${ARCHIVE}.sha256" "$CHECKSUM_URL" # Verify checksum echo "Verifying checksum..." cd "$TMPDIR" if command -v sha256sum >/dev/null 2>&1; then sha256sum -c "${ARCHIVE}.sha256" elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -c "${ARCHIVE}.sha256" else echo "Warning: No sha256sum or shasum found, skipping checksum verification." fi # Extract tar xzf "${ARCHIVE}" # Install if [ -w "$INSTALL_DIR" ]; then mv "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" else echo "Installing to ${INSTALL_DIR} (requires sudo)..." sudo mv "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" fi chmod +x "${INSTALL_DIR}/${BINARY_NAME}" } # ── Main ─────────────────────────────────────────────────────────── main() { detect_platform resolve_version resolve_install_dir install echo "" echo "Stove CLI v${VERSION} installed to ${INSTALL_DIR}/${BINARY_NAME}" # Check if install dir is in PATH case ":$PATH:" in *":${INSTALL_DIR}:"*) ;; *) echo "" echo "Note: ${INSTALL_DIR} is not in your PATH." echo "Add it with: export PATH=\"${INSTALL_DIR}:\$PATH\"" ;; esac } main ================================================ FILE: tools/stove-cli/rustfmt.toml ================================================ edition = "2024" tab_spaces = 2 ================================================ FILE: tools/stove-cli/spa/biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "linter": { "enabled": true, "rules": { "style": { "noNonNullAssertion": "off" }, "suspicious": { "noUnknownAtRules": "off" } } }, "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always", "trailingCommas": "all" } }, "css": { "parser": { "cssModules": false, "tailwindDirectives": true } }, "files": { "includes": ["src/**"] } } ================================================ FILE: tools/stove-cli/spa/index.html ================================================ Stove Dashboard
================================================ FILE: tools/stove-cli/spa/package.json ================================================ { "name": "stove-dashboard-spa", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "check": "biome check src", "format": "biome check --write src" }, "dependencies": { "@dagrejs/dagre": "^3.0.0", "@tanstack/react-query": "^5.80.0", "@xyflow/react": "^12.10.2", "react": "^19.1.0", "react-dom": "^19.1.0" }, "devDependencies": { "@biomejs/biome": "2.4.15", "@tailwindcss/postcss": "^4.0.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^6.0.0", "tailwindcss": "^4.0.0", "typescript": "~6.0.0", "vite": "^8.0.0" } } ================================================ FILE: tools/stove-cli/spa/postcss.config.js ================================================ export default { plugins: { "@tailwindcss/postcss": {}, }, }; ================================================ FILE: tools/stove-cli/spa/src/App.tsx ================================================ import { VersionMismatchBanner } from "./components/VersionMismatchBanner"; import { useAppData } from "./hooks/useAppData"; import { Header } from "./layout/Header"; import { Sidebar } from "./layout/Sidebar"; import { TestDetail } from "./layout/TestDetail"; export default function App() { const { apps, activeApp, latestRun, tests, selectedTest, liveConnected, mismatchedApps, versionMismatchSummary, selectApp, selectTest, } = useAppData(); return (
{versionMismatchSummary ? : null}
{latestRun && selectedTest ? ( ) : (
{apps.length === 0 ? "Waiting for test events..." : "Select a test to view details"}
)}
); } ================================================ FILE: tools/stove-cli/spa/src/api/client.ts ================================================ import type { AppSummary, Entry, MetaResponse, Run, Snapshot, Span, Test } from "./types"; const BASE = "/api/v1"; const encodePath = (value: string) => encodeURIComponent(value); async function get(url: string): Promise { const res = await fetch(`${BASE}${url}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } async function del(url: string): Promise { const res = await fetch(`${BASE}${url}`, { method: "DELETE" }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); } export const api = { getMeta: () => get("/meta"), getApps: () => get("/apps"), getRuns: (app?: string) => get(app ? `/runs?app=${encodeURIComponent(app)}` : "/runs"), getRun: (runId: string) => get(`/runs/${encodePath(runId)}`), getTests: (runId: string) => get(`/runs/${encodePath(runId)}/tests`), getEntries: (runId: string, testId: string) => get(`/runs/${encodePath(runId)}/tests/${encodePath(testId)}/entries`), getSpans: (runId: string, testId: string) => get(`/runs/${encodePath(runId)}/tests/${encodePath(testId)}/spans`), getSnapshots: (runId: string, testId: string) => get(`/runs/${encodePath(runId)}/tests/${encodePath(testId)}/snapshots`), getTrace: (traceId: string) => get(`/traces/${encodePath(traceId)}`), clearAll: () => del("/data"), }; ================================================ FILE: tools/stove-cli/spa/src/api/live-cache.ts ================================================ import type { QueryClient } from "@tanstack/react-query"; import type { Status } from "../utils/status"; import type { AppSummary, Entry, LiveDashboardEvent, Run, Snapshot, Span, Test } from "./types"; import { EVENT_TYPE } from "./types"; const RUNNING: Status = "RUNNING"; export function applyLiveDashboardEvent(queryClient: QueryClient, event: LiveDashboardEvent) { switch (event.event_type) { case EVENT_TYPE.RUN_STARTED: { const run: Run = { id: event.run_id, app_name: event.payload.app_name, started_at: event.payload.started_at, ended_at: null, status: RUNNING, total_tests: 0, passed: 0, failed: 0, duration_ms: null, stove_version: event.payload.stove_version, systems: event.payload.systems, }; queryClient.setQueryData(["apps"], (apps) => upsertAppSummary(apps, { app_name: event.payload.app_name, latest_run_id: event.run_id, latest_status: RUNNING, stove_version: event.payload.stove_version, total_runs: nextRunCount(apps, event.payload.app_name, event.run_id), }), ); queryClient.setQueryData(["runs", event.payload.app_name], (runs) => upsertRun(runs, run), ); queryClient.setQueryData(["tests", event.run_id], (tests) => tests ?? []); break; } case EVENT_TYPE.RUN_ENDED: { updateCachedRuns(queryClient, event.run_id, (run) => ({ ...run, ended_at: event.payload.ended_at, status: event.payload.status, total_tests: event.payload.total_tests, passed: event.payload.passed, failed: event.payload.failed, duration_ms: event.payload.duration_ms, })); queryClient.setQueryData( ["apps"], (apps) => apps?.map((app) => app.latest_run_id === event.run_id ? { ...app, latest_status: event.payload.status } : app, ) ?? apps, ); break; } case EVENT_TYPE.TEST_STARTED: { const test: Test = { id: event.payload.test_id, run_id: event.run_id, test_name: event.payload.test_name, spec_name: event.payload.spec_name, test_path: event.payload.test_path ?? [], started_at: event.payload.started_at, ended_at: null, status: event.payload.status, duration_ms: null, error: null, }; queryClient.setQueryData(["tests", event.run_id], (tests) => upsertTest(tests, test)); queryClient.setQueryData( ["entries", event.run_id, event.payload.test_id], (entries) => entries ?? [], ); queryClient.setQueryData( ["spans", event.run_id, event.payload.test_id], (spans) => spans ?? [], ); queryClient.setQueryData( ["snapshots", event.run_id, event.payload.test_id], (snapshots) => snapshots ?? [], ); break; } case EVENT_TYPE.TEST_ENDED: { updateCachedTests(queryClient, event.run_id, event.payload.test_id, (test) => ({ ...test, ended_at: event.payload.ended_at, status: event.payload.status, duration_ms: event.payload.duration_ms, error: event.payload.error, })); break; } case EVENT_TYPE.ENTRY_RECORDED: { const entry: Entry = { id: event.payload.id, run_id: event.run_id, test_id: event.payload.test_id, timestamp: event.payload.timestamp, system: event.payload.system, action: event.payload.action, result: event.payload.result, input: event.payload.input, output: event.payload.output, metadata: event.payload.metadata, expected: event.payload.expected, actual: event.payload.actual, error: event.payload.error, trace_id: event.payload.trace_id, }; queryClient.setQueryData( ["entries", event.run_id, event.payload.test_id], (entries) => appendEntries(entries, entry), ); if (event.payload.trace_id) { const traceSpans = queryClient.getQueryData(["trace", event.payload.trace_id]); if (traceSpans?.length) { queryClient.setQueryData( ["spans", event.run_id, event.payload.test_id], (spans) => mergeSpans(spans, traceSpans), ); } } break; } case EVENT_TYPE.SPAN_RECORDED: { const span: Span = { id: event.payload.id, run_id: event.run_id, trace_id: event.payload.trace_id, span_id: event.payload.span_id, parent_span_id: event.payload.parent_span_id, operation_name: event.payload.operation_name, service_name: event.payload.service_name, start_time_nanos: event.payload.start_time_nanos, end_time_nanos: event.payload.end_time_nanos, status: event.payload.status, attributes: event.payload.attributes, exception_type: event.payload.exception_type, exception_message: event.payload.exception_message, exception_stack_trace: event.payload.exception_stack_trace, }; queryClient.setQueryData(["trace", event.payload.trace_id], (trace) => appendSpan(trace, span), ); const testId = event.payload.test_id ?? findTestIdForTrace(queryClient, event.run_id, event.payload.trace_id); if (testId) { queryClient.setQueryData(["spans", event.run_id, testId], (spans) => appendSpan(spans, span), ); } break; } case EVENT_TYPE.SNAPSHOT: { const snapshot: Snapshot = { id: event.payload.id, run_id: event.run_id, test_id: event.payload.test_id, system: event.payload.system, state_json: event.payload.state_json, summary: event.payload.summary, }; queryClient.setQueryData( ["snapshots", event.run_id, event.payload.test_id], (snapshots) => appendSnapshots(snapshots, snapshot), ); break; } } } export function invalidateDashboardQueries(queryClient: QueryClient, runId?: string) { queryClient.invalidateQueries({ queryKey: ["apps"] }); queryClient.invalidateQueries({ queryKey: ["runs"] }); if (runId) { queryClient.invalidateQueries({ queryKey: ["tests", runId] }); queryClient.invalidateQueries({ queryKey: ["entries", runId] }); queryClient.invalidateQueries({ queryKey: ["spans", runId] }); queryClient.invalidateQueries({ queryKey: ["snapshots", runId] }); } else { queryClient.invalidateQueries(); } } function upsertAppSummary(apps: AppSummary[] | undefined, incoming: AppSummary): AppSummary[] { return [...(apps ?? []).filter((app) => app.app_name !== incoming.app_name), incoming].sort( (left, right) => left.app_name.localeCompare(right.app_name), ); } function nextRunCount(apps: AppSummary[] | undefined, appName: string, runId: string): number { const existing = apps?.find((app) => app.app_name === appName); if (!existing) { return 1; } return existing.latest_run_id === runId ? existing.total_runs : existing.total_runs + 1; } function upsertRun(runs: Run[] | undefined, incoming: Run): Run[] { return [...(runs ?? []).filter((run) => run.id !== incoming.id), incoming].sort(compareRuns); } function upsertTest(tests: Test[] | undefined, incoming: Test): Test[] { return [...(tests ?? []).filter((test) => test.id !== incoming.id), incoming].sort(compareTests); } function updateCachedRuns(queryClient: QueryClient, runId: string, updater: (run: Run) => Run) { for (const [queryKey, runs] of queryClient.getQueriesData({ queryKey: ["runs"] })) { if (!runs?.some((run) => run.id === runId)) { continue; } queryClient.setQueryData( queryKey, runs.map((run) => (run.id === runId ? updater(run) : run)).sort(compareRuns), ); } } function updateCachedTests( queryClient: QueryClient, runId: string, testId: string, updater: (test: Test) => Test, ) { queryClient.setQueryData( ["tests", runId], (tests) => tests?.map((test) => (test.id === testId ? updater(test) : test)).sort(compareTests) ?? tests, ); } function appendEntries(entries: Entry[] | undefined, incoming: Entry): Entry[] { if (entries?.some((entry) => entry.id === incoming.id)) { return entries; } return [...(entries ?? []), incoming].sort((left, right) => left.timestamp.localeCompare(right.timestamp), ); } function appendSpan(spans: Span[] | undefined, incoming: Span): Span[] { if (spans?.some((span) => isSameSpan(span, incoming))) { return spans; } return [...(spans ?? []), incoming].sort( (left, right) => left.start_time_nanos - right.start_time_nanos, ); } function mergeSpans(existing: Span[] | undefined, incoming: Span[]): Span[] { return incoming.reduce((acc, span) => appendSpan(acc, span), existing ?? []); } function appendSnapshots(snapshots: Snapshot[] | undefined, incoming: Snapshot): Snapshot[] { if ( snapshots?.some( (snapshot) => snapshot.system === incoming.system && snapshot.summary === incoming.summary && snapshot.state_json === incoming.state_json, ) ) { return snapshots; } return [...(snapshots ?? []), incoming]; } function compareRuns(left: Run, right: Run): number { return right.started_at.localeCompare(left.started_at) || right.id.localeCompare(left.id); } function compareTests(left: Test, right: Test): number { return left.started_at.localeCompare(right.started_at) || left.id.localeCompare(right.id); } function isSameSpan(left: Span, right: Span): boolean { return left.trace_id === right.trace_id && left.span_id === right.span_id; } function findTestIdForTrace( queryClient: QueryClient, runId: string, traceId: string, ): string | null { for (const [queryKey, entries] of queryClient.getQueriesData({ queryKey: ["entries", runId], })) { if (!entries?.some((entry) => entry.trace_id === traceId)) { continue; } if (Array.isArray(queryKey) && typeof queryKey[2] === "string") { return queryKey[2]; } } return null; } ================================================ FILE: tools/stove-cli/spa/src/api/sse.ts ================================================ import { useEffect, useRef, useState } from "react"; import type { LiveDashboardEvent } from "./types"; interface UseSSEOptions { onEvent: (event: LiveDashboardEvent) => void; onGap?: (event: LiveDashboardEvent) => void; onReconnect?: () => void; onDisconnect?: () => void; } export function useSSE({ onEvent, onGap, onReconnect, onDisconnect }: UseSSEOptions) { const callbacksRef = useRef({ onEvent, onGap, onReconnect, onDisconnect }); const lastSeqRef = useRef(null); const hasConnectedRef = useRef(false); const openRef = useRef(false); const [connected, setConnected] = useState(false); callbacksRef.current = { onEvent, onGap, onReconnect, onDisconnect }; useEffect(() => { let disposed = false; let reconnectTimer: ReturnType | null = null; let source: EventSource | null = null; function connect() { if (disposed) { return; } source = new EventSource("/api/v1/events/stream"); source.onopen = () => { const isReconnect = hasConnectedRef.current; hasConnectedRef.current = true; openRef.current = true; setConnected(true); if (isReconnect) { lastSeqRef.current = null; callbacksRef.current.onReconnect?.(); } }; source.onmessage = (message) => { try { const event: LiveDashboardEvent = JSON.parse(message.data); if ( typeof event.seq !== "number" || typeof event.run_id !== "string" || typeof event.event_type !== "string" ) { return; } if (lastSeqRef.current !== null && event.seq !== lastSeqRef.current + 1) { callbacksRef.current.onGap?.(event); } lastSeqRef.current = event.seq; callbacksRef.current.onEvent(event); } catch { // Ignore malformed events } }; source.onerror = () => { source?.close(); source = null; if (openRef.current) { openRef.current = false; setConnected(false); callbacksRef.current.onDisconnect?.(); } if (!disposed) { reconnectTimer = setTimeout(connect, 3000); } }; } connect(); return () => { disposed = true; if (reconnectTimer != null) { clearTimeout(reconnectTimer); } openRef.current = false; setConnected(false); source?.close(); }; }, []); return { connected }; } ================================================ FILE: tools/stove-cli/spa/src/api/types.ts ================================================ import type { Status } from "../utils/status"; export type { Status }; export const EVENT_TYPE = { RUN_STARTED: "run_started", RUN_ENDED: "run_ended", TEST_STARTED: "test_started", TEST_ENDED: "test_ended", ENTRY_RECORDED: "entry_recorded", SPAN_RECORDED: "span_recorded", SNAPSHOT: "snapshot", } as const; export type EventType = (typeof EVENT_TYPE)[keyof typeof EVENT_TYPE]; export interface AppSummary { app_name: string; latest_run_id: string; latest_status: Status; stove_version: string | null; total_runs: number; } export interface MetaResponse { stove_cli_version: string; } export type LiveRecordId = number | string; export interface Run { id: string; app_name: string; started_at: string; ended_at: string | null; status: Status; total_tests: number; passed: number; failed: number; duration_ms: number | null; stove_version: string | null; systems: string[]; } export interface Test { id: string; run_id: string; test_name: string; spec_name: string; test_path: string[]; started_at: string; ended_at: string | null; status: Status; duration_ms: number | null; error: string | null; } export interface Entry { id: LiveRecordId; run_id: string; test_id: string; timestamp: string; system: string; action: string; result: string; input: string | null; output: string | null; metadata: string | null; expected: string | null; actual: string | null; error: string | null; trace_id: string | null; } export interface Span { id: LiveRecordId; run_id: string; trace_id: string; span_id: string; parent_span_id: string | null; operation_name: string; service_name: string; start_time_nanos: number; end_time_nanos: number; status: Status; attributes: string | null; exception_type: string | null; exception_message: string | null; exception_stack_trace: string | null; } export interface Snapshot { id: LiveRecordId; run_id: string; test_id: string; system: string; state_json: string; summary: string; } export interface LiveRunStartedPayload { app_name: string; started_at: string; stove_version: string | null; systems: string[]; } export interface LiveRunEndedPayload { ended_at: string; status: Status; total_tests: number; passed: number; failed: number; duration_ms: number; } export interface LiveTestStartedPayload { test_id: string; test_name: string; spec_name: string; test_path: string[]; started_at: string; status: Status; } export interface LiveTestEndedPayload { test_id: string; status: Status; duration_ms: number; error: string | null; ended_at: string; } export interface LiveEntryRecordedPayload { id: LiveRecordId; test_id: string; timestamp: string; system: string; action: string; result: string; input: string | null; output: string | null; metadata: string | null; expected: string | null; actual: string | null; error: string | null; trace_id: string | null; } export interface LiveSpanRecordedPayload { id: LiveRecordId; test_id: string | null; trace_id: string; span_id: string; parent_span_id: string | null; operation_name: string; service_name: string; start_time_nanos: number; end_time_nanos: number; status: Status; attributes: string | null; exception_type: string | null; exception_message: string | null; exception_stack_trace: string | null; } export interface LiveSnapshotPayload { id: LiveRecordId; test_id: string; system: string; state_json: string; summary: string; } interface LiveEventBase { seq: number; run_id: string; } export type LiveDashboardEvent = | (LiveEventBase & { event_type: typeof EVENT_TYPE.RUN_STARTED; payload: LiveRunStartedPayload }) | (LiveEventBase & { event_type: typeof EVENT_TYPE.RUN_ENDED; payload: LiveRunEndedPayload }) | (LiveEventBase & { event_type: typeof EVENT_TYPE.TEST_STARTED; payload: LiveTestStartedPayload; }) | (LiveEventBase & { event_type: typeof EVENT_TYPE.TEST_ENDED; payload: LiveTestEndedPayload }) | (LiveEventBase & { event_type: typeof EVENT_TYPE.ENTRY_RECORDED; payload: LiveEntryRecordedPayload; }) | (LiveEventBase & { event_type: typeof EVENT_TYPE.SPAN_RECORDED; payload: LiveSpanRecordedPayload; }) | (LiveEventBase & { event_type: typeof EVENT_TYPE.SNAPSHOT; payload: LiveSnapshotPayload }); ================================================ FILE: tools/stove-cli/spa/src/components/Badge.tsx ================================================ import type { Status } from "../api/types"; import { useTheme } from "../hooks/useTheme"; import { isRunning } from "../utils/status"; interface BadgeProps { status: Status; } export function Badge({ status }: BadgeProps) { const { theme } = useTheme(); const upper = status.toUpperCase() as Status; const configs = theme === "dark" ? DARK : LIGHT; const config = configs[upper] ?? configs.DEFAULT; return ( {config.icon} {isRunning(upper) && ( )} {!isRunning(upper) && upper} ); } interface BadgeStyle { bg: string; text: string; icon: string; } const DARK: Record = { PASSED: { bg: "#06291e", text: "#34d399", icon: "\u2713 " }, FAILED: { bg: "#2d0a0a", text: "#f87171", icon: "\u2717 " }, ERROR: { bg: "#2d0a0a", text: "#f87171", icon: "\u2717 " }, RUNNING: { bg: "#0f1d2e", text: "#60a5fa", icon: "" }, DEFAULT: { bg: "#1e293b", text: "#94a3b8", icon: "" }, }; const LIGHT: Record = { PASSED: { bg: "#d1fae5", text: "#065f46", icon: "\u2713 " }, FAILED: { bg: "#fee2e2", text: "#991b1b", icon: "\u2717 " }, ERROR: { bg: "#fee2e2", text: "#991b1b", icon: "\u2717 " }, RUNNING: { bg: "#dbeafe", text: "#1e40af", icon: "" }, DEFAULT: { bg: "#e5e7eb", text: "#4b5563", icon: "" }, }; ================================================ FILE: tools/stove-cli/spa/src/components/CapturedStateLane.tsx ================================================ import type { Snapshot } from "../api/types"; import { describeJsonValue, getJsonPreviewKeys, parseJsonDeep } from "../utils/json"; import { getSystemInfo } from "../utils/systems"; interface CapturedStateLaneProps { snapshots: Snapshot[]; onSelect: (snapshot: Snapshot) => void; } export function CapturedStateLane({ snapshots, onSelect }: CapturedStateLaneProps) { if (snapshots.length === 0) { return null; } return (
Captured State
{snapshots.length} snapshot{snapshots.length === 1 ? "" : "s"}
{snapshots.map((snapshot) => ( ))}
); } function SnapshotLaneCard({ snapshot, onSelect, }: { snapshot: Snapshot; onSelect: (snapshot: Snapshot) => void; }) { const info = getSystemInfo(snapshot.system); const parsedState = parseJsonDeep(snapshot.state_json); const previewKeys = getJsonPreviewKeys(parsedState, 3); return ( ); } ================================================ FILE: tools/stove-cli/spa/src/components/Detail.tsx ================================================ import { tryFormatJson } from "../utils/json"; interface DetailProps { label: string; value: string; color?: string; } export function Detail({ label, value, color }: DetailProps) { return (
{label}:
        {tryFormatJson(value)}
      
); } ================================================ FILE: tools/stove-cli/spa/src/components/DurationEdge.tsx ================================================ import type { EdgeProps } from "@xyflow/react"; import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath } from "@xyflow/react"; import type { DurationEdgeData } from "../utils/flow"; import { formatDuration } from "../utils/format"; export function DurationEdge(props: EdgeProps) { const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd } = props; const d = props.data as DurationEdgeData | undefined; const [edgePath, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, }); const label = d?.label ?? (d?.durationMs != null && d.durationMs > 0 ? formatDuration(d.durationMs) : null); return ( <> {label && (
{label}
)} ); } ================================================ FILE: tools/stove-cli/spa/src/components/EntryDetails.tsx ================================================ import type { Entry } from "../api/types"; import { Detail } from "./Detail"; export function EntryDetails({ entry }: { entry: Entry }) { return ( <> {entry.input && } {entry.output && } {entry.expected && } {entry.actual && } {entry.error && } {entry.metadata && entry.metadata !== "{}" && ( )} ); } ================================================ FILE: tools/stove-cli/spa/src/components/EntryRow.tsx ================================================ import { useState } from "react"; import type { Entry } from "../api/types"; import { formatTimestamp } from "../utils/format"; import { EntryDetails } from "./EntryDetails"; import { ResultIcon } from "./ResultIcon"; import { SysBadge } from "./SysBadge"; interface EntryRowProps { entry: Entry; } export function EntryRow({ entry }: EntryRowProps) { const [expanded, setExpanded] = useState(false); const passed = entry.result === "PASSED"; return ( ); } ================================================ FILE: tools/stove-cli/spa/src/components/FlowDag.tsx ================================================ import { Controls, type Edge, type Node, type NodeMouseHandler, Panel, ReactFlow, useReactFlow, } from "@xyflow/react"; import { useCallback } from "react"; import type { FlowNodeData } from "../utils/flow"; import { DurationEdge } from "./DurationEdge"; import { GapNode } from "./GapNode"; import { SystemNode } from "./SystemNode"; const nodeTypes = { systemNode: SystemNode, gapNode: GapNode, }; const edgeTypes = { durationEdge: DurationEdge }; const defaultEdgeOptions = { animated: false }; const proOptions = { hideAttribution: true }; interface FlowDagProps { nodes: Node[]; edges: Edge[]; onNodeClick?: (nodeData: FlowNodeData) => void; compact?: boolean; } export function FlowDag({ nodes, edges, onNodeClick, compact }: FlowDagProps) { const { fitView } = useReactFlow(); const handleNodeClick: NodeMouseHandler = useCallback( (_, node) => { if (onNodeClick) { onNodeClick(node.data as FlowNodeData); } }, [onNodeClick], ); const handleCenterView = useCallback(() => { void fitView({ padding: 0.18, duration: 250 }); }, [fitView]); if (nodes.length === 0) { return (
No data to visualize
); } return ( {!compact && } {!compact && ( )} ); } ================================================ FILE: tools/stove-cli/spa/src/components/FlowTab.tsx ================================================ import { ReactFlowProvider } from "@xyflow/react"; import { useCallback, useMemo, useState } from "react"; import type { Entry, Snapshot, Span } from "../api/types"; import type { FlowNodeData, GapNodeData, SystemNodeData } from "../utils/flow"; import { applyDagreLayout, entriesToDag, spansToTraceDag } from "../utils/flow"; import { CapturedStateLane } from "./CapturedStateLane"; import { FlowDag } from "./FlowDag"; import { NodePopup } from "./NodePopup"; import { SnapshotStateDialog } from "./SnapshotStateDialog"; interface FlowTabProps { entries: Entry[]; spans: Span[]; snapshots: Snapshot[]; onOpenTraceTab?: (() => void) | undefined; } type FlowMode = "timeline" | "trace"; function modeButtonClass(active: boolean): string { return `px-2.5 py-1 rounded text-xs cursor-pointer border-0 ${ active ? "bg-[var(--stove-blue)] text-white" : "bg-stove-card text-[var(--stove-text-secondary)] hover:text-[var(--stove-text)]" }`; } export function FlowTab({ entries, spans, snapshots, onOpenTraceTab }: FlowTabProps) { const [mode, setMode] = useState("timeline"); const [selectedNode, setSelectedNode] = useState(null); const [selectedSnapshot, setSelectedSnapshot] = useState(null); const { nodes, edges } = useMemo(() => { if (mode === "trace" && spans.length > 0) { const dag = spansToTraceDag(spans); return { nodes: applyDagreLayout(dag.nodes, dag.edges), edges: dag.edges }; } const dag = entriesToDag(entries); return { nodes: applyDagreLayout(dag.nodes, dag.edges), edges: dag.edges }; }, [mode, entries, spans]); const handleNodeClick = useCallback((data: FlowNodeData) => { if (!data.inspectable) { return; } setSelectedNode(data); }, []); const handleOpenTraceTab = useCallback(() => { setSelectedNode(null); onOpenTraceTab?.(); }, [onOpenTraceTab]); const summary = useMemo(() => { if (mode === "trace") { return `${spans.length} spans`; } const stepCount = nodes.filter( (node) => node.type === "systemNode" && node.data.kind === "step", ).length; const arrangeCount = nodes.filter( (node) => node.type === "systemNode" && node.data.kind === "arrange", ).length; const gapNodes = nodes.filter((node) => node.type === "gapNode"); const gapCount = gapNodes.length; const totalGapMs = gapNodes.reduce( (sum, node) => sum + ((node.data as GapNodeData).durationMs ?? 0), 0, ); const parts = [`${stepCount} steps`]; if (arrangeCount > 0) { parts.push(`${arrangeCount} arrange`); } if (gapCount > 0) { parts.push(`${gapCount} waits`); } if (snapshots.length > 0) { parts.push(`${snapshots.length} snapshots`); } if (totalGapMs > 0) { parts.push(`${Math.round(totalGapMs / 100) / 10}s idle`); } return parts.join(" • "); }, [mode, nodes, snapshots.length, spans.length]); if (entries.length === 0 && spans.length === 0 && snapshots.length === 0) { return (
No data to visualize
); } return (
{spans.length > 0 && ( )}
{summary}
{mode === "timeline" && ( )} {selectedNode && ( setSelectedNode(null)} onOpenTrace={selectedNode.traceId ? handleOpenTraceTab : undefined} /> )} {selectedSnapshot && ( setSelectedSnapshot(null)} /> )}
); } ================================================ FILE: tools/stove-cli/spa/src/components/GapNode.tsx ================================================ import type { NodeProps } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react"; import type { GapNodeData } from "../utils/flow"; import { formatDuration, formatTimestamp } from "../utils/format"; export function GapNode({ data }: NodeProps) { const d = data as GapNodeData; return (
{d.label}
{formatDuration(d.durationMs)}
{formatTimestamp(d.startedAt)} to {formatTimestamp(d.endedAt)}
); } ================================================ FILE: tools/stove-cli/spa/src/components/JsonTree.tsx ================================================ import { useState } from "react"; interface JsonTreeProps { value: unknown; defaultExpandedDepth?: number; searchQuery?: string; } export function JsonTree({ value, defaultExpandedDepth = 1, searchQuery = "" }: JsonTreeProps) { return (
); } interface JsonTreeNodeProps { value: unknown; depth: number; label: string; defaultExpandedDepth: number; searchQuery: string; } function JsonTreeNode({ value, depth, label, defaultExpandedDepth, searchQuery, }: JsonTreeNodeProps) { const expandable = isExpandable(value); const [expanded, setExpanded] = useState(depth < defaultExpandedDepth); const hasActiveSearch = searchQuery.trim().length > 0; const effectiveExpanded = hasActiveSearch || expanded; if (!expandable) { return (
{": "}
); } const children = getChildren(value); const opening = Array.isArray(value) ? "[" : "{"; const closing = Array.isArray(value) ? "]" : "}"; const summary = Array.isArray(value) ? `${children.length} item${children.length === 1 ? "" : "s"}` : `${children.length} key${children.length === 1 ? "" : "s"}`; return (
{effectiveExpanded && ( <> {children.length === 0 ? (
empty
) : ( children.map(([childLabel, childValue]) => ( )) )}
{closing}
)}
); } function JsonPrimitive({ value, searchQuery }: { value: unknown; searchQuery: string }) { if (typeof value === "string") { return ( ); } if (typeof value === "number") { return ( ); } if (typeof value === "boolean") { return ( ); } if (value === null) { return ( ); } return ( ); } function isExpandable(value: unknown): value is Record | unknown[] { return Array.isArray(value) || (typeof value === "object" && value !== null); } function getChildren(value: Record | unknown[]): Array<[string, unknown]> { if (Array.isArray(value)) { return value.map((item, index) => [`[${index}]`, item]); } return Object.entries(value); } function renderCollapsedPreview(children: Array<[string, unknown]>, isArray: boolean): string { if (children.length === 0) { return ""; } const preview = children .slice(0, 3) .map(([label]) => label) .join(", "); const suffix = children.length > 3 ? ", ..." : ""; return isArray ? `${preview}${suffix}` : `${preview}${suffix}`; } function HighlightedText({ text, query }: { text: string; query: string }) { const normalizedQuery = query.trim(); if (!normalizedQuery) { return text; } const parts = splitByQuery(text, normalizedQuery); return parts.map((part) => part.match ? ( {part.text} ) : ( {part.text} ), ); } function splitByQuery( text: string, query: string, ): Array<{ text: string; match: boolean; start: number }> { const escapedQuery = escapeRegExp(query); const regex = new RegExp(`(${escapedQuery})`, "gi"); let offset = 0; return text .split(regex) .filter((part) => part.length > 0) .map((part) => { const segment = { text: part, match: part.toLowerCase() === query.toLowerCase(), start: offset, }; offset += part.length; return segment; }); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } ================================================ FILE: tools/stove-cli/spa/src/components/NodePopup.tsx ================================================ import { useEffect } from "react"; import type { Entry } from "../api/types"; import { EntryDetails } from "./EntryDetails"; import { ResultIcon } from "./ResultIcon"; interface NodePopupProps { entries: Entry[]; traceId: string | null; onClose: () => void; onOpenTrace?: (() => void) | undefined; } export function NodePopup({ entries, traceId, onClose, onOpenTrace }: NodePopupProps) { useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }} onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} role="dialog" >
Entry Details
{entries.length > 0 && (
{entries.map((entry) => (
{entry.action}
{entries.length > 1 &&
}
))}
)} {traceId && (
Trace Context
Trace Id
{traceId}
Full trace inspection lives in the Trace tab.
{onOpenTrace && ( )}
)}
); } ================================================ FILE: tools/stove-cli/spa/src/components/ResultIcon.tsx ================================================ import { getResultTone } from "../utils/result"; export function ResultIcon({ result }: { result: string }) { const tone = getResultTone(result); const color = tone === "failed" ? "var(--stove-red)" : tone === "success" ? "var(--stove-green)" : "var(--stove-text-secondary)"; const icon = tone === "failed" ? "\u2717" : tone === "success" ? "\u2713" : "\u2022"; return {icon}; } ================================================ FILE: tools/stove-cli/spa/src/components/SnapshotCards.tsx ================================================ import { useState } from "react"; import type { Snapshot } from "../api/types"; import { describeJsonValue, getJsonPreviewKeys, parseJsonDeep } from "../utils/json"; import { getKafkaSnapshotMetrics } from "../utils/snapshot-state"; import { getSystemInfo } from "../utils/systems"; import { SnapshotMetricTiles } from "./SnapshotMetricTiles"; import { SnapshotStateDialog } from "./SnapshotStateDialog"; interface SnapshotCardsProps { snapshots: Snapshot[]; hiddenCount?: number; } export function SnapshotCards({ snapshots, hiddenCount = 0 }: SnapshotCardsProps) { const [selectedSnapshot, setSelectedSnapshot] = useState(null); if (snapshots.length === 0) { return (
{hiddenCount > 0 ? "No detailed snapshots captured" : "No snapshots captured"}
); } return (
{snapshots.map((snap) => { return ( setSelectedSnapshot(snap)} /> ); })}
{selectedSnapshot && ( setSelectedSnapshot(null)} /> )}
); } function DetailedSnapshotCard({ snapshot, onOpen }: { snapshot: Snapshot; onOpen: () => void }) { const info = getSystemInfo(snapshot.system); const parsedState = parseJsonDeep(snapshot.state_json); const previewKeys = getJsonPreviewKeys(parsedState); const kafkaMetrics = snapshot.system === "Kafka" ? getKafkaSnapshotMetrics(snapshot, parsedState) : []; return (
{info.icon} {snapshot.system}
        {snapshot.summary}
      
{kafkaMetrics.length > 0 && }
State {parsedState ? describeJsonValue(parsedState) : "raw text"}
{previewKeys.length > 0 ? (
{previewKeys.map((key) => ( {key} ))}
) : (
Open the state explorer to inspect the captured payload.
)}
); } function HiddenSnapshotNotice({ hiddenCount, className = "", boxed = false, }: { hiddenCount: number; className?: string; boxed?: boolean; }) { if (hiddenCount === 0) { return null; } return (
{hiddenCount} system{hiddenCount === 1 ? "" : "s"} had no detailed state.
); } ================================================ FILE: tools/stove-cli/spa/src/components/SnapshotMetricTiles.tsx ================================================ import type { SnapshotMetric } from "../utils/snapshot-state"; interface SnapshotMetricTilesProps { metrics: SnapshotMetric[]; compact?: boolean; } export function SnapshotMetricTiles({ metrics, compact = false }: SnapshotMetricTilesProps) { if (metrics.length === 0) { return null; } return (
{metrics.map((metric) => ( ))}
); } function SnapshotMetricTile({ metric, compact }: { metric: SnapshotMetric; compact: boolean }) { const style = toneStyle(metric.tone); return (
{metric.label}
{metric.value}
); } function toneStyle(tone: SnapshotMetric["tone"]): { border: string; label: string; value: string; } { switch (tone) { case "info": return { border: "var(--stove-blue)", label: "var(--stove-blue)", value: "var(--stove-blue)", }; case "success": return { border: "var(--stove-green)", label: "var(--stove-green)", value: "var(--stove-green)", }; case "danger": return { border: "var(--stove-red)", label: "var(--stove-red)", value: "var(--stove-red)", }; case "warning": return { border: "var(--stove-amber)", label: "var(--stove-amber)", value: "var(--stove-amber)", }; default: return { border: "var(--stove-border)", label: "var(--stove-text-secondary)", value: "var(--stove-text)", }; } } ================================================ FILE: tools/stove-cli/spa/src/components/SnapshotStateDialog.tsx ================================================ import { useEffect, useMemo, useState } from "react"; import type { Snapshot } from "../api/types"; import { describeJsonValue, filterJsonByQuery, parseJsonDeep, tryFormatJsonDeep, } from "../utils/json"; import { getKafkaSnapshotMetrics, hasDetailedSnapshotState } from "../utils/snapshot-state"; import { getSystemInfo } from "../utils/systems"; import { JsonTree } from "./JsonTree"; import { SnapshotMetricTiles } from "./SnapshotMetricTiles"; interface SnapshotStateDialogProps { snapshot: Snapshot; onClose: () => void; } export function SnapshotStateDialog({ snapshot, onClose }: SnapshotStateDialogProps) { const info = getSystemInfo(snapshot.system); const parsedState = parseJsonDeep(snapshot.state_json); const hasDetailedState = hasDetailedSnapshotState(snapshot, parsedState); const kafkaMetrics = snapshot.system === "Kafka" ? getKafkaSnapshotMetrics(snapshot, parsedState) : []; const [searchQuery, setSearchQuery] = useState(""); const normalizedSearchQuery = searchQuery.trim(); const searchResult = useMemo(() => { if (parsedState === null) { return { filteredValue: null, matchCount: 0 }; } return filterJsonByQuery(parsedState, normalizedSearchQuery); }, [normalizedSearchQuery, parsedState]); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }} onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} role="dialog" >
{info.icon} {snapshot.system} State
{snapshot.summary}
{hasDetailedState ? parsedState ? describeJsonValue(parsedState) : "raw text" : "no details"} {hasDetailedState ? "Detailed state" : "Summary only"}
{kafkaMetrics.length > 0 && } {hasDetailedState && parsedState ? ( <>
setSearchQuery(e.target.value)} placeholder="Filter by any key or value" className="min-w-0 flex-1 rounded-md border border-stove-border bg-stove-surface px-3 py-2 text-sm text-[var(--stove-text)] outline-none placeholder:text-[var(--stove-text-muted)] focus:border-[var(--stove-blue)]" /> {normalizedSearchQuery && ( )}
{normalizedSearchQuery ? `${searchResult.matchCount} match${searchResult.matchCount === 1 ? "" : "es"}` : "Type to narrow the state by any property name or value"}
{searchResult.filteredValue !== null ? ( ) : (
No matches in this state payload.
)} ) : hasDetailedState ? (
              {tryFormatJsonDeep(snapshot.state_json)}
            
) : (
This snapshot only recorded the summary. There is no detailed state payload to inspect.
)} {hasDetailedState && (
Raw JSON
                {tryFormatJsonDeep(snapshot.state_json)}
              
)}
); } ================================================ FILE: tools/stove-cli/spa/src/components/SpanTree.tsx ================================================ import { useMemo, useState } from "react"; import type { Span } from "../api/types"; import { formatNanosDuration } from "../utils/format"; import { parseAttrs } from "../utils/json"; import { getResultTone, isFailed } from "../utils/result"; interface SpanTreeProps { spans: Span[]; } interface SpanNode { span: Span; children: SpanNode[]; } export function SpanTree({ spans }: SpanTreeProps) { const tree = useMemo(() => buildTree(spans), [spans]); const totalFailed = spans.filter((s) => isFailed(s.status)).length; const totalNeutral = spans.filter((s) => getResultTone(s.status) === "neutral").length; if (spans.length === 0) { return
No spans recorded
; } return (
{tree.map((node) => ( ))}
{spans.length} spans {totalFailed > 0 && {totalFailed} failed} {totalNeutral > 0 && {totalNeutral} unset} {tree[0] && root: {tree[0].span.operation_name}}
); } function SpanNodeView({ node, depth }: { node: SpanNode; depth: number }) { const [collapsed, setCollapsed] = useState(false); const s = node.span; const tone = getResultTone(s.status); const isError = tone === "failed"; const duration = formatNanosDuration(s.start_time_nanos, s.end_time_nanos); const attrs = parseAttrs(s.attributes); const relevantAttrs = Object.entries(attrs).filter(([k]) => ["db.", "http.", "rpc.", "messaging."].some((p) => k.startsWith(p)), ); const statusColor = tone === "failed" ? "var(--stove-red)" : tone === "success" ? "var(--stove-green)" : "var(--stove-text-secondary)"; const statusIcon = tone === "failed" ? "\u2717" : tone === "success" ? "\u2713" : "\u2022"; return (
{!collapsed && ( <> {isError && s.exception_type && (
{s.exception_type}: {s.exception_message} {s.exception_stack_trace && (
                  {s.exception_stack_trace}
                
)}
)} {relevantAttrs.length > 0 && (
{relevantAttrs.map(([k, v]) => ( {k}={v} ))}
)} {node.children.map((child) => ( ))} )}
); } function buildTree(spans: Span[]): SpanNode[] { const map = new Map(); const roots: SpanNode[] = []; for (const span of spans) { map.set(span.span_id, { span, children: [] }); } for (const span of spans) { const node = map.get(span.span_id); if (!node) continue; const parent = span.parent_span_id ? map.get(span.parent_span_id) : undefined; if (parent) { parent.children.push(node); } else { roots.push(node); } } return roots; } ================================================ FILE: tools/stove-cli/spa/src/components/SysBadge.tsx ================================================ import { getSystemInfo } from "../utils/systems"; interface SysBadgeProps { system: string; } export function SysBadge({ system }: SysBadgeProps) { const info = getSystemInfo(system); return ( {info.icon} {system} ); } ================================================ FILE: tools/stove-cli/spa/src/components/SystemNode.tsx ================================================ import type { NodeProps } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react"; import type { SystemNodeData } from "../utils/flow"; import { formatDuration, formatTimestamp } from "../utils/format"; import { isFailed } from "../utils/result"; import { getSystemInfo } from "../utils/systems"; import { ResultIcon } from "./ResultIcon"; import { SysBadge } from "./SysBadge"; export function SystemNode({ data }: NodeProps) { const d = data as SystemNodeData; const info = getSystemInfo(d.system); const failed = isFailed(d.result); const isArrange = d.kind === "arrange"; const sizeClass = d.kind === "trace" ? "w-[240px] min-h-[120px]" : "w-[240px] min-h-[128px]"; return (
{isArrange && ( arrange )} {d.count > 1 && ( {d.count}x )}
{d.action}
{(d.startedAt || d.durationMs) && (
{d.startedAt && {formatTimestamp(d.startedAt)}} {d.durationMs != null && d.durationMs > 0 && {formatDuration(d.durationMs)}}
)} {failed && d.error && (
{d.error}
)}
); } ================================================ FILE: tools/stove-cli/spa/src/components/VersionMismatchBanner.tsx ================================================ import { buildVersionMismatchBannerModel, type VersionMismatchSummary, } from "../utils/version-mismatch"; interface VersionMismatchBannerProps { summary: VersionMismatchSummary; } export function VersionMismatchBanner({ summary }: VersionMismatchBannerProps) { const model = buildVersionMismatchBannerModel(summary); const affectedApps = model.affectedApps.join(", "); return (

{model.title}

Affected apps: {affectedApps}. {model.switchHint ? ` ${model.switchHint}` : null}

{model.selectedAppName ? (

Selected app: {model.selectedAppName}

Runtime version:{" "} {model.runtimeVersion ?? "not reported"} {" · "} CLI version: {model.cliVersion}

{model.remediationSteps.map((step) => (

{step.kind === "command" ? ( {step.value} ) : ( step.value )}

))}
) : null}
); } ================================================ FILE: tools/stove-cli/spa/src/hooks/useAppData.ts ================================================ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useState } from "react"; import { api } from "../api/client"; import { applyLiveDashboardEvent, invalidateDashboardQueries } from "../api/live-cache"; import { useSSE } from "../api/sse"; import { EVENT_TYPE, type LiveDashboardEvent } from "../api/types"; import { isRunning } from "../utils/status"; import { summarizeVersionMismatches } from "../utils/version-mismatch"; export function useAppData() { const queryClient = useQueryClient(); const [selectedApp, setSelectedApp] = useState(null); const [selectedTestId, setSelectedTestId] = useState(null); const handleLiveEvent = useCallback( (event: LiveDashboardEvent) => { applyLiveDashboardEvent(queryClient, event); if (event.event_type === EVENT_TYPE.RUN_STARTED) { setSelectedApp(event.payload.app_name); setSelectedTestId(null); } }, [queryClient], ); const { connected: liveConnected } = useSSE({ onEvent: handleLiveEvent, onGap: (event) => invalidateDashboardQueries(queryClient, event.run_id), onReconnect: () => invalidateDashboardQueries(queryClient), }); const { data: apps = [] } = useQuery({ queryKey: ["apps"], queryFn: api.getApps, refetchInterval: liveConnected ? false : 5000, staleTime: liveConnected ? Number.POSITIVE_INFINITY : 0, }); const { data: meta } = useQuery({ queryKey: ["meta"], queryFn: api.getMeta, staleTime: Number.POSITIVE_INFINITY, }); const activeApp = selectedApp ?? apps[0]?.app_name ?? null; const cliVersion = meta?.stove_cli_version ?? null; const { data: runs = [] } = useQuery({ queryKey: ["runs", activeApp], queryFn: () => api.getRuns(activeApp!), enabled: !!activeApp, refetchInterval: !!activeApp && !liveConnected ? 5000 : false, staleTime: liveConnected ? Number.POSITIVE_INFINITY : 0, }); const latestRun = runs[0] ?? null; const { data: tests = [] } = useQuery({ queryKey: ["tests", latestRun?.id], queryFn: () => api.getTests(latestRun!.id), enabled: !!latestRun, refetchInterval: latestRun && isRunning(latestRun.status) && !liveConnected ? 5000 : false, staleTime: liveConnected ? Number.POSITIVE_INFINITY : 0, }); useEffect(() => { if (selectedApp && !apps.some((app) => app.app_name === selectedApp)) { setSelectedApp(null); setSelectedTestId(null); } }, [apps, selectedApp]); useEffect(() => { if (selectedTestId && !tests.some((test) => test.id === selectedTestId)) { setSelectedTestId(tests[0]?.id ?? null); } }, [selectedTestId, tests]); const selectedTest = tests.find((test) => test.id === selectedTestId) ?? tests[0] ?? null; const versionMismatchSummary = summarizeVersionMismatches(apps, cliVersion, activeApp); const mismatchedApps = versionMismatchSummary?.affectedAppNames ?? []; return { apps, activeApp, cliVersion, latestRun, tests, selectedTest, liveConnected, mismatchedApps, versionMismatchSummary, selectApp: (name: string) => { setSelectedApp(name); setSelectedTestId(null); }, selectTest: setSelectedTestId, }; } ================================================ FILE: tools/stove-cli/spa/src/hooks/useTheme.tsx ================================================ import { createContext, type ReactNode, useContext, useEffect, useState } from "react"; type Theme = "light" | "dark"; interface ThemeContext { theme: Theme; toggle: () => void; } const Ctx = createContext({ theme: "dark", toggle: () => {} }); export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState(() => { const stored = localStorage.getItem("stove-theme"); return stored === "light" || stored === "dark" ? stored : "dark"; }); useEffect(() => { const root = document.documentElement; root.classList.toggle("dark", theme === "dark"); localStorage.setItem("stove-theme", theme); }, [theme]); const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark")); return {children}; } export function useTheme() { return useContext(Ctx); } ================================================ FILE: tools/stove-cli/spa/src/index.css ================================================ @import "tailwindcss"; @import "@xyflow/react/dist/style.css"; @theme { --color-stove-base: var(--stove-base); --color-stove-surface: var(--stove-surface); --color-stove-card: var(--stove-card); --color-stove-border: var(--stove-border); --font-mono: "JetBrains Mono", monospace; --font-sans: "IBM Plex Sans", system-ui, sans-serif; --animate-fade-in: fade-in 0.2s ease; --animate-pulse-dot: pulse-dot 1.5s ease-in-out infinite; } /* ── Theme variables ─────────────────────────────────────────────── */ :root { --stove-base: #f8f9fa; --stove-surface: #ffffff; --stove-card: #f1f3f5; --stove-border: #dee2e6; --stove-text: #1f2937; --stove-text-secondary: #6b7280; --stove-text-muted: #9ca3af; --stove-text-heading: #111827; --stove-hover: rgba(0, 0, 0, 0.04); --stove-blue: #2563eb; --stove-green: #16a34a; --stove-red: #dc2626; --stove-amber: #d97706; --stove-red-bg: rgba(220, 38, 38, 0.1); --stove-amber-bg: rgba(217, 119, 6, 0.1); } :root.dark { --stove-base: #080b12; --stove-surface: #0a0e17; --stove-card: #0d1117; --stove-border: #1e293b; --stove-text: #d1d5db; --stove-text-secondary: #6b7280; --stove-text-muted: #4b5563; --stove-text-heading: #e5e7eb; --stove-hover: rgba(255, 255, 255, 0.02); --stove-blue: #60a5fa; --stove-green: #4ade80; --stove-red: #f87171; --stove-amber: #fbbf24; --stove-red-bg: rgba(127, 29, 29, 0.3); --stove-amber-bg: rgba(120, 53, 15, 0.2); } /* ── React Flow theme ────────────────────────────────────────────── */ .react-flow { --xy-background-color: var(--stove-base); --xy-node-border-radius: 8px; --xy-edge-stroke: var(--stove-border); --xy-edge-stroke-selected: var(--stove-blue); --xy-controls-button-background-color: var(--stove-card); --xy-controls-button-border-color: var(--stove-border); --xy-controls-button-color: var(--stove-text); --xy-minimap-background-color: var(--stove-surface); } /* ── Animations ──────────────────────────────────────────────────── */ @keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } ================================================ FILE: tools/stove-cli/spa/src/layout/Header.tsx ================================================ import { useTheme } from "../hooks/useTheme"; export function Header() { const { theme, toggle } = useTheme(); return (
🔥 Stove v{__STOVE_VERSION__}
MCP /mcp Docs GitHub Connected
); } ================================================ FILE: tools/stove-cli/spa/src/layout/Sidebar.tsx ================================================ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import type { AppSummary, Run, Test } from "../api/types"; import { filterTests } from "../utils/filters"; import { AppPicker } from "./sidebar/AppPicker"; import { RunSummary } from "./sidebar/RunSummary"; import type { FilterValue } from "./sidebar/TestFilters"; import { TestFilters } from "./sidebar/TestFilters"; import { TestTree } from "./sidebar/TestTree"; const SIDEBAR_MIN_WIDTH = 240; const SIDEBAR_MAX_WIDTH = 600; const SIDEBAR_DEFAULT_WIDTH = 320; const SIDEBAR_STORAGE_KEY = "stove-sidebar-width"; function loadSidebarWidth(): number { const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY); if (!stored) return SIDEBAR_DEFAULT_WIDTH; const parsed = Number(stored); return Number.isFinite(parsed) ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, parsed)) : SIDEBAR_DEFAULT_WIDTH; } interface SidebarProps { apps: AppSummary[]; mismatchedApps: string[]; selectedApp: string | null; onSelectApp: (name: string) => void; run: Run | null; tests: Test[]; selectedTestId: string | null; onSelectTest: (testId: string) => void; } export function Sidebar({ apps, mismatchedApps, selectedApp, onSelectApp, run, tests, selectedTestId, onSelectTest, }: SidebarProps) { const queryClient = useQueryClient(); const [filter, setFilter] = useState("all"); const [search, setSearch] = useState(""); const [clearing, setClearing] = useState(false); const [width, setWidth] = useState(loadSidebarWidth); const draggingRef = useRef(false); const handleClear = async () => { if (!confirm("Clear all stored data? This cannot be undone.")) return; setClearing(true); try { await api.clearAll(); queryClient.invalidateQueries(); } finally { setClearing(false); } }; const filteredTests = filterTests(tests, filter, search); const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); draggingRef.current = true; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, []); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!draggingRef.current) return; const clamped = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, e.clientX)); setWidth(clamped); }; const handleMouseUp = () => { if (!draggingRef.current) return; draggingRef.current = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; setWidth((w) => { localStorage.setItem(SIDEBAR_STORAGE_KEY, String(w)); return w; }); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, []); return ( ); } ================================================ FILE: tools/stove-cli/spa/src/layout/TestDetail.tsx ================================================ import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { api } from "../api/client"; import type { Test } from "../api/types"; import { EntryRow } from "../components/EntryRow"; import { FlowTab } from "../components/FlowTab"; import { SnapshotCards } from "../components/SnapshotCards"; import { SpanTree } from "../components/SpanTree"; import { partitionSnapshotsByDetail } from "../utils/snapshot-state"; import { isRunning } from "../utils/status"; import type { Tab } from "./detail/TabBar"; import { TabBar } from "./detail/TabBar"; import { TestHeader } from "./detail/TestHeader"; interface TestDetailProps { runId: string; test: Test; liveConnected: boolean; } export function TestDetail({ runId, test, liveConnected }: TestDetailProps) { const [tab, setTab] = useState("timeline"); const liveRefetchInterval = isRunning(test.status) && !liveConnected ? 5000 : false; const { data: entries = [], error: entriesError } = useQuery({ queryKey: ["entries", runId, test.id], queryFn: () => api.getEntries(runId, test.id), refetchInterval: liveRefetchInterval, staleTime: liveConnected ? Number.POSITIVE_INFINITY : 0, }); const { data: spans = [], isLoading: spansLoading, error: spansError, } = useQuery({ queryKey: ["spans", runId, test.id], queryFn: () => api.getSpans(runId, test.id), refetchInterval: liveRefetchInterval, staleTime: liveConnected ? Number.POSITIVE_INFINITY : 0, }); const { data: snapshots = [], isLoading: snapshotsLoading, error: snapshotsError, } = useQuery({ queryKey: ["snapshots", runId, test.id], queryFn: () => api.getSnapshots(runId, test.id), refetchInterval: liveRefetchInterval, staleTime: liveConnected ? Number.POSITIVE_INFINITY : 0, }); const { detailedSnapshots, hiddenCount: hiddenSnapshotCount } = useMemo( () => partitionSnapshotsByDetail(snapshots), [snapshots], ); const tabs = [ { id: "timeline" as Tab, label: `Timeline (${entries.length})`, icon: "\u{1f4cb}" }, { id: "trace" as Tab, label: `Trace (${spans.length})`, icon: "\u{1f50d}" }, { id: "snapshots" as Tab, label: `Snapshots (${detailedSnapshots.length})`, icon: "\u{1f4f8}", }, { id: "flow" as Tab, label: "Flow", icon: "\u{1f310}" }, ]; return (
{test.error && (
{test.error}
)}
{tab === "timeline" && (
{entriesError && ( )} {entries.map((entry) => ( ))} {entries.length === 0 && (
No entries recorded
)}
)} {tab === "trace" && (spansLoading ? (
Loading traces...
) : spansError ? ( ) : ( ))} {tab === "snapshots" && (snapshotsLoading ? (
Loading snapshots...
) : snapshotsError ? ( ) : ( ))} {tab === "flow" && ( setTab("trace")} /> )}
); } function QueryErrorMessage({ error, fallback }: { error: unknown; fallback: string }) { const message = error instanceof Error ? error.message : fallback; return
{message}
; } ================================================ FILE: tools/stove-cli/spa/src/layout/detail/TabBar.tsx ================================================ export type Tab = "timeline" | "trace" | "snapshots" | "flow"; interface TabDef { id: Tab; label: string; icon: string; } interface TabBarProps { tabs: TabDef[]; active: Tab; onSelect: (tab: Tab) => void; } export function TabBar({ tabs, active, onSelect }: TabBarProps) { return (
{tabs.map((t) => ( ))}
); } ================================================ FILE: tools/stove-cli/spa/src/layout/detail/TestHeader.tsx ================================================ import type { Test } from "../../api/types"; import { Badge } from "../../components/Badge"; import { formatDuration } from "../../utils/format"; interface TestHeaderProps { test: Test; } export function TestHeader({ test }: TestHeaderProps) { return (
{test.spec_name}
{test.test_name}
{formatDuration(test.duration_ms)}
); } ================================================ FILE: tools/stove-cli/spa/src/layout/sidebar/AppPicker.tsx ================================================ import type { AppSummary } from "../../api/types"; interface AppPickerProps { apps: AppSummary[]; mismatchedApps: string[]; selectedApp: string | null; onSelectApp: (name: string) => void; } export function AppPicker({ apps, mismatchedApps, selectedApp, onSelectApp }: AppPickerProps) { const mismatchedAppSet = new Set(mismatchedApps); return (
); } ================================================ FILE: tools/stove-cli/spa/src/layout/sidebar/RunSummary.tsx ================================================ import type { Run, Test } from "../../api/types"; import { Badge } from "../../components/Badge"; import { formatDuration } from "../../utils/format"; import { isFailed, isPassed, isRunning } from "../../utils/status"; interface RunSummaryProps { run: Run; tests: Test[]; } export function RunSummary({ run, tests }: RunSummaryProps) { const hasLiveTests = tests.length > 0; const total = hasLiveTests ? tests.length : run.total_tests; const passed = hasLiveTests ? tests.filter((t) => isPassed(t.status)).length : run.passed; const failed = hasLiveTests ? tests.filter((t) => isFailed(t.status)).length : run.failed; const running = hasLiveTests ? tests.filter((t) => isRunning(t.status)).length : 0; return (
{formatDuration(run.duration_ms)}
); } function Stat({ label, value, color }: { label: string; value: number; color?: string }) { return (
{value}
{label}
); } ================================================ FILE: tools/stove-cli/spa/src/layout/sidebar/TestFilters.tsx ================================================ export type FilterValue = "all" | "pass" | "fail"; interface TestFiltersProps { filter: FilterValue; onFilterChange: (f: FilterValue) => void; search: string; onSearchChange: (s: string) => void; } export function TestFilters({ filter, onFilterChange, search, onSearchChange }: TestFiltersProps) { return (
{(["all", "pass", "fail"] as const).map((f) => ( ))} onSearchChange(e.target.value)} />
); } ================================================ FILE: tools/stove-cli/spa/src/layout/sidebar/TestListItem.tsx ================================================ import type { Test } from "../../api/types"; import { Badge } from "../../components/Badge"; import { formatDuration } from "../../utils/format"; interface TestListItemProps { test: Test; selected: boolean; onSelect: () => void; hideSpec?: boolean; } export function TestListItem({ test, selected, onSelect, hideSpec }: TestListItemProps) { return ( ); } ================================================ FILE: tools/stove-cli/spa/src/layout/sidebar/TestTree.tsx ================================================ import { useCallback, useMemo, useState } from "react"; import type { Test } from "../../api/types"; import { aggregateStatus, type Status } from "../../utils/status"; import { TestListItem } from "./TestListItem"; interface TestTreeProps { tests: Test[]; selectedTestId: string | null; onSelectTest: (testId: string) => void; } interface TreeNode { label: string; tests: Test[]; children: Map; } export function TestTree({ tests, selectedTestId, onSelectTest }: TestTreeProps) { const [collapsed, setCollapsed] = useState>(new Set()); const tree = useMemo(() => buildTree(tests), [tests]); const toggle = useCallback((key: string) => { setCollapsed((prev) => prev.has(key) ? new Set([...prev].filter((k) => k !== key)) : new Set([...prev, key]), ); }, []); return <>{renderNodes(tree, collapsed, toggle, selectedTestId, onSelectTest, 0, "")}; } function buildTree(tests: Test[]): Map { const root = new Map(); for (const test of tests) { const specName = test.spec_name || "(no spec)"; const path = test.test_path.length > 0 ? test.test_path : [test.test_name]; if (!root.has(specName)) { root.set(specName, { label: specName, tests: [], children: new Map() }); } const specNode = root.get(specName)!; if (path.length <= 1) { specNode.tests.push(test); continue; } let current = specNode; for (let i = 0; i < path.length - 1; i++) { const segment = path[i]; if (!current.children.has(segment)) { current.children.set(segment, { label: segment, tests: [], children: new Map() }); } current = current.children.get(segment)!; } current.tests.push(test); } return root; } function renderNodes( nodes: Map, collapsed: Set, toggle: (key: string) => void, selectedTestId: string | null, onSelectTest: (testId: string) => void, depth: number, parentKey: string, ): React.ReactNode[] { const result: React.ReactNode[] = []; for (const [key, node] of nodes) { const nodeKey = parentKey ? `${parentKey}/${key}` : key; const isCollapsed = collapsed.has(nodeKey); const hasChildren = node.children.size > 0 || node.tests.length > 0; const status = getNodeAggregateStatus(node); result.push( , ); if (!isCollapsed) { if (node.children.size > 0) { result.push( ...renderNodes( node.children, collapsed, toggle, selectedTestId, onSelectTest, depth + 1, nodeKey, ), ); } for (const test of node.tests) { result.push(
onSelectTest(test.id)} hideSpec />
, ); } } } return result; } function getNodeAggregateStatus(node: TreeNode): Status { const statuses: Status[] = []; collectNodeStatuses(node, statuses); return aggregateStatus(statuses); } function collectNodeStatuses(node: TreeNode, out: Status[]): void { for (const test of node.tests) { out.push(test.status); } for (const child of node.children.values()) { collectNodeStatuses(child, out); } } function StatusDot({ status }: { status: Status }) { const color = status === "FAILED" || status === "ERROR" ? "bg-red-400" : status === "PASSED" ? "bg-emerald-400" : "bg-blue-400 animate-pulse-dot"; return ; } ================================================ FILE: tools/stove-cli/spa/src/main.tsx ================================================ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import { ThemeProvider } from "./hooks/useTheme"; import "./index.css"; const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1, }, }, }); createRoot(document.getElementById("root")!).render( , ); ================================================ FILE: tools/stove-cli/spa/src/utils/filters.ts ================================================ import type { Test } from "../api/types"; import type { FilterValue } from "../layout/sidebar/TestFilters"; import { isFailed, isPassed } from "./status"; function matchesFilter(test: Test, filter: FilterValue): boolean { if (filter === "pass") return isPassed(test.status); if (filter === "fail") return isFailed(test.status); return true; } function matchesSearch(test: Test, query: string): boolean { if (!query) return true; const q = query.toLowerCase(); return ( test.test_name.toLowerCase().includes(q) || test.test_path.some((seg) => seg.toLowerCase().includes(q)) || test.spec_name.toLowerCase().includes(q) ); } export function filterTests(tests: Test[], filter: FilterValue, search: string): Test[] { return tests.filter((t) => matchesFilter(t, filter) && matchesSearch(t, search)); } ================================================ FILE: tools/stove-cli/spa/src/utils/flow.ts ================================================ import dagre from "@dagrejs/dagre"; import type { Edge, Node } from "@xyflow/react"; import { MarkerType } from "@xyflow/react"; import type { Entry, Span } from "../api/types"; import { parseAttrs } from "./json"; import { isFailed } from "./result"; const EXECUTION_GAP_THRESHOLD_MS = 1000; const ADJACENT_MERGE_WINDOW_MS = 250; const STEP_NODE_SIZE = { width: 240, height: 128 }; const TRACE_NODE_SIZE = { width: 240, height: 120 }; const ARRANGE_NODE_SIZE = { width: 240, height: 128 }; const GAP_NODE_SIZE = { width: 208, height: 96 }; export interface SystemNodeData extends Record { kind: "step" | "trace" | "arrange"; system: string; action: string; result: string; count: number; error: string | null; entries: Entry[]; traceId: string | null; startedAt: string | null; endedAt: string | null; durationMs: number | null; inspectable: boolean; } export interface GapNodeData extends Record { kind: "gap"; label: string; durationMs: number; startedAt: string; endedAt: string; inspectable: false; } export type FlowNodeData = SystemNodeData | GapNodeData; export interface DurationEdgeData extends Record { durationMs: number; label?: string; } interface TimelineStepGroup { entries: Entry[]; startedAtMs: number; endedAtMs: number; kind: "step" | "arrange"; actionLabel: string; displayCount: number; } interface TimelineStepSpec { kind: "step"; group: TimelineStepGroup; } interface TimelineGapSpec { kind: "gap"; durationMs: number; startedAt: string; endedAt: string; startedAtMs: number; endedAtMs: number; } type TimelineSpec = TimelineStepSpec | TimelineGapSpec; export function entriesToDag(entries: Entry[]): { nodes: Node[]; edges: Edge[] } { if (entries.length === 0) return { nodes: [], edges: [] }; const sorted = [...entries].sort((a, b) => toMs(a.timestamp) - toMs(b.timestamp)); const stepGroups = sorted.length > 0 ? collapseArrangeRuns(groupEntriesIntoSteps(sorted)) : []; const { arrangeGroups, mainGroups } = splitArrangeGroups(stepGroups); const orderedSpecs = expandTimelineSpecs(mainGroups); const timelineNodes: Node[] = orderedSpecs.map((spec, index) => { if (spec.kind === "gap") { return { id: nodeIdForSpec(spec, index), type: "gapNode", position: { x: 0, y: 0 }, data: { kind: "gap", label: "Idle gap", durationMs: spec.durationMs, startedAt: spec.startedAt, endedAt: spec.endedAt, inspectable: false, } satisfies GapNodeData, }; } const group = spec.group.entries; const first = group[0]; const last = group[group.length - 1]; const hasFailed = group.some((entry) => isFailed(entry.result)); const firstError = group.find((entry) => entry.error)?.error ?? null; const traceId = group.find((entry) => entry.trace_id)?.trace_id ?? null; return createSystemNode(nodeIdForSpec(spec, index), { kind: spec.group.kind, system: first.system, action: spec.group.actionLabel, result: hasFailed ? "FAILED" : "PASSED", count: spec.group.displayCount, error: firstError, entries: group, traceId, startedAt: first.timestamp, endedAt: last.timestamp, durationMs: Math.max(0, spec.group.endedAtMs - spec.group.startedAtMs), inspectable: true, }); }); const edges: Edge[] = []; for (let i = 1; i < orderedSpecs.length; i++) { const previousSpec = orderedSpecs[i - 1]; const currentSpec = orderedSpecs[i]; edges.push({ id: `edge-${i - 1}-${i}`, source: nodeIdForSpec(previousSpec, i - 1), target: nodeIdForSpec(currentSpec, i), type: "durationEdge", markerEnd: { type: MarkerType.ArrowClosed }, data: { durationMs: Math.max(0, getSpecStartedAtMs(currentSpec) - getSpecEndedAtMs(previousSpec)), } satisfies DurationEdgeData, }); } const nodes = [...timelineNodes]; if (arrangeGroups.length > 0) { const firstTimelineNodeId = timelineNodes.length > 0 ? timelineNodes[0].id : null; arrangeGroups.forEach((group, index) => { const first = group.entries[0]; const last = group.entries[group.entries.length - 1]; const hasFailed = group.entries.some((entry) => isFailed(entry.result)); const firstError = group.entries.find((entry) => entry.error)?.error ?? null; const traceId = group.entries.find((entry) => entry.trace_id)?.trace_id ?? null; const arrangeNodeId = `arrange-step-${index}`; nodes.push( createSystemNode(arrangeNodeId, { kind: "arrange", system: first.system, action: group.actionLabel, result: hasFailed ? "FAILED" : "PASSED", count: group.displayCount, error: firstError, entries: group.entries, traceId, startedAt: first.timestamp, endedAt: last.timestamp, durationMs: Math.max(0, group.endedAtMs - group.startedAtMs), inspectable: true, }), ); if (firstTimelineNodeId) { edges.push({ id: `${arrangeNodeId}-${firstTimelineNodeId}`, source: arrangeNodeId, target: firstTimelineNodeId, type: "durationEdge", markerEnd: { type: MarkerType.ArrowClosed }, data: { durationMs: 0, label: "ready", } satisfies DurationEdgeData, }); } }); } return { nodes, edges }; } export function spansToTraceDag(spans: Span[]): { nodes: Node[]; edges: Edge[] } { if (spans.length === 0) return { nodes: [], edges: [] }; const nodes: Node[] = spans.map((span) => { const system = detectSystemFromSpan(span); return { id: span.span_id, type: "systemNode", position: { x: 0, y: 0 }, data: { kind: "trace", system, action: span.operation_name, result: span.status, count: 1, error: span.exception_message ?? null, entries: [], traceId: span.trace_id, startedAt: null, endedAt: null, durationMs: Math.max(0, (span.end_time_nanos - span.start_time_nanos) / 1_000_000), inspectable: true, } satisfies SystemNodeData, }; }); const spanIds = new Set(spans.map((span) => span.span_id)); const edges: Edge[] = spans .filter((span) => span.parent_span_id && spanIds.has(span.parent_span_id)) .map((span) => ({ id: `span-edge-${span.parent_span_id}-${span.span_id}`, source: span.parent_span_id!, target: span.span_id, type: "durationEdge", markerEnd: { type: MarkerType.ArrowClosed }, data: { durationMs: Math.max(0, (span.end_time_nanos - span.start_time_nanos) / 1_000_000), } satisfies DurationEdgeData, })); return { nodes, edges }; } export function applyDagreLayout( nodes: Node[], edges: Edge[], direction: "LR" | "TB" = "LR", ): Node[] { if (nodes.length === 0) return nodes; const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); g.setGraph({ rankdir: direction, nodesep: 128, ranksep: 176, edgesep: 48, marginx: 24, marginy: 24, }); for (const node of nodes) { const size = getNodeLayoutSize(node); g.setNode(node.id, size); } for (const edge of edges) { g.setEdge(edge.source, edge.target); } dagre.layout(g); return nodes.map((node) => { const pos = g.node(node.id); const size = getNodeLayoutSize(node); return { ...node, position: { x: pos.x - size.width / 2, y: pos.y - size.height / 2 }, }; }); } export function getNodeLayoutSize(node: Node): { width: number; height: number } { switch (node.type) { case "gapNode": return cloneLayoutSize(GAP_NODE_SIZE); case "systemNode": return getSystemNodeLayoutSize(node.data as SystemNodeData); default: return cloneLayoutSize(STEP_NODE_SIZE); } } const DB_SYSTEM_MAP: Record = { postgresql: "PostgreSQL", mysql: "MySQL", mssql: "MSSQL", mongodb: "MongoDB", redis: "Redis", couchbase: "Couchbase", elasticsearch: "Elasticsearch", cassandra: "Cassandra", }; function detectSystemFromSpan(span: Span): string { const attrs = parseAttrs(span.attributes); const keys = Object.keys(attrs); if (keys.some((key) => key.startsWith("http."))) return "HTTP"; if (keys.some((key) => key.startsWith("messaging."))) return "Kafka"; if (keys.some((key) => key.startsWith("db."))) { const dbSystem = attrs["db.system"]; if (dbSystem) return DB_SYSTEM_MAP[dbSystem.toLowerCase()] ?? dbSystem; return "Database"; } if (keys.some((key) => key.startsWith("rpc."))) return "gRPC"; return span.service_name || "Unknown"; } function createSystemNode(id: string, data: SystemNodeData): Node { return { id, type: "systemNode", position: { x: 0, y: 0 }, data, }; } function getSystemNodeLayoutSize(data: SystemNodeData): { width: number; height: number } { switch (data.kind) { case "trace": return cloneLayoutSize(TRACE_NODE_SIZE); case "arrange": return cloneLayoutSize(ARRANGE_NODE_SIZE); default: return cloneLayoutSize(STEP_NODE_SIZE); } } function cloneLayoutSize(size: { width: number; height: number }): { width: number; height: number; } { return { width: size.width, height: size.height }; } function groupEntriesIntoSteps(entries: Entry[]): TimelineStepGroup[] { const groups: TimelineStepGroup[] = []; let currentEntries: Entry[] = [entries[0]]; let currentStartedAtMs = toMs(entries[0].timestamp); let currentEndedAtMs = currentStartedAtMs; for (let i = 1; i < entries.length; i++) { const previous = currentEntries[currentEntries.length - 1]; const next = entries[i]; const nextAtMs = toMs(next.timestamp); if (canMergeAdjacentEntries(previous, next, currentEndedAtMs, nextAtMs)) { currentEntries.push(next); currentEndedAtMs = nextAtMs; continue; } groups.push({ entries: currentEntries, startedAtMs: currentStartedAtMs, endedAtMs: currentEndedAtMs, kind: isArrangeEntryGroup(currentEntries) ? "arrange" : "step", actionLabel: currentEntries[0].action, displayCount: currentEntries.length, }); currentEntries = [next]; currentStartedAtMs = nextAtMs; currentEndedAtMs = nextAtMs; } groups.push({ entries: currentEntries, startedAtMs: currentStartedAtMs, endedAtMs: currentEndedAtMs, kind: isArrangeEntryGroup(currentEntries) ? "arrange" : "step", actionLabel: currentEntries[0].action, displayCount: currentEntries.length, }); return groups; } function canMergeAdjacentEntries( previous: Entry, next: Entry, previousAtMs: number, nextAtMs: number, ): boolean { return ( next.system === previous.system && next.action === previous.action && next.result === previous.result && (next.error ?? null) === (previous.error ?? null) && (next.trace_id ?? null) === (previous.trace_id ?? null) && nextAtMs - previousAtMs <= ADJACENT_MERGE_WINDOW_MS ); } function expandTimelineSpecs(groups: TimelineStepGroup[]): TimelineSpec[] { const specs: TimelineSpec[] = []; for (let i = 0; i < groups.length; i++) { if (i > 0) { const previous = groups[i - 1]; const current = groups[i]; const gapMs = Math.max(0, current.startedAtMs - previous.endedAtMs); if (gapMs >= EXECUTION_GAP_THRESHOLD_MS) { specs.push({ kind: "gap", durationMs: gapMs, startedAt: previous.entries[previous.entries.length - 1].timestamp, endedAt: current.entries[0].timestamp, startedAtMs: previous.endedAtMs, endedAtMs: current.startedAtMs, }); } } specs.push({ kind: "step", group: groups[i], }); } return specs; } function splitArrangeGroups(groups: TimelineStepGroup[]): { arrangeGroups: TimelineStepGroup[]; mainGroups: TimelineStepGroup[]; } { let arrangeCount = 0; while (arrangeCount < groups.length && groups[arrangeCount].kind === "arrange") { arrangeCount += 1; } return { arrangeGroups: groups.slice(0, arrangeCount), mainGroups: groups.slice(arrangeCount), }; } function collapseArrangeRuns(groups: TimelineStepGroup[]): TimelineStepGroup[] { const collapsed: TimelineStepGroup[] = []; let index = 0; while (index < groups.length) { const current = groups[index]; if (current.kind !== "arrange") { collapsed.push(current); index += 1; continue; } const system = current.entries[0]?.system; const arrangeRun = [current]; index += 1; while ( index < groups.length && groups[index].kind === "arrange" && groups[index].entries[0]?.system === system ) { arrangeRun.push(groups[index]); index += 1; } collapsed.push(combineArrangeRun(arrangeRun)); } return collapsed; } function combineArrangeRun(groups: TimelineStepGroup[]): TimelineStepGroup { const firstGroup = groups[0]; if (!firstGroup) { throw new Error("arrange run cannot be empty"); } if (groups.length === 1) { return firstGroup; } const entries = groups.flatMap((group) => group.entries); const system = firstGroup.entries[0]?.system ?? "Unknown"; return { entries, startedAtMs: firstGroup.startedAtMs, endedAtMs: groups[groups.length - 1].endedAtMs, kind: "arrange", actionLabel: summarizeArrangeAction(system, groups.length, firstGroup.actionLabel), displayCount: groups.length, }; } function isArrangeEntryGroup(entries: Entry[]): boolean { const first = entries[0]; if (!first) { return false; } return isArrangeSystem(first.system) && isArrangeAction(first.action); } function isArrangeSystem(system: string): boolean { return ARRANGE_SYSTEMS.has(system); } function isArrangeAction(action: string): boolean { return ARRANGE_ACTION_PATTERNS.some((pattern) => pattern.test(action)); } function nodeIdForSpec(spec: TimelineSpec, index: number): string { return spec.kind === "gap" ? `gap-${index}` : `step-${index}`; } function getSpecStartedAtMs(spec: TimelineSpec): number { return spec.kind === "gap" ? spec.startedAtMs : spec.group.startedAtMs; } function getSpecEndedAtMs(spec: TimelineSpec): number { return spec.kind === "gap" ? spec.endedAtMs : spec.group.endedAtMs; } function summarizeArrangeAction( system: string, registrationCount: number, fallbackAction: string, ): string { if (registrationCount <= 1) { return fallbackAction; } if (system === "WireMock" || system === "gRPC Mock") { return `Registered ${registrationCount} stubs`; } return `${registrationCount} setup actions`; } function toMs(timestamp: string): number { return new Date(timestamp).getTime(); } const ARRANGE_SYSTEMS = new Set(["WireMock", "gRPC Mock"]); const ARRANGE_ACTION_PATTERNS = [/^Register stub:/, /^Register .* stub:/]; ================================================ FILE: tools/stove-cli/spa/src/utils/format.ts ================================================ export function formatDuration(ms: number | null | undefined): string { if (ms == null) return "-"; if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; } export function formatTimestamp(iso: string): string { try { const d = new Date(iso); return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3, } as Intl.DateTimeFormatOptions); } catch { return iso; } } export function formatNanosDuration(startNanos: number, endNanos: number): string { const ms = (endNanos - startNanos) / 1_000_000; return formatDuration(ms); } ================================================ FILE: tools/stove-cli/spa/src/utils/json.ts ================================================ export function tryFormatJson(s: string): string { try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; } } export function tryFormatJsonDeep(s: string): string { const parsed = parseJsonDeep(s); if (parsed === null) { return s; } return JSON.stringify(parsed, null, 2); } export function parseJsonDeep(s: string): unknown | null { try { return normalizeEmbeddedJson(JSON.parse(s)); } catch { return null; } } export interface JsonSearchResult { filteredValue: unknown | null; matchCount: number; } export function filterJsonByQuery(value: unknown, query: string): JsonSearchResult { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return { filteredValue: value, matchCount: 0, }; } return filterJsonValue(value, normalizedQuery); } export function describeJsonValue(value: unknown): string { if (Array.isArray(value)) { return `${value.length} item${value.length === 1 ? "" : "s"}`; } if (typeof value === "object" && value !== null) { const keys = Object.keys(value); return `${keys.length} key${keys.length === 1 ? "" : "s"}`; } if (value === null) { return "null"; } return typeof value; } export function getJsonPreviewKeys(value: unknown, limit = 4): string[] { if (Array.isArray(value)) { return value.slice(0, limit).map((_, index) => `[${index}]`); } if (typeof value === "object" && value !== null) { return Object.keys(value).slice(0, limit); } return []; } function normalizeEmbeddedJson(value: unknown): unknown { if (typeof value === "string") { const trimmed = value.trim(); if ( (trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")) ) { try { return normalizeEmbeddedJson(JSON.parse(trimmed)); } catch { return value; } } return value; } if (Array.isArray(value)) { return value.map(normalizeEmbeddedJson); } if (typeof value === "object" && value !== null) { return Object.fromEntries( Object.entries(value).map(([key, nested]) => [key, normalizeEmbeddedJson(nested)]), ); } return value; } function filterJsonValue(value: unknown, normalizedQuery: string): JsonSearchResult { if (Array.isArray(value)) { const filteredItems: unknown[] = []; let matchCount = 0; value.forEach((item) => { const nested = filterJsonValue(item, normalizedQuery); if (nested.filteredValue !== null) { filteredItems.push(nested.filteredValue); matchCount += nested.matchCount; } }); return filteredItems.length > 0 ? { filteredValue: filteredItems, matchCount } : { filteredValue: null, matchCount: 0 }; } if (typeof value === "object" && value !== null) { const filteredEntries: Array<[string, unknown]> = []; let matchCount = 0; for (const [key, nestedValue] of Object.entries(value)) { if (key.toLowerCase().includes(normalizedQuery)) { filteredEntries.push([key, nestedValue]); matchCount += 1; continue; } const nested = filterJsonValue(nestedValue, normalizedQuery); if (nested.filteredValue !== null) { filteredEntries.push([key, nested.filteredValue]); matchCount += nested.matchCount; } } return filteredEntries.length > 0 ? { filteredValue: Object.fromEntries(filteredEntries), matchCount } : { filteredValue: null, matchCount: 0 }; } return primitiveIncludes(value, normalizedQuery) ? { filteredValue: value, matchCount: 1 } : { filteredValue: null, matchCount: 0 }; } function primitiveIncludes(value: unknown, normalizedQuery: string): boolean { if (value == null) { return "null".includes(normalizedQuery); } return String(value).toLowerCase().includes(normalizedQuery); } export function parseAttrs(json: string | null): Record { if (!json) return {}; try { const parsed: unknown = JSON.parse(json); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; const result: Record = {}; for (const [k, v] of Object.entries(parsed)) { result[k] = String(v); } return result; } catch { return {}; } } ================================================ FILE: tools/stove-cli/spa/src/utils/result.ts ================================================ export function isFailed(result: string): boolean { const upper = result.toUpperCase(); return upper === "FAILED" || upper === "ERROR"; } export function isSuccessful(result: string): boolean { const upper = result.toUpperCase(); return upper === "PASSED" || upper === "OK"; } export function getResultTone(result: string): "failed" | "success" | "neutral" { if (isFailed(result)) return "failed"; if (isSuccessful(result)) return "success"; return "neutral"; } ================================================ FILE: tools/stove-cli/spa/src/utils/snapshot-state.ts ================================================ import type { Snapshot } from "../api/types"; import { parseJsonDeep } from "./json"; export interface SnapshotMetric { key: string; label: string; value: number; tone: "info" | "success" | "warning" | "danger" | "neutral"; } export interface SnapshotPartition { detailedSnapshots: TSnapshot[]; hiddenCount: number; } export function hasDetailedSnapshotState( snapshot: Pick, parsedState: unknown | null = parseJsonDeep(snapshot.state_json), ): boolean { if (parsedState !== null) { return hasInspectableValue(parsedState); } const rawState = snapshot.state_json.trim(); return rawState.length > 0 && rawState !== "{}" && rawState !== "[]"; } export function getKafkaSnapshotMetrics( snapshot: Pick, parsedState: unknown | null = parseJsonDeep(snapshot.state_json), ): SnapshotMetric[] { if (typeof parsedState !== "object" || parsedState === null || Array.isArray(parsedState)) { return []; } const state = parsedState as Record; const metricDefs = [ { key: "consumed", label: "Consumed" }, { key: "published", label: "Published" }, { key: "produced", label: "Produced" }, { key: "committed", label: "Committed" }, { key: "failed", label: "Failed" }, ]; return metricDefs.flatMap(({ key, label }) => { if (!(key in state)) { return []; } const value = countMetricValue(state[key]); if (value === null) { return []; } return [ { key, label, value, tone: metricTone(key, value), } satisfies SnapshotMetric, ]; }); } export function partitionSnapshotsByDetail>( snapshots: TSnapshot[], ): SnapshotPartition { const detailedSnapshots = snapshots.filter((snapshot) => hasDetailedSnapshotState(snapshot)); return { detailedSnapshots, hiddenCount: snapshots.length - detailedSnapshots.length, }; } function hasInspectableValue(value: unknown): boolean { if (Array.isArray(value)) { return value.some(hasInspectableValue); } if (typeof value === "object" && value !== null) { const entries = Object.values(value); return entries.length > 0 && entries.some(hasInspectableValue); } if (typeof value === "string") { return value.trim().length > 0; } return typeof value === "number" || typeof value === "boolean"; } function countMetricValue(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (Array.isArray(value)) { return value.length; } if (typeof value === "object" && value !== null) { return Object.keys(value).length; } return null; } function metricTone(key: string, value: number): SnapshotMetric["tone"] { if (key === "failed") { return value > 0 ? "danger" : "neutral"; } if (key === "published" || key === "produced") { return value > 0 ? "success" : "neutral"; } if (key === "consumed" || key === "committed") { return value > 0 ? "info" : "neutral"; } return "warning"; } ================================================ FILE: tools/stove-cli/spa/src/utils/status.ts ================================================ export type Status = "RUNNING" | "PASSED" | "FAILED" | "ERROR"; export const isFailed = (s: Status): boolean => s === "FAILED" || s === "ERROR"; export const isRunning = (s: Status): boolean => s === "RUNNING"; export const isPassed = (s: Status): boolean => s === "PASSED"; export function aggregateStatus(statuses: Iterable): Status { let hasPassed = false; for (const s of statuses) { if (isFailed(s)) return "FAILED"; if (isRunning(s)) return "RUNNING"; if (isPassed(s)) hasPassed = true; } return hasPassed ? "PASSED" : "RUNNING"; } export function collectStatuses(items: T[], getStatus: (item: T) => Status): Status { const statuses = items.map(getStatus); return aggregateStatus(statuses); } ================================================ FILE: tools/stove-cli/spa/src/utils/systems.ts ================================================ interface SystemInfo { color: string; icon: string; } const SYSTEM_MAP: Record = { HTTP: { color: "#60a5fa", icon: "\u21c4" }, Kafka: { color: "#f59e0b", icon: "\u26a1" }, PostgreSQL: { color: "#34d399", icon: "\u229e" }, Postgres: { color: "#34d399", icon: "\u229e" }, WireMock: { color: "#a78bfa", icon: "\u25ce" }, gRPC: { color: "#fb923c", icon: "\u25c8" }, "gRPC Mock": { color: "#fb923c", icon: "\u25c8" }, Redis: { color: "#f87171", icon: "\u25c6" }, MongoDB: { color: "#4ade80", icon: "\u2291" }, Mongo: { color: "#4ade80", icon: "\u2291" }, Couchbase: { color: "#06b6d4", icon: "\u2261" }, Elasticsearch: { color: "#fbbf24", icon: "\u2315" }, MySQL: { color: "#0ea5e9", icon: "\u229e" }, MSSQL: { color: "#8b5cf6", icon: "\u229e" }, Cassandra: { color: "#d946ef", icon: "\u2609" }, }; const DEFAULT_SYSTEM: SystemInfo = { color: "#94a3b8", icon: "\u2022" }; export function getSystemInfo(name: string): SystemInfo { return SYSTEM_MAP[name] ?? DEFAULT_SYSTEM; } ================================================ FILE: tools/stove-cli/spa/src/utils/version-mismatch.ts ================================================ import type { AppSummary } from "../api/types"; const RELEASE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; const SWITCH_HINT = "Switch to a mismatched app to see exact remediation."; const CLI_UPGRADE_COMMAND = "brew upgrade Trendyol/trendyol-tap/stove"; export type VersionMismatchKind = "runtime_older" | "cli_older" | "unknown"; export interface VersionMismatch { appName: string; cliVersion: string; runtimeVersion: string | null; kind: VersionMismatchKind; } export interface VersionMismatchSummary { cliVersion: string; mismatches: VersionMismatch[]; affectedAppNames: string[]; selectedAppMismatch: VersionMismatch | null; } export interface VersionMismatchRemediationStep { kind: "text" | "command"; value: string; } export interface VersionMismatchBannerModel { title: string; affectedApps: string[]; switchHint: string | null; selectedAppName: string | null; runtimeVersion: string | null; cliVersion: string; remediationSteps: VersionMismatchRemediationStep[]; } export function compareVersions( runtimeVersion: string | null | undefined, cliVersion: string, ): VersionMismatchKind | null { const normalizedRuntime = normalizeVersion(runtimeVersion); if (normalizedRuntime === cliVersion) { return null; } if (!normalizedRuntime) { return "unknown"; } const runtimeTriplet = parseReleaseVersion(normalizedRuntime); const cliTriplet = parseReleaseVersion(cliVersion); if (!runtimeTriplet || !cliTriplet) { return "unknown"; } for (let index = 0; index < runtimeTriplet.length; index += 1) { if (runtimeTriplet[index] < cliTriplet[index]) { return "runtime_older"; } if (runtimeTriplet[index] > cliTriplet[index]) { return "cli_older"; } } return "unknown"; } export function summarizeVersionMismatches( apps: AppSummary[], cliVersion: string | null, selectedApp: string | null, ): VersionMismatchSummary | null { if (!cliVersion) { return null; } const mismatches = apps .map((app) => createVersionMismatch(app, cliVersion)) .filter((mismatch): mismatch is VersionMismatch => mismatch !== null); if (mismatches.length === 0) { return null; } const affectedAppNames = mismatches.map((mismatch) => mismatch.appName); return { cliVersion, mismatches, affectedAppNames, selectedAppMismatch: mismatches.find((mismatch) => mismatch.appName === selectedApp) ?? null, }; } export function buildVersionMismatchBannerModel( summary: VersionMismatchSummary, ): VersionMismatchBannerModel { const mismatchCount = summary.mismatches.length; const selectedAppMismatch = summary.selectedAppMismatch; return { title: bannerTitle(mismatchCount), affectedApps: summary.affectedAppNames, switchHint: selectedAppMismatch ? null : SWITCH_HINT, selectedAppName: selectedAppMismatch?.appName ?? null, runtimeVersion: selectedAppMismatch?.runtimeVersion ?? null, cliVersion: summary.cliVersion, remediationSteps: selectedAppMismatch ? remediationStepsForMismatch(selectedAppMismatch) : [], }; } function createVersionMismatch(app: AppSummary, cliVersion: string): VersionMismatch | null { const kind = compareVersions(app.stove_version, cliVersion); if (!kind) { return null; } return { appName: app.app_name, cliVersion, runtimeVersion: normalizeVersion(app.stove_version), kind, }; } function bannerTitle(mismatchCount: number): string { return mismatchCount === 1 ? "A version mismatch was detected in the latest Stove dashboard run." : `${mismatchCount} apps have a version mismatch in their latest Stove dashboard runs.`; } function normalizeVersion(version: string | null | undefined): string | null { const normalized = version?.trim(); return normalized ? normalized : null; } function parseReleaseVersion(version: string): number[] | null { const match = version.match(RELEASE_VERSION_PATTERN); if (!match) { return null; } return match.slice(1).map(Number); } function remediationStepsForMismatch(mismatch: VersionMismatch): VersionMismatchRemediationStep[] { if (mismatch.kind === "runtime_older") { return [textStep(dependencyAlignmentMessage(mismatch.cliVersion))]; } if (mismatch.kind === "cli_older") { return [ textStep("Update stove-cli to match the runtime version:"), commandStep(CLI_UPGRADE_COMMAND), commandStep(installScriptCommand(mismatch.runtimeVersion!)), ]; } return [ textStep( `This run comes from an older or non-standard Stove runtime. ${dependencyAlignmentMessage(mismatch.cliVersion)}`, ), ]; } function dependencyAlignmentMessage(cliVersion: string): string { return `Align the Stove BOM or all Stove test dependencies to ${cliVersion}.`; } function installScriptCommand(runtimeVersion: string): string { return `curl -fsSL https://raw.githubusercontent.com/Trendyol/stove/main/tools/stove-cli/install.sh | sh -s -- --version ${runtimeVersion}`; } function textStep(value: string): VersionMismatchRemediationStep { return { kind: "text", value }; } function commandStep(value: string): VersionMismatchRemediationStep { return { kind: "command", value }; } ================================================ FILE: tools/stove-cli/spa/src/vite-env.d.ts ================================================ /// declare const __STOVE_VERSION__: string; ================================================ FILE: tools/stove-cli/spa/test/api-client.test.mjs ================================================ import assert from "node:assert/strict"; import test from "node:test"; import createJiti from "jiti"; const jiti = createJiti(import.meta.url); const { api } = await jiti.import("../src/api/client.ts"); test("getSnapshots URL-encodes run and test ids before requesting snapshot data", async () => { const originalFetch = globalThis.fetch; const seen = []; globalThis.fetch = async (input) => { seen.push(String(input)); return new Response("[]", { status: 200, headers: { "content-type": "application/json" }, }); }; try { await api.getSnapshots( "run:1", "AuditHeadersValidationTests::should not require audit headers for get endpoint", ); } finally { globalThis.fetch = originalFetch; } assert.equal( seen[0], "/api/v1/runs/run%3A1/tests/AuditHeadersValidationTests%3A%3Ashould%20not%20require%20audit%20headers%20for%20get%20endpoint/snapshots", ); }); ================================================ FILE: tools/stove-cli/spa/test/flow.test.mjs ================================================ import assert from "node:assert/strict"; import test from "node:test"; import createJiti from "jiti"; const jiti = createJiti(import.meta.url); const { applyDagreLayout, entriesToDag, getNodeLayoutSize, spansToTraceDag } = await jiti.import( "../src/utils/flow.ts", ); test("spansToTraceDag preserves UNSET span status instead of marking it as passed", () => { const { nodes } = spansToTraceDag([ { id: 1, run_id: "run-1", trace_id: "trace-1", span_id: "span-1", parent_span_id: null, operation_name: "GET /health", service_name: "my-api", start_time_nanos: 0, end_time_nanos: 1_000_000, status: "UNSET", attributes: null, exception_type: null, exception_message: null, exception_stack_trace: null, }, ]); assert.equal(nodes.length, 1); assert.equal(nodes[0].data.result, "UNSET"); }); test("entriesToDag keeps distinct actions separate even when they belong to the same system", () => { const { nodes } = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.000Z", system: "HTTP", action: "POST /products", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-1", }, { id: 2, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.050Z", system: "HTTP", action: "GET /products/42", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-2", }, ]); assert.equal(nodes.length, 2); assert.equal(nodes[0].data.action, "POST /products"); assert.equal(nodes[1].data.action, "GET /products/42"); }); test("entriesToDag inserts explicit gap nodes for long idle periods", () => { const { nodes, edges } = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.000Z", system: "HTTP", action: "POST /products", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-1", }, { id: 2, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:03.250Z", system: "Kafka", action: "consume ProductCreated", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, ]); assert.equal(nodes.length, 3); assert.equal(nodes[1].type, "gapNode"); assert.equal(nodes[1].data.kind, "gap"); assert.equal(nodes[1].data.durationMs, 3250); assert.equal(edges.length, 2); }); test("entriesToDag keeps captured snapshots out of the execution graph layout", () => { const { nodes, edges } = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.000Z", system: "HTTP", action: "POST /products", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-1", }, ]); assert.equal(nodes.length, 1); assert.equal(nodes[0].type, "systemNode"); assert.equal(edges.length, 0); }); test("entriesToDag lifts leading mock registration steps into an arrange branch", () => { const { nodes, edges } = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.000Z", system: "WireMock", action: "Register stub: GET /inventory", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 2, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.100Z", system: "gRPC Mock", action: "Register unary stub: inventory.StockService/GetStock", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 3, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.250Z", system: "HTTP", action: "POST /products", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-1", }, ]); assert.equal(nodes.length, 3); const arrangeNodes = nodes.filter( (node) => node.type === "systemNode" && node.data.kind === "arrange", ); assert.equal(arrangeNodes.length, 2); const executionNode = nodes.find( (node) => node.type === "systemNode" && node.data.kind === "step", ); assert.ok(executionNode); assert.equal(executionNode.data.system, "HTTP"); assert.equal(edges.filter((edge) => edge.data.label === "ready").length, 2); }); test("entriesToDag groups leading registrations by mock system", () => { const { nodes } = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.000Z", system: "WireMock", action: "Register stub: GET /inventory", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 2, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.100Z", system: "WireMock", action: "Register stub: GET /prices", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 3, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.150Z", system: "gRPC Mock", action: "Register unary stub: inventory.StockService/GetStock", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 4, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.250Z", system: "HTTP", action: "POST /products", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-1", }, ]); const arrangeNodes = nodes.filter( (node) => node.type === "systemNode" && node.data.kind === "arrange", ); assert.equal(arrangeNodes.length, 2); const wireMockNode = arrangeNodes.find((node) => node.data.system === "WireMock"); assert.ok(wireMockNode); assert.equal(wireMockNode.data.count, 2); assert.equal(wireMockNode.data.action, "Registered 2 stubs"); }); test("entriesToDag groups consecutive WireMock registrations in the middle of the timeline", () => { const { nodes } = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-31T09:00:00.000Z", system: "SpringJdbc", action: "Select business unit", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 2, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-31T09:00:00.050Z", system: "WireMock", action: "Register stub: GET /first", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 3, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-31T09:00:00.100Z", system: "WireMock", action: "Register stub: GET /second", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 4, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-31T09:00:00.150Z", system: "Kafka", action: "Publish order result", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, ]); const flowNodes = nodes.filter((node) => node.type === "systemNode"); assert.equal(flowNodes.length, 3); assert.equal(flowNodes[1].data.system, "WireMock"); assert.equal(flowNodes[1].data.kind, "arrange"); assert.equal(flowNodes[1].data.count, 2); assert.equal(flowNodes[1].data.action, "Registered 2 stubs"); }); test("applyDagreLayout keeps arrange siblings on distinct coordinates", () => { const dag = entriesToDag([ { id: 1, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.000Z", system: "WireMock", action: "Register stub: GET /inventory", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 2, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.050Z", system: "WireMock", action: "Register stub: GET /prices", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 3, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.100Z", system: "gRPC Mock", action: "Register unary stub: inventory.StockService/GetStock", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: null, }, { id: 4, run_id: "run-1", test_id: "test-1", timestamp: "2026-03-30T10:00:00.300Z", system: "HTTP", action: "POST /products", result: "PASSED", input: null, output: null, metadata: null, expected: null, actual: null, error: null, trace_id: "trace-1", }, ]); const laidOut = applyDagreLayout(dag.nodes, dag.edges); const arrangeNodes = laidOut.filter( (node) => node.type === "systemNode" && node.data.kind === "arrange", ); assert.equal(arrangeNodes.length, 2); assert.notDeepEqual( arrangeNodes.map((node) => node.position), [{ x: arrangeNodes[0].position.x, y: arrangeNodes[0].position.y }, { x: arrangeNodes[0].position.x, y: arrangeNodes[0].position.y }], ); }); test("applyDagreLayout keeps trace siblings on distinct coordinates", () => { const dag = spansToTraceDag([ { id: 1, run_id: "run-1", trace_id: "trace-1", span_id: "root", parent_span_id: null, operation_name: "request", service_name: "api", start_time_nanos: 0, end_time_nanos: 4_000_000, status: "OK", attributes: null, exception_type: null, exception_message: null, exception_stack_trace: null, }, { id: 2, run_id: "run-1", trace_id: "trace-1", span_id: "child-a", parent_span_id: "root", operation_name: "db query", service_name: "postgres", start_time_nanos: 500_000, end_time_nanos: 2_000_000, status: "OK", attributes: '{"db.system":"postgresql"}', exception_type: null, exception_message: null, exception_stack_trace: null, }, { id: 3, run_id: "run-1", trace_id: "trace-1", span_id: "child-b", parent_span_id: "root", operation_name: "http call", service_name: "inventory", start_time_nanos: 1_000_000, end_time_nanos: 3_000_000, status: "OK", attributes: '{"http.method":"GET"}', exception_type: null, exception_message: null, exception_stack_trace: null, }, ]); const laidOut = applyDagreLayout(dag.nodes, dag.edges); const traceChildren = laidOut.filter((node) => node.id === "child-a" || node.id === "child-b"); assert.equal(traceChildren.length, 2); assert.notDeepEqual(traceChildren[0].position, traceChildren[1].position); }); test("getNodeLayoutSize keeps stable spacing for regular graph nodes", () => { const stepNodeSize = getNodeLayoutSize({ id: "step-1", type: "systemNode", position: { x: 0, y: 0 }, data: { kind: "step", system: "HTTP", action: "POST /products", result: "PASSED", count: 1, error: null, entries: [], traceId: null, startedAt: "2026-03-30T10:00:00.000Z", endedAt: "2026-03-30T10:00:00.200Z", durationMs: 200, inspectable: true, }, }); const gapNodeSize = getNodeLayoutSize({ id: "gap-1", type: "gapNode", position: { x: 0, y: 0 }, data: { kind: "gap", label: "Idle gap", durationMs: 1200, startedAt: "2026-03-30T10:00:00.000Z", endedAt: "2026-03-30T10:00:01.200Z", inspectable: false, }, }); assert.equal(stepNodeSize.width, 240); assert.equal(gapNodeSize.width, 208); assert.ok(stepNodeSize.height > gapNodeSize.height); }); ================================================ FILE: tools/stove-cli/spa/test/json.test.mjs ================================================ import assert from "node:assert/strict"; import test from "node:test"; import createJiti from "jiti"; const jiti = createJiti(import.meta.url); const { tryFormatJsonDeep, parseJsonDeep, filterJsonByQuery, describeJsonValue, getJsonPreviewKeys, } = await jiti.import("../src/utils/json.ts"); test("tryFormatJsonDeep expands embedded JSON strings inside structured snapshot payloads", () => { const formatted = tryFormatJsonDeep( JSON.stringify({ outboxEvents: [ JSON.stringify({ type: "ProductCreated", payload: { productId: 42, sellerId: 99, }, }), ], metadata: { count: 1, }, }), ); assert.match(formatted, /"outboxEvents": \[/); assert.match(formatted, /"type": "ProductCreated"/); assert.match(formatted, /"productId": 42/); assert.doesNotMatch(formatted, /\\"type\\"/); }); test("parseJsonDeep returns structured nested values for snapshot state rendering", () => { const parsed = parseJsonDeep( JSON.stringify({ counts: { succeeded: 4, }, outboxEvents: [ JSON.stringify({ eventType: "ProductUpdated", }), ], }), ); assert.deepEqual(parsed, { counts: { succeeded: 4, }, outboxEvents: [ { eventType: "ProductUpdated", }, ], }); }); test("snapshot json helpers describe and preview object roots for compact cards", () => { const parsed = parseJsonDeep( JSON.stringify({ registeredStubs: [], servedRequests: [], unmatchedRequests: [], metadata: { matched: 0, }, }), ); assert.equal(describeJsonValue(parsed), "4 keys"); assert.deepEqual(getJsonPreviewKeys(parsed), [ "registeredStubs", "servedRequests", "unmatchedRequests", "metadata", ]); }); test("filterJsonByQuery narrows state by matching property names while preserving subtree context", () => { const parsed = parseJsonDeep( JSON.stringify({ servedRequests: [ { method: "GET", url: "/inventory", matched: true, }, ], unmatchedRequests: [], }), ); const filtered = filterJsonByQuery(parsed, "servedRequests"); assert.equal(filtered.matchCount, 1); assert.deepEqual(filtered.filteredValue, { servedRequests: [ { method: "GET", url: "/inventory", matched: true, }, ], }); }); test("filterJsonByQuery narrows state by matching primitive values", () => { const parsed = parseJsonDeep( JSON.stringify({ servedRequests: [ { method: "GET", url: "/inventory", matched: true, }, { method: "POST", url: "/orders", matched: false, }, ], }), ); const filtered = filterJsonByQuery(parsed, "/orders"); assert.equal(filtered.matchCount, 1); assert.deepEqual(filtered.filteredValue, { servedRequests: [ { url: "/orders", }, ], }); }); test("filterJsonByQuery returns no matches when the query is absent from the state", () => { const parsed = parseJsonDeep( JSON.stringify({ servedRequests: [], unmatchedRequests: [], }), ); const filtered = filterJsonByQuery(parsed, "kafka"); assert.equal(filtered.matchCount, 0); assert.equal(filtered.filteredValue, null); }); ================================================ FILE: tools/stove-cli/spa/test/live-cache.test.mjs ================================================ import assert from "node:assert/strict"; import test from "node:test"; import createJiti from "jiti"; import { QueryClient } from "@tanstack/react-query"; const jiti = createJiti(import.meta.url); const { applyLiveDashboardEvent } = await jiti.import("../src/api/live-cache.ts"); test("applyLiveDashboardEvent updates run, test, and detail caches from live SSE payloads", () => { const queryClient = new QueryClient(); applyLiveDashboardEvent(queryClient, { seq: 1, run_id: "run-live", event_type: "run_started", payload: { app_name: "live-app", started_at: "2024-06-01T10:00:00Z", stove_version: "0.23.2", systems: ["HTTP"], }, }); applyLiveDashboardEvent(queryClient, { seq: 2, run_id: "run-live", event_type: "test_started", payload: { test_id: "test-1", test_name: "streams immediately", spec_name: "LiveSpec", started_at: "2024-06-01T10:00:01Z", status: "RUNNING", }, }); applyLiveDashboardEvent(queryClient, { seq: 3, run_id: "run-live", event_type: "entry_recorded", payload: { id: -3, test_id: "test-1", timestamp: "2024-06-01T10:00:02Z", system: "HTTP", action: "GET /health", result: "PASSED", input: null, output: null, metadata: "{}", expected: null, actual: null, error: null, trace_id: "trace-1", }, }); applyLiveDashboardEvent(queryClient, { seq: 4, run_id: "run-live", event_type: "span_recorded", payload: { id: -4, test_id: null, trace_id: "trace-1", span_id: "span-1", parent_span_id: null, operation_name: "GET /health", service_name: "live-app", start_time_nanos: 1_000_000, end_time_nanos: 2_000_000, status: "OK", attributes: "{}", exception_type: null, exception_message: null, exception_stack_trace: null, }, }); applyLiveDashboardEvent(queryClient, { seq: 5, run_id: "run-live", event_type: "test_ended", payload: { test_id: "test-1", status: "PASSED", duration_ms: 1200, error: null, ended_at: "2024-06-01T10:00:03Z", }, }); const apps = queryClient.getQueryData(["apps"]); const runs = queryClient.getQueryData(["runs", "live-app"]); const tests = queryClient.getQueryData(["tests", "run-live"]); const entries = queryClient.getQueryData(["entries", "run-live", "test-1"]); const spans = queryClient.getQueryData(["spans", "run-live", "test-1"]); assert.equal(apps.length, 1); assert.equal(apps[0].latest_run_id, "run-live"); assert.equal(apps[0].stove_version, "0.23.2"); assert.equal(runs.length, 1); assert.equal(runs[0].status, "RUNNING"); assert.equal(runs[0].stove_version, "0.23.2"); assert.equal(tests.length, 1); assert.equal(tests[0].status, "PASSED"); assert.equal(tests[0].duration_ms, 1200); assert.equal(entries.length, 1); assert.equal(entries[0].action, "GET /health"); assert.equal(spans.length, 1); assert.equal(spans[0].span_id, "span-1"); }); ================================================ FILE: tools/stove-cli/spa/test/snapshot-state.test.mjs ================================================ import assert from "node:assert/strict"; import test from "node:test"; import createJiti from "jiti"; const jiti = createJiti(import.meta.url); const { getKafkaSnapshotMetrics, hasDetailedSnapshotState, partitionSnapshotsByDetail, } = await jiti.import("../src/utils/snapshot-state.ts"); test("hasDetailedSnapshotState returns false for empty object payloads", () => { const snapshot = { state_json: "{}", }; assert.equal(hasDetailedSnapshotState(snapshot), false); }); test("hasDetailedSnapshotState returns true when structured state exists", () => { const snapshot = { state_json: JSON.stringify({ consumed: [{ topic: "orders" }, { topic: "payments" }, { topic: "orders-retry" }], published: [{ topic: "events" }], failed: [], }), }; assert.equal(hasDetailedSnapshotState(snapshot), true); }); test("hasDetailedSnapshotState returns false when nested collections are all empty", () => { const snapshot = { state_json: JSON.stringify({ registeredStubs: [], servedRequests: [], unmatchedRequests: [], }), }; assert.equal(hasDetailedSnapshotState(snapshot), false); }); test("hasDetailedSnapshotState treats scalar counters as meaningful values", () => { const snapshot = { state_json: JSON.stringify({ consumed: 0, published: 0, failed: 0, }), }; assert.equal(hasDetailedSnapshotState(snapshot), true); }); test("getKafkaSnapshotMetrics derives counts from kafka snapshot payloads", () => { const snapshot = { state_json: JSON.stringify({ consumed: [{ topic: "orders" }, { topic: "payments" }], published: [{ topic: "events" }], committed: [], failed: [{ topic: "orders.failed" }], }), }; assert.deepEqual(getKafkaSnapshotMetrics(snapshot), [ { key: "consumed", label: "Consumed", value: 2, tone: "info" }, { key: "published", label: "Published", value: 1, tone: "success" }, { key: "committed", label: "Committed", value: 0, tone: "neutral" }, { key: "failed", label: "Failed", value: 1, tone: "danger" }, ]); }); test("partitionSnapshotsByDetail separates hidden summary-only snapshots from detailed ones", () => { const snapshots = [ { id: "http", state_json: "{}", }, { id: "wiremock", state_json: JSON.stringify({ registeredStubs: [], servedRequests: [], unmatchedRequests: [], }), }, { id: "kafka", state_json: JSON.stringify({ consumed: 0, published: 1, }), }, ]; const result = partitionSnapshotsByDetail(snapshots); assert.deepEqual(result.detailedSnapshots, [snapshots[2]]); assert.equal(result.hiddenCount, 2); }); ================================================ FILE: tools/stove-cli/spa/test/version-mismatch.test.mjs ================================================ import assert from "node:assert/strict"; import test from "node:test"; import createJiti from "jiti"; const jiti = createJiti(import.meta.url); const { buildVersionMismatchBannerModel, compareVersions, summarizeVersionMismatches, } = await jiti.import( "../src/utils/version-mismatch.ts", ); test("compareVersions detects exact matches, directional mismatches, and unknown cases", () => { assert.equal(compareVersions("0.23.2", "0.23.2"), null); assert.equal(compareVersions("0.23.0", "0.23.2"), "runtime_older"); assert.equal(compareVersions("0.23.3", "0.23.2"), "cli_older"); assert.equal(compareVersions(null, "0.23.2"), "unknown"); assert.equal(compareVersions("0.23.2-SNAPSHOT", "0.23.2"), "unknown"); }); test("summarizeVersionMismatches returns null when every latest app matches the CLI", () => { const summary = summarizeVersionMismatches( [ { app_name: "alpha-api", latest_run_id: "run-1", latest_status: "PASSED", stove_version: "0.23.2", total_runs: 1, }, ], "0.23.2", "alpha-api", ); assert.equal(summary, null); }); test("summarizeVersionMismatches captures selected-app mismatch and all affected apps", () => { const summary = summarizeVersionMismatches( [ { app_name: "alpha-api", latest_run_id: "run-1", latest_status: "PASSED", stove_version: "0.23.0", total_runs: 1, }, { app_name: "beta-api", latest_run_id: "run-2", latest_status: "FAILED", stove_version: "0.23.5", total_runs: 1, }, ], "0.23.2", "alpha-api", ); assert.equal(summary.cliVersion, "0.23.2"); assert.equal(summary.mismatches.length, 2); assert.deepEqual(summary.affectedAppNames, ["alpha-api", "beta-api"]); assert.equal(summary.selectedAppMismatch.appName, "alpha-api"); assert.equal(summary.selectedAppMismatch.kind, "runtime_older"); }); test("buildVersionMismatchBannerModel returns dependency alignment guidance for older runtimes", () => { const model = buildVersionMismatchBannerModel({ cliVersion: "0.23.2", mismatches: [ { appName: "alpha-api", cliVersion: "0.23.2", runtimeVersion: "0.23.0", kind: "runtime_older", }, ], affectedAppNames: ["alpha-api"], selectedAppMismatch: { appName: "alpha-api", cliVersion: "0.23.2", runtimeVersion: "0.23.0", kind: "runtime_older", }, }); assert.equal(model.selectedAppName, "alpha-api"); assert.deepEqual(model.remediationSteps, [ { kind: "text", value: "Align the Stove BOM or all Stove test dependencies to 0.23.2.", }, ]); }); test("buildVersionMismatchBannerModel returns CLI upgrade commands when the runtime is newer", () => { const model = buildVersionMismatchBannerModel({ cliVersion: "0.23.2", mismatches: [ { appName: "beta-api", cliVersion: "0.23.2", runtimeVersion: "0.23.5", kind: "cli_older", }, ], affectedAppNames: ["beta-api"], selectedAppMismatch: { appName: "beta-api", cliVersion: "0.23.2", runtimeVersion: "0.23.5", kind: "cli_older", }, }); assert.equal(model.remediationSteps[0].kind, "text"); assert.equal(model.remediationSteps[1].kind, "command"); assert.equal(model.remediationSteps[1].value, "brew upgrade Trendyol/trendyol-tap/stove"); assert.equal( model.remediationSteps[2].value, "curl -fsSL https://raw.githubusercontent.com/Trendyol/stove/main/tools/stove-cli/install.sh | sh -s -- --version 0.23.5", ); }); test("buildVersionMismatchBannerModel stays summary-only when another app mismatches", () => { const model = buildVersionMismatchBannerModel({ cliVersion: "0.23.2", mismatches: [ { appName: "alpha-api", cliVersion: "0.23.2", runtimeVersion: "0.23.0", kind: "runtime_older", }, { appName: "beta-api", cliVersion: "0.23.2", runtimeVersion: "0.23.5", kind: "cli_older", }, ], affectedAppNames: ["alpha-api", "beta-api"], selectedAppMismatch: null, }); assert.deepEqual(model.affectedApps, ["alpha-api", "beta-api"]); assert.equal(model.switchHint, "Switch to a mismatched app to see exact remediation."); assert.deepEqual(model.remediationSteps, []); }); test("buildVersionMismatchBannerModel returns legacy guidance for missing runtime versions", () => { const model = buildVersionMismatchBannerModel({ cliVersion: "0.23.2", mismatches: [ { appName: "legacy-api", cliVersion: "0.23.2", runtimeVersion: null, kind: "unknown", }, ], affectedAppNames: ["legacy-api"], selectedAppMismatch: { appName: "legacy-api", cliVersion: "0.23.2", runtimeVersion: null, kind: "unknown", }, }); assert.equal(model.runtimeVersion, null); assert.equal(model.remediationSteps[0].kind, "text"); assert.match(model.remediationSteps[0].value, /older or non-standard Stove runtime/); }); ================================================ FILE: tools/stove-cli/spa/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["src"] } ================================================ FILE: tools/stove-cli/spa/vite.config.ts ================================================ import react from "@vitejs/plugin-react"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { defineConfig } from "vite"; const propsPath = resolve(import.meta.dirname, "../../../gradle.properties"); const version = readFileSync(propsPath, "utf-8").match(/^version=(.+)$/m)?.[1] ?? "dev"; export default defineConfig({ plugins: [react()], define: { __STOVE_VERSION__: JSON.stringify(version), }, server: { proxy: { "/api": "http://localhost:4040", }, }, }); ================================================ FILE: tools/stove-cli/src/config.rs ================================================ use std::path::Path; use clap::{Parser, Subcommand}; /// CLI configuration parsed from command-line arguments. #[derive(Parser, Debug)] #[command( name = "stove", about = "Stove CLI \u{2014} local e2e test observability", version = env!("STOVE_VERSION") )] #[allow(clippy::struct_excessive_bools)] // CLI flags are naturally bool-heavy pub struct Config { /// HTTP port for the web UI and REST API #[arg(long, default_value_t = 4040)] pub port: u16, /// gRPC port for receiving events from Stove test process #[arg(long, default_value_t = 4041)] pub grpc_port: u16, /// Path to `SQLite` database file #[arg(long, default_value_t = default_db_path())] pub db: String, /// Clear all stored runs and exit #[arg(long)] pub clear: bool, /// Drop and recreate the database from scratch (backs up existing file first) #[arg(long)] pub fresh_start: bool, /// Fetch and apply Stove agent skills from GitHub on startup without prompting. /// Useful for automation inside repositories. #[arg(long)] pub update_skills: bool, /// Skip the startup Stove agent skills check entirely. #[arg(long)] pub no_skills_check: bool, /// Optional subcommand. When omitted, the CLI runs the dashboard. #[command(subcommand)] pub command: Option, } /// Top-level subcommands for the Stove CLI. #[derive(Subcommand, Debug)] pub enum StoveCommand { /// Manage Stove agent skills under the current project. Skills { #[command(subcommand)] command: SkillsCommand, }, } /// `stove skills <...>` subcommands. #[derive(Subcommand, Debug)] pub enum SkillsCommand { /// Install or update Stove agent skills from GitHub. Install { /// Skip git repository detection and overwrite without prompting. /// Installs into the resolved skill target relative to the current directory. #[arg(long)] force: bool, }, } /// If `--fresh-start` is set, backs up the existing database file and deletes the original. /// Returns `Ok(Some(backup_path))` if a backup was created, `Ok(None)` if no file existed. /// Skips in-memory databases. pub fn handle_fresh_start(db_path: &str) -> std::io::Result> { if db_path == ":memory:" { return Ok(None); } let path = Path::new(db_path); if !path.exists() { return Ok(None); } let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); let backup_path = format!("{db_path}.backup-{timestamp}"); std::fs::copy(path, &backup_path)?; std::fs::remove_file(path)?; Ok(Some(backup_path)) } /// Returns the default database path in the user's home directory. fn default_db_path() -> String { dirs_fallback() .join(".stove-dashboard.db") .to_string_lossy() .to_string() } /// Best-effort home directory lookup without pulling in the `dirs` crate. fn dirs_fallback() -> std::path::PathBuf { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .map_or_else( |_| std::env::current_dir().unwrap_or_else(|_| ".".into()), std::path::PathBuf::from, ) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn fresh_start_backs_up_and_deletes_existing_db() { let dir = TempDir::new().unwrap(); let db_path = dir.path().join("test.db"); fs::write(&db_path, b"some data").unwrap(); let result = handle_fresh_start(db_path.to_str().unwrap()).unwrap(); assert!(result.is_some(), "should return backup path"); let backup_path = result.unwrap(); assert!(Path::new(&backup_path).exists(), "backup file should exist"); assert!(!db_path.exists(), "original file should be deleted"); assert_eq!(fs::read(&backup_path).unwrap(), b"some data"); } #[test] fn fresh_start_returns_none_when_file_does_not_exist() { let dir = TempDir::new().unwrap(); let db_path = dir.path().join("nonexistent.db"); let result = handle_fresh_start(db_path.to_str().unwrap()).unwrap(); assert!(result.is_none()); } #[test] fn fresh_start_skips_in_memory_database() { let result = handle_fresh_start(":memory:").unwrap(); assert!(result.is_none()); } #[test] fn cli_parses_default_values() { let config = Config::try_parse_from(["stove"]).unwrap(); assert_eq!(config.port, 4040); assert_eq!(config.grpc_port, 4041); assert!(!config.clear); assert!(!config.fresh_start); } #[test] fn cli_parses_custom_ports() { let config = Config::try_parse_from(["stove", "--port", "8080", "--grpc-port", "9090"]).unwrap(); assert_eq!(config.port, 8080); assert_eq!(config.grpc_port, 9090); } #[test] fn cli_parses_clear_flag() { let config = Config::try_parse_from(["stove", "--clear"]).unwrap(); assert!(config.clear); } #[test] fn cli_parses_fresh_start_flag() { let config = Config::try_parse_from(["stove", "--fresh-start"]).unwrap(); assert!(config.fresh_start); } #[test] fn cli_parses_custom_db_path() { let config = Config::try_parse_from(["stove", "--db", "/tmp/my.db"]).unwrap(); assert_eq!(config.db, "/tmp/my.db"); } #[test] fn cli_defaults_skills_flags_off() { let config = Config::try_parse_from(["stove"]).unwrap(); assert!(!config.update_skills); assert!(!config.no_skills_check); assert!(config.command.is_none()); } #[test] fn cli_parses_update_skills_flag() { let config = Config::try_parse_from(["stove", "--update-skills"]).unwrap(); assert!(config.update_skills); } #[test] fn cli_parses_no_skills_check_flag() { let config = Config::try_parse_from(["stove", "--no-skills-check"]).unwrap(); assert!(config.no_skills_check); } #[test] fn cli_parses_skills_install_subcommand() { let config = Config::try_parse_from(["stove", "skills", "install"]).unwrap(); let Some(StoveCommand::Skills { command }) = config.command else { panic!("expected skills subcommand"); }; let SkillsCommand::Install { force } = command; assert!(!force); } #[test] fn cli_parses_skills_install_force() { let config = Config::try_parse_from(["stove", "skills", "install", "--force"]).unwrap(); let Some(StoveCommand::Skills { command }) = config.command else { panic!("expected skills subcommand"); }; let SkillsCommand::Install { force } = command; assert!(force); } } ================================================ FILE: tools/stove-cli/src/error.rs ================================================ use thiserror::Error; /// Application-level error types. /// /// Uses `thiserror` for typed, displayable errors in library-like code. /// `anyhow` is used only at the top level (`main.rs`) for ergonomic `?` usage. #[derive(Error, Debug)] pub enum AppError { #[error("Database error: {0}")] Database(#[from] rusqlite::Error), #[error("gRPC transport error: {0}")] GrpcTransport(#[from] tonic::transport::Error), #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), #[error("Invalid dashboard event: {0}")] InvalidEvent(String), #[error("Server startup failed: {0}")] #[allow(dead_code)] Startup(String), } pub type Result = std::result::Result; /// Convert `AppError` into an axum-compatible HTTP response. impl axum::response::IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let status = match &self { AppError::GrpcTransport(_) => axum::http::StatusCode::BAD_GATEWAY, AppError::Serialization(_) | AppError::InvalidEvent(_) => axum::http::StatusCode::BAD_REQUEST, AppError::Database(_) | AppError::Startup(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, }; let body = axum::Json(serde_json::json!({ "error": self.to_string() })); (status, body).into_response() } } ================================================ FILE: tools/stove-cli/src/grpc/mod.rs ================================================ pub mod service; ================================================ FILE: tools/stove-cli/src/grpc/service.rs ================================================ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use tonic::{Request, Response, Status, Streaming}; use tracing::warn; use crate::error::{AppError, Result as AppResult}; use crate::ingest::{ DEFAULT_MAX_BATCH_DELAY, DEFAULT_MAX_BATCH_SIZE, EventIngestor, LiveDashboardEvent, LiveDashboardPayload, LiveEntryRecordedPayload, LiveRunEndedPayload, LiveRunStartedPayload, LiveSnapshotPayload, LiveSpanRecordedPayload, LiveTestEndedPayload, LiveTestStartedPayload, PersistedDashboardEvent, }; use crate::proto; use crate::sse::manager::SseManager; use crate::storage::models::{NewEntry, NewSpan, RunStatus}; use crate::storage::repository::Repository; mod event_type { pub const RUN_STARTED: &str = "run_started"; pub const RUN_ENDED: &str = "run_ended"; pub const TEST_STARTED: &str = "test_started"; pub const TEST_ENDED: &str = "test_ended"; pub const ENTRY_RECORDED: &str = "entry_recorded"; pub const SPAN_RECORDED: &str = "span_recorded"; pub const SNAPSHOT: &str = "snapshot"; } /// gRPC service implementation that receives events from Stove test processes. pub struct DashboardEventServiceImpl { #[allow(dead_code)] repository: Arc, ingestor: EventIngestor, sse_manager: Arc, next_live_seq: AtomicU64, state: Mutex, } impl DashboardEventServiceImpl { #[must_use] pub fn new(repository: Arc, sse_manager: Arc) -> Self { Self::new_with_ingest_config( repository, sse_manager, DEFAULT_MAX_BATCH_SIZE, DEFAULT_MAX_BATCH_DELAY, ) } #[must_use] pub fn new_with_ingest_config( repository: Arc, sse_manager: Arc, max_batch_size: usize, max_batch_delay: Duration, ) -> Self { let ingestor = EventIngestor::with_config(repository.clone(), max_batch_size, max_batch_delay); Self::new_with_ingestor(repository, sse_manager, ingestor) } #[must_use] pub fn new_with_ingestor( repository: Arc, sse_manager: Arc, ingestor: EventIngestor, ) -> Self { Self { repository, ingestor, sse_manager, next_live_seq: AtomicU64::new(0), state: Mutex::new(LiveState::default()), } } pub async fn flush_pending(&self) -> AppResult<()> { self.ingestor.flush_pending().await } /// Queue persistence work and immediately broadcast the full event to SSE. fn process_event(&self, event: &proto::DashboardEvent) -> std::result::Result<(), Status> { let Some(prepared) = self.prepare_event(event).map_err(to_status)? else { return Ok(()); }; self .ingestor .enqueue(prepared.persisted, prepared.flush_immediately) .map_err(to_status)?; let seq = self.next_live_seq.fetch_add(1, Ordering::Relaxed) + 1; let live_event = prepared.live.with_seq(seq); match serde_json::to_string(&live_event) { Ok(json) => self.sse_manager.broadcast(&json), Err(error) => warn!(%error, "Failed to serialize live SSE event"), } Ok(()) } fn prepare_event( &self, event: &proto::DashboardEvent, ) -> AppResult> { let Some(inner_event) = &event.event else { warn!("Received DashboardEvent with no event payload"); return Ok(None); }; let mut state = self .state .lock() .expect("dashboard live state lock poisoned"); let prepared = match inner_event { proto::dashboard_event::Event::RunStarted(inner) => { Ok(Self::prepare_run_started(&mut state, &event.run_id, inner)) } proto::dashboard_event::Event::RunEnded(inner) => { Self::prepare_run_ended(&mut state, &event.run_id, inner) } proto::dashboard_event::Event::TestStarted(inner) => { Self::prepare_test_started(&mut state, &event.run_id, inner) } proto::dashboard_event::Event::TestEnded(inner) => { Self::prepare_test_ended(&mut state, &event.run_id, inner) } proto::dashboard_event::Event::EntryRecorded(inner) => { Self::prepare_entry_recorded(&mut state, &event.run_id, inner) } proto::dashboard_event::Event::SpanRecorded(inner) => { Self::prepare_span_recorded(&mut state, &event.run_id, inner) } proto::dashboard_event::Event::Snapshot(inner) => { Self::prepare_snapshot(&mut state, &event.run_id, inner) } }?; Ok(Some(prepared)) } fn prepare_run_started( state: &mut LiveState, run_id: &str, event: &proto::RunStartedEvent, ) -> PreparedDashboardEvent { let started_at = format_timestamp(event.timestamp.as_ref()); state.runs.insert(run_id.to_string()); PreparedDashboardEvent { live: Self::live_event( run_id, event_type::RUN_STARTED, LiveDashboardPayload::RunStarted(LiveRunStartedPayload { app_name: event.app_name.clone(), started_at: started_at.clone(), stove_version: non_empty(&event.stove_version), systems: event.systems.clone(), }), ), persisted: PersistedDashboardEvent::RunStarted { run_id: run_id.to_string(), app_name: event.app_name.clone(), started_at, stove_version: non_empty(&event.stove_version), systems: event.systems.clone(), }, flush_immediately: false, } } fn prepare_run_ended( state: &mut LiveState, run_id: &str, event: &proto::RunEndedEvent, ) -> AppResult { ensure_run_known(state, run_id)?; let ended_at = format_timestamp(event.timestamp.as_ref()); let status = run_status(event.failed).to_string(); state.clear_run(run_id); Ok(PreparedDashboardEvent { live: Self::live_event( run_id, event_type::RUN_ENDED, LiveDashboardPayload::RunEnded(LiveRunEndedPayload { ended_at: ended_at.clone(), status, total_tests: event.total_tests, passed: event.passed, failed: event.failed, duration_ms: event.duration_ms, }), ), persisted: PersistedDashboardEvent::RunEnded { run_id: run_id.to_string(), ended_at, total_tests: event.total_tests, passed: event.passed, failed: event.failed, duration_ms: event.duration_ms, }, flush_immediately: true, }) } fn prepare_test_started( state: &mut LiveState, run_id: &str, event: &proto::TestStartedEvent, ) -> AppResult { ensure_run_known(state, run_id)?; let started_at = format_timestamp(event.timestamp.as_ref()); state .tests .insert((run_id.to_string(), event.test_id.clone())); Ok(PreparedDashboardEvent { live: Self::live_event( run_id, event_type::TEST_STARTED, LiveDashboardPayload::TestStarted(LiveTestStartedPayload { test_id: event.test_id.clone(), test_name: event.test_name.clone(), spec_name: event.spec_name.clone(), test_path: event.test_path.clone(), started_at: started_at.clone(), status: "RUNNING".to_string(), }), ), persisted: PersistedDashboardEvent::TestStarted { run_id: run_id.to_string(), test_id: event.test_id.clone(), test_name: event.test_name.clone(), spec_name: event.spec_name.clone(), test_path: event.test_path.clone(), started_at, }, flush_immediately: false, }) } fn prepare_test_ended( state: &mut LiveState, run_id: &str, event: &proto::TestEndedEvent, ) -> AppResult { ensure_test_known(state, run_id, &event.test_id)?; let ended_at = format_timestamp(event.timestamp.as_ref()); Ok(PreparedDashboardEvent { live: Self::live_event( run_id, event_type::TEST_ENDED, LiveDashboardPayload::TestEnded(LiveTestEndedPayload { test_id: event.test_id.clone(), status: event.status.clone(), duration_ms: event.duration_ms, error: non_empty(&event.error), ended_at: ended_at.clone(), }), ), persisted: PersistedDashboardEvent::TestEnded { run_id: run_id.to_string(), test_id: event.test_id.clone(), status: event.status.clone(), duration_ms: event.duration_ms, error: non_empty(&event.error), ended_at, }, flush_immediately: false, }) } fn prepare_entry_recorded( state: &mut LiveState, run_id: &str, event: &proto::EntryRecordedEvent, ) -> AppResult { ensure_test_known(state, run_id, &event.test_id)?; if !event.trace_id.is_empty() { state.traces.insert( (run_id.to_string(), event.trace_id.clone()), event.test_id.clone(), ); } let metadata = serde_json::to_string(&event.metadata)?; let timestamp = format_timestamp(event.timestamp.as_ref()); let entry = NewEntry { run_id: run_id.to_string(), test_id: event.test_id.clone(), timestamp: timestamp.clone(), system: event.system.clone(), action: event.action.clone(), result: event.result.clone(), input: event.input.clone(), output: event.output.clone(), metadata: metadata.clone(), expected: event.expected.clone(), actual: event.actual.clone(), error: event.error.clone(), trace_id: event.trace_id.clone(), }; Ok(PreparedDashboardEvent { live: Self::live_event( run_id, event_type::ENTRY_RECORDED, LiveDashboardPayload::EntryRecorded(LiveEntryRecordedPayload { id: 0, test_id: event.test_id.clone(), timestamp, system: event.system.clone(), action: event.action.clone(), result: event.result.clone(), input: non_empty(&event.input), output: non_empty(&event.output), metadata: non_empty(&metadata), expected: non_empty(&event.expected), actual: non_empty(&event.actual), error: non_empty(&event.error), trace_id: non_empty(&event.trace_id), }), ), persisted: PersistedDashboardEvent::EntryRecorded(entry), flush_immediately: false, }) } fn prepare_span_recorded( state: &mut LiveState, run_id: &str, event: &proto::SpanRecordedEvent, ) -> AppResult { ensure_run_known(state, run_id)?; let test_id = extract_test_id(&event.attributes).or_else(|| { state .traces .get(&(run_id.to_string(), event.trace_id.clone())) .cloned() }); let attributes = serde_json::to_string(&event.attributes)?; let (exception_type, exception_message, exception_stack_trace) = event .exception .as_ref() .map(|exception| { ( exception.r#type.clone(), exception.message.clone(), exception.stack_trace.join("\n"), ) }) .unwrap_or_default(); let span = NewSpan { run_id: run_id.to_string(), trace_id: event.trace_id.clone(), span_id: event.span_id.clone(), parent_span_id: event.parent_span_id.clone(), operation_name: event.operation_name.clone(), service_name: event.service_name.clone(), start_time_nanos: event.start_time_nanos, end_time_nanos: event.end_time_nanos, status: event.status.clone(), attributes: attributes.clone(), exception_type: exception_type.clone(), exception_message: exception_message.clone(), exception_stack_trace: exception_stack_trace.clone(), }; Ok(PreparedDashboardEvent { live: Self::live_event( run_id, event_type::SPAN_RECORDED, LiveDashboardPayload::SpanRecorded(LiveSpanRecordedPayload { id: 0, test_id, trace_id: event.trace_id.clone(), span_id: event.span_id.clone(), parent_span_id: non_empty(&event.parent_span_id), operation_name: event.operation_name.clone(), service_name: event.service_name.clone(), start_time_nanos: event.start_time_nanos, end_time_nanos: event.end_time_nanos, status: event.status.clone(), attributes: non_empty(&attributes), exception_type: non_empty(&exception_type), exception_message: non_empty(&exception_message), exception_stack_trace: non_empty(&exception_stack_trace), }), ), persisted: PersistedDashboardEvent::SpanRecorded(span), flush_immediately: false, }) } fn prepare_snapshot( state: &mut LiveState, run_id: &str, event: &proto::SnapshotEvent, ) -> AppResult { ensure_test_known(state, run_id, &event.test_id)?; Ok(PreparedDashboardEvent { live: Self::live_event( run_id, event_type::SNAPSHOT, LiveDashboardPayload::Snapshot(LiveSnapshotPayload { id: 0, test_id: event.test_id.clone(), system: event.system.clone(), state_json: event.state_json.clone(), summary: event.summary.clone(), }), ), persisted: PersistedDashboardEvent::Snapshot { run_id: run_id.to_string(), test_id: event.test_id.clone(), system: event.system.clone(), state_json: event.state_json.clone(), summary: event.summary.clone(), }, flush_immediately: false, }) } fn live_event( run_id: &str, event_type: &str, payload: LiveDashboardPayload, ) -> LiveDashboardEvent { LiveDashboardEvent { seq: 0, run_id: run_id.to_string(), event_type: event_type.to_string(), payload, } } } #[tonic::async_trait] impl proto::dashboard_event_service_server::DashboardEventService for DashboardEventServiceImpl { async fn stream_events( &self, request: Request>, ) -> std::result::Result, Status> { let mut stream = request.into_inner(); while let Some(event) = stream.message().await? { self.process_event(&event)?; } Ok(Response::new(proto::EventAck { accepted: true })) } async fn send_event( &self, request: Request, ) -> std::result::Result, Status> { self.process_event(&request.into_inner())?; Ok(Response::new(proto::EventAck { accepted: true })) } } struct PreparedDashboardEvent { live: LiveDashboardEvent, persisted: PersistedDashboardEvent, flush_immediately: bool, } #[derive(Default)] struct LiveState { runs: HashSet, tests: HashSet<(String, String)>, traces: HashMap<(String, String), String>, } impl LiveState { fn clear_run(&mut self, run_id: &str) { self.runs.remove(run_id); self .tests .retain(|(known_run_id, _)| known_run_id != run_id); self .traces .retain(|(known_run_id, _), _| known_run_id != run_id); } } fn ensure_run_known(state: &LiveState, run_id: &str) -> AppResult<()> { if state.runs.contains(run_id) { Ok(()) } else { Err(AppError::InvalidEvent(format!( "received event for unknown run `{run_id}`" ))) } } fn ensure_test_known(state: &LiveState, run_id: &str, test_id: &str) -> AppResult<()> { ensure_run_known(state, run_id)?; if state .tests .contains(&(run_id.to_string(), test_id.to_string())) { Ok(()) } else { Err(AppError::InvalidEvent(format!( "received event for unknown test `{test_id}` in run `{run_id}`" ))) } } fn extract_test_id(attributes: &HashMap) -> Option { [ "x-stove-test-id", "X-Stove-Test-Id", "stove.test.id", "stove_test_id", ] .iter() .find_map(|key| attributes.get(*key)) .cloned() } fn run_status(failed: i32) -> RunStatus { if failed > 0 { RunStatus::Failed } else { RunStatus::Passed } } fn format_timestamp(ts: Option<&prost_types::Timestamp>) -> String { ts.map(|timestamp| { #[allow(clippy::cast_sign_loss)] chrono::DateTime::from_timestamp(timestamp.seconds, timestamp.nanos as u32) .map(|datetime| datetime.to_rfc3339()) .unwrap_or_default() }) .unwrap_or_default() } fn non_empty(value: &str) -> Option { if value.is_empty() { None } else { Some(value.to_string()) } } #[allow(clippy::needless_pass_by_value)] fn to_status(error: AppError) -> Status { match error { AppError::InvalidEvent(message) => Status::invalid_argument(message), other => Status::internal(other.to_string()), } } #[cfg(test)] mod tests { use super::*; use crate::storage::database::Database; fn test_service() -> DashboardEventServiceImpl { let db = Database::open(":memory:").unwrap(); let repo = Arc::new(Repository::new(db)); let sse = Arc::new(SseManager::new()); DashboardEventServiceImpl::new_with_ingest_config(repo, sse, 50, Duration::from_secs(60)) } fn ts(seconds: i64) -> Option { Some(prost_types::Timestamp { seconds, nanos: 0 }) } #[tokio::test] async fn no_broadcast_on_invalid_event_order() { let svc = test_service(); let mut rx = svc.sse_manager.subscribe(); let result = svc.process_event(&proto::DashboardEvent { run_id: "nonexistent-run".to_string(), event: Some(proto::dashboard_event::Event::TestStarted( proto::TestStartedEvent { test_id: "t-1".to_string(), test_name: "orphan test".to_string(), spec_name: "Spec".to_string(), timestamp: ts(1_704_067_200), test_path: vec![], }, )), }); assert!(result.is_err(), "invalid event ordering should be rejected"); assert!( rx.try_recv().is_err(), "invalid events must not be broadcast" ); assert!(svc.repository.get_runs(None).unwrap().is_empty()); svc.flush_pending().await.unwrap(); assert!(svc.repository.get_runs(None).unwrap().is_empty()); } #[tokio::test] async fn broadcast_fires_before_batch_flush() { let svc = test_service(); let mut rx = svc.sse_manager.subscribe(); svc .process_event(&proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::RunStarted( proto::RunStartedEvent { timestamp: ts(1_704_067_200), app_name: "my-api".to_string(), systems: vec!["HTTP".to_string()], stove_version: "0.23.1".to_string(), }, )), }) .unwrap(); let msg = rx.try_recv().expect("broadcast should be sent on success"); assert!(msg.contains("run_started")); assert!( svc.repository.get_runs(None).unwrap().is_empty(), "run should not be visible in SQLite before an explicit flush" ); svc.flush_pending().await.unwrap(); let runs = svc.repository.get_runs(None).unwrap(); assert_eq!(runs.len(), 1); } #[tokio::test] async fn process_run_started_event() { let svc = test_service(); let event = proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::RunStarted( proto::RunStartedEvent { timestamp: Some(prost_types::Timestamp { seconds: 1_704_067_200, nanos: 0, }), app_name: "product-api".to_string(), systems: vec!["HTTP".to_string(), "Kafka".to_string()], stove_version: "0.23.2".to_string(), }, )), }; svc.process_event(&event).unwrap(); svc.flush_pending().await.unwrap(); let runs = svc.repository.get_runs(None).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].app_name, "product-api"); assert_eq!(runs[0].stove_version.as_deref(), Some("0.23.2")); } #[tokio::test] async fn process_full_lifecycle() { let svc = test_service(); svc .process_event(&proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::RunStarted( proto::RunStartedEvent { timestamp: Some(prost_types::Timestamp { seconds: 1_704_067_200, nanos: 0, }), app_name: "test-app".to_string(), stove_version: String::new(), systems: vec![], }, )), }) .unwrap(); svc .process_event(&proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::TestStarted( proto::TestStartedEvent { test_id: "test-1".to_string(), test_name: "my test".to_string(), spec_name: "MySpec".to_string(), timestamp: Some(prost_types::Timestamp { seconds: 1_704_067_201, nanos: 0, }), test_path: vec![], }, )), }) .unwrap(); svc .process_event(&proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::EntryRecorded( proto::EntryRecordedEvent { test_id: "test-1".to_string(), timestamp: Some(prost_types::Timestamp { seconds: 1_704_067_202, nanos: 0, }), system: "HTTP".to_string(), action: "GET /api".to_string(), result: "PASSED".to_string(), input: String::new(), output: String::new(), metadata: std::collections::HashMap::default(), expected: String::new(), actual: String::new(), error: String::new(), trace_id: String::new(), }, )), }) .unwrap(); svc .process_event(&proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::TestEnded( proto::TestEndedEvent { test_id: "test-1".to_string(), status: "PASSED".to_string(), duration_ms: 500, error: String::new(), timestamp: Some(prost_types::Timestamp { seconds: 1_704_067_203, nanos: 0, }), }, )), }) .unwrap(); svc .process_event(&proto::DashboardEvent { run_id: "run-1".to_string(), event: Some(proto::dashboard_event::Event::RunEnded( proto::RunEndedEvent { timestamp: Some(prost_types::Timestamp { seconds: 1_704_067_210, nanos: 0, }), total_tests: 1, passed: 1, failed: 0, duration_ms: 10000, }, )), }) .unwrap(); svc.flush_pending().await.unwrap(); let runs = svc.repository.get_runs(None).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].status, crate::storage::models::RunStatus::Passed); let tests = svc.repository.get_tests_for_run("run-1").unwrap(); assert_eq!(tests.len(), 1); assert_eq!(tests[0].status, crate::storage::models::TestStatus::Passed); let entries = svc.repository.get_entries("run-1", "test-1").unwrap(); assert_eq!(entries.len(), 1); } } ================================================ FILE: tools/stove-cli/src/http/mod.rs ================================================ pub mod routes; pub mod server; ================================================ FILE: tools/stove-cli/src/http/routes/meta.rs ================================================ use axum::Json; use axum::http::HeaderMap; use axum::http::header::HOST; use serde::Serialize; use crate::STOVE_CLI_VERSION; #[derive(Serialize)] pub struct MetaResponse { pub stove_cli_version: &'static str, pub mcp: McpMeta, } #[derive(Serialize)] pub struct McpMeta { pub enabled: bool, pub transport: &'static str, pub endpoint: String, pub scope: &'static str, } pub async fn get_meta(headers: HeaderMap) -> Json { Json(MetaResponse { stove_cli_version: STOVE_CLI_VERSION, mcp: McpMeta { enabled: true, transport: "streamable-http", endpoint: mcp_endpoint(&headers), scope: "read-only-test-observability", }, }) } fn mcp_endpoint(headers: &HeaderMap) -> String { headers .get(HOST) .and_then(|value| value.to_str().ok()) .filter(|host| !host.trim().is_empty()) .map_or_else(|| "/mcp".to_string(), |host| format!("http://{host}/mcp")) } ================================================ FILE: tools/stove-cli/src/http/routes/mod.rs ================================================ mod meta; mod runs; mod sse; mod static_files; mod tests; mod traces; pub use meta::*; pub use runs::*; pub use sse::*; pub use static_files::*; pub use tests::*; pub use traces::*; ================================================ FILE: tools/stove-cli/src/http/routes/runs.rs ================================================ use axum::Json; use axum::extract::{Path, Query, State}; use serde::Deserialize; use crate::http::server::AppState; use crate::storage::models::{AppSummary, Run}; #[derive(Deserialize)] pub struct RunsQuery { pub app: Option, } pub async fn get_apps( State(state): State, ) -> Result>, crate::error::AppError> { let apps = state.repository.get_apps()?; Ok(Json(apps)) } pub async fn get_runs( State(state): State, Query(query): Query, ) -> Result>, crate::error::AppError> { let runs = state.repository.get_runs(query.app.as_deref())?; Ok(Json(runs)) } pub async fn get_run( State(state): State, Path(run_id): Path, ) -> Result>, crate::error::AppError> { let run = state.repository.get_run(&run_id)?; Ok(Json(run)) } pub async fn clear_all( State(state): State, ) -> Result, crate::error::AppError> { state.repository.clear_all()?; Ok(Json(serde_json::json!({ "cleared": true }))) } ================================================ FILE: tools/stove-cli/src/http/routes/sse.rs ================================================ use std::convert::Infallible; use std::time::Duration; use axum::extract::State; use axum::response::sse::{Event, KeepAlive, Sse}; use tokio_stream::StreamExt; use tokio_stream::wrappers::BroadcastStream; use crate::http::server::AppState; /// SSE endpoint that streams dashboard events to connected browser clients. /// /// Sends a keep-alive comment every 15 seconds to prevent proxies and browsers /// from closing the connection during long-running tests. pub async fn sse_handler( State(state): State, ) -> Sse>> { let rx = state.sse_manager.subscribe(); let stream = BroadcastStream::new(rx) .filter_map(|result| result.ok().map(|data| Ok(Event::default().data(data)))); Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) .text("keep-alive"), ) } ================================================ FILE: tools/stove-cli/src/http/routes/static_files.rs ================================================ use axum::http::{StatusCode, Uri, header}; use axum::response::{IntoResponse, Response}; use rust_embed::Embed; /// Embedded SPA assets, baked into the binary at compile time. /// /// In development, this folder may be empty — the SPA is served by Vite's dev server instead. #[derive(Embed)] #[folder = "spa/dist/"] #[allow(dead_code)] struct SpaAssets; /// Serve embedded SPA files, falling back to `index.html` for client-side routing. pub async fn static_handler(uri: Uri) -> Response { let path = uri.path().trim_start_matches('/'); // Try exact file match first if let Some(file) = SpaAssets::get(path) { let mime = mime_guess::from_path(path).first_or_octet_stream(); return ([(header::CONTENT_TYPE, mime.as_ref())], file.data).into_response(); } if is_asset_like_path(path) { return (StatusCode::NOT_FOUND, "Asset not found").into_response(); } // Fallback to index.html for SPA client-side routing match SpaAssets::get("index.html") { Some(file) => ([(header::CONTENT_TYPE, "text/html")], file.data).into_response(), None => ( StatusCode::NOT_FOUND, "SPA not built. Run: cd spa && npm run build", ) .into_response(), } } fn is_asset_like_path(path: &str) -> bool { !path.is_empty() && std::path::Path::new(path).extension().is_some() } #[cfg(test)] mod tests { use super::is_asset_like_path; #[test] fn detects_asset_like_paths_by_extension() { assert!(is_asset_like_path("assets/app.js")); assert!(is_asset_like_path("styles/main.css")); assert!(!is_asset_like_path("")); assert!(!is_asset_like_path("runs/run-1")); assert!(!is_asset_like_path("dashboard/settings")); } } ================================================ FILE: tools/stove-cli/src/http/routes/tests.rs ================================================ use axum::Json; use axum::extract::{Path, State}; use crate::http::server::AppState; use crate::storage::models::{Entry, Snapshot, Test}; pub async fn get_tests( State(state): State, Path(run_id): Path, ) -> Result>, crate::error::AppError> { let tests = state.repository.get_tests_for_run(&run_id)?; Ok(Json(tests)) } pub async fn get_entries( State(state): State, Path((run_id, test_id)): Path<(String, String)>, ) -> Result>, crate::error::AppError> { let entries = state.repository.get_entries(&run_id, &test_id)?; Ok(Json(entries)) } pub async fn get_snapshots( State(state): State, Path((run_id, test_id)): Path<(String, String)>, ) -> Result>, crate::error::AppError> { let snapshots = state.repository.get_snapshots(&run_id, &test_id)?; Ok(Json(snapshots)) } pub async fn get_test_spans( State(state): State, Path((run_id, test_id)): Path<(String, String)>, ) -> Result>, crate::error::AppError> { let spans = state.repository.get_spans_for_test(&run_id, &test_id)?; Ok(Json(spans)) } ================================================ FILE: tools/stove-cli/src/http/routes/traces.rs ================================================ use axum::Json; use axum::extract::{Path, State}; use crate::http::server::AppState; use crate::storage::models::Span; pub async fn get_trace( State(state): State, Path(trace_id): Path, ) -> Result>, crate::error::AppError> { let spans = state.repository.get_trace(&trace_id)?; Ok(Json(spans)) } ================================================ FILE: tools/stove-cli/src/http/server.rs ================================================ use std::sync::Arc; use axum::Router; use axum::routing::{delete, get}; use tower_http::cors::CorsLayer; use crate::ingest::EventIngestor; use crate::sse::manager::SseManager; use crate::storage::repository::Repository; /// Shared application state passed to all HTTP handlers. #[derive(Clone)] pub struct AppState { pub repository: Arc, pub sse_manager: Arc, pub ingestor: Option, } /// Create the axum router with all API routes, SSE, and embedded SPA. pub fn create_router(repository: Arc, sse_manager: Arc) -> Router { create_router_with_ingestor(repository, sse_manager, None) } /// Create the axum router with an optional ingest flush handle for MCP reads. pub fn create_router_with_ingestor( repository: Arc, sse_manager: Arc, ingestor: Option, ) -> Router { let state = AppState { repository, sse_manager, ingestor, }; let api = Router::new() .route("/meta", get(super::routes::get_meta)) .route("/apps", get(super::routes::get_apps)) .route("/runs", get(super::routes::get_runs)) .route("/runs/{run_id}", get(super::routes::get_run)) .route("/runs/{run_id}/tests", get(super::routes::get_tests)) .route( "/runs/{run_id}/tests/{test_id}/entries", get(super::routes::get_entries), ) .route( "/runs/{run_id}/tests/{test_id}/spans", get(super::routes::get_test_spans), ) .route( "/runs/{run_id}/tests/{test_id}/snapshots", get(super::routes::get_snapshots), ) .route("/traces/{trace_id}", get(super::routes::get_trace)) .route("/events/stream", get(super::routes::sse_handler)) .route("/data", delete(super::routes::clear_all)); Router::new() .route( "/mcp", get(crate::mcp::handle_get).post(crate::mcp::handle_post), ) .nest("/api/v1", api) .fallback(super::routes::static_handler) .layer(CorsLayer::permissive()) .with_state(state) } ================================================ FILE: tools/stove-cli/src/ingest.rs ================================================ use std::sync::Arc; use std::time::Duration; use serde::Serialize; use tokio::sync::{mpsc, oneshot}; use tracing::warn; use crate::error::{AppError, Result}; use crate::storage::models::{NewEntry, NewSpan}; use crate::storage::repository::Repository; pub const DEFAULT_MAX_BATCH_SIZE: usize = 20; pub const DEFAULT_MAX_BATCH_DELAY: Duration = Duration::from_secs(5); #[derive(Clone, Debug)] pub enum PersistedDashboardEvent { RunStarted { run_id: String, app_name: String, started_at: String, stove_version: Option, systems: Vec, }, RunEnded { run_id: String, ended_at: String, total_tests: i32, passed: i32, failed: i32, duration_ms: i64, }, TestStarted { run_id: String, test_id: String, test_name: String, spec_name: String, test_path: Vec, started_at: String, }, TestEnded { run_id: String, test_id: String, status: String, duration_ms: i64, error: Option, ended_at: String, }, EntryRecorded(NewEntry), SpanRecorded(NewSpan), Snapshot { run_id: String, test_id: String, system: String, state_json: String, summary: String, }, } #[derive(Clone, Debug, Serialize)] pub struct LiveDashboardEvent { pub seq: u64, pub run_id: String, pub event_type: String, pub payload: LiveDashboardPayload, } impl LiveDashboardEvent { #[must_use] pub fn with_seq(mut self, seq: u64) -> Self { self.seq = seq; let temp_id = live_record_id(seq); match &mut self.payload { LiveDashboardPayload::EntryRecorded(payload) => payload.id = temp_id, LiveDashboardPayload::SpanRecorded(payload) => payload.id = temp_id, LiveDashboardPayload::Snapshot(payload) => payload.id = temp_id, LiveDashboardPayload::RunStarted(_) | LiveDashboardPayload::RunEnded(_) | LiveDashboardPayload::TestStarted(_) | LiveDashboardPayload::TestEnded(_) => {} } self } } #[derive(Clone, Debug, Serialize)] #[serde(untagged)] pub enum LiveDashboardPayload { RunStarted(LiveRunStartedPayload), RunEnded(LiveRunEndedPayload), TestStarted(LiveTestStartedPayload), TestEnded(LiveTestEndedPayload), EntryRecorded(LiveEntryRecordedPayload), SpanRecorded(LiveSpanRecordedPayload), Snapshot(LiveSnapshotPayload), } #[derive(Clone, Debug, Serialize)] pub struct LiveRunStartedPayload { pub app_name: String, pub started_at: String, pub stove_version: Option, pub systems: Vec, } #[derive(Clone, Debug, Serialize)] pub struct LiveRunEndedPayload { pub ended_at: String, pub status: String, pub total_tests: i32, pub passed: i32, pub failed: i32, pub duration_ms: i64, } #[derive(Clone, Debug, Serialize)] pub struct LiveTestStartedPayload { pub test_id: String, pub test_name: String, pub spec_name: String, pub test_path: Vec, pub started_at: String, pub status: String, } #[derive(Clone, Debug, Serialize)] pub struct LiveTestEndedPayload { pub test_id: String, pub status: String, pub duration_ms: i64, pub error: Option, pub ended_at: String, } #[derive(Clone, Debug, Serialize)] pub struct LiveEntryRecordedPayload { pub id: i64, pub test_id: String, pub timestamp: String, pub system: String, pub action: String, pub result: String, pub input: Option, pub output: Option, pub metadata: Option, pub expected: Option, pub actual: Option, pub error: Option, pub trace_id: Option, } #[derive(Clone, Debug, Serialize)] pub struct LiveSpanRecordedPayload { pub id: i64, pub test_id: Option, pub trace_id: String, pub span_id: String, pub parent_span_id: Option, pub operation_name: String, pub service_name: String, pub start_time_nanos: i64, pub end_time_nanos: i64, pub status: String, pub attributes: Option, pub exception_type: Option, pub exception_message: Option, pub exception_stack_trace: Option, } #[derive(Clone, Debug, Serialize)] pub struct LiveSnapshotPayload { pub id: i64, pub test_id: String, pub system: String, pub state_json: String, pub summary: String, } #[derive(Clone)] pub struct EventIngestor { sender: mpsc::UnboundedSender, } impl EventIngestor { #[must_use] pub fn new(repository: Arc) -> Self { Self::with_config(repository, DEFAULT_MAX_BATCH_SIZE, DEFAULT_MAX_BATCH_DELAY) } #[must_use] pub fn with_config( repository: Arc, max_batch_size: usize, max_batch_delay: Duration, ) -> Self { let (sender, receiver) = mpsc::unbounded_channel(); tokio::spawn(run_ingest_loop( repository, receiver, max_batch_size, max_batch_delay, )); Self { sender } } pub fn enqueue(&self, event: PersistedDashboardEvent, flush_immediately: bool) -> Result<()> { self .sender .send(IngestCommand::Persist { event: Box::new(event), flush_immediately, }) .map_err(|_| AppError::Startup("persistence worker is not running".to_string())) } pub async fn flush_pending(&self) -> Result<()> { let (reply_tx, reply_rx) = oneshot::channel(); self .sender .send(IngestCommand::Flush { reply: reply_tx }) .map_err(|_| AppError::Startup("persistence worker is not running".to_string()))?; reply_rx .await .map_err(|_| AppError::Startup("persistence worker stopped before flushing".to_string()))? } } enum IngestCommand { Persist { event: Box, flush_immediately: bool, }, Flush { reply: oneshot::Sender>, }, } async fn run_ingest_loop( repository: Arc, mut receiver: mpsc::UnboundedReceiver, max_batch_size: usize, max_batch_delay: Duration, ) { let mut pending = Vec::with_capacity(max_batch_size.max(1)); loop { if pending.is_empty() { let Some(command) = receiver.recv().await else { break; }; handle_command( command, repository.as_ref(), &mut pending, max_batch_size.max(1), ); continue; } let delay = tokio::time::sleep(max_batch_delay); tokio::pin!(delay); tokio::select! { maybe_command = receiver.recv() => { if let Some(command) = maybe_command { handle_command( command, repository.as_ref(), &mut pending, max_batch_size.max(1), ); } else { if let Err(error) = persist_pending(repository.as_ref(), &mut pending) { warn!(%error, "Failed to flush pending dashboard events during shutdown"); } break; } } () = &mut delay => { if let Err(error) = persist_pending(repository.as_ref(), &mut pending) { warn!(%error, "Failed to persist a batched dashboard event flush"); } } } } } fn handle_command( command: IngestCommand, repository: &Repository, pending: &mut Vec, max_batch_size: usize, ) { match command { IngestCommand::Persist { event, flush_immediately, } => { pending.push(*event); if (flush_immediately || pending.len() >= max_batch_size) && let Err(error) = persist_pending(repository, pending) { warn!(%error, "Failed to persist dashboard events after batch threshold"); } } IngestCommand::Flush { reply } => { let _ = reply.send(persist_pending(repository, pending)); } } } fn persist_pending( repository: &Repository, pending: &mut Vec, ) -> Result<()> { if pending.is_empty() { return Ok(()); } let batch = std::mem::take(pending); match repository.apply_persisted_events(&batch) { Ok(()) => Ok(()), Err(batch_error) => { warn!( %batch_error, batch_size = batch.len(), "Batch persistence failed, retrying events individually" ); let mut first_individual_error = None; for event in &batch { if let Err(error) = repository.apply_persisted_events(std::slice::from_ref(event)) { warn!(%error, "Failed to persist dashboard event after individual retry"); if first_individual_error.is_none() { first_individual_error = Some(error); } } } if let Some(error) = first_individual_error { Err(error) } else { Ok(()) } } } } fn live_record_id(seq: u64) -> i64 { let bounded = seq.min(i64::MAX as u64); -bounded.cast_signed() } ================================================ FILE: tools/stove-cli/src/lib.rs ================================================ #![deny(clippy::all)] #![warn(clippy::pedantic)] // Allow these pedantic lints project-wide — they conflict with our conventions. #![allow(clippy::module_name_repetitions)] #![allow(clippy::missing_errors_doc)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::redundant_closure_for_method_calls)] pub const STOVE_CLI_VERSION: &str = env!("STOVE_VERSION"); pub mod config; pub mod error; pub mod grpc; pub mod http; pub mod ingest; pub mod mcp; pub mod skills; pub mod sse; pub mod storage; /// Generated protobuf types from shared `.proto` contract. #[allow(clippy::pedantic)] pub mod proto { tonic::include_proto!("stove.dashboard.v1"); } ================================================ FILE: tools/stove-cli/src/main.rs ================================================ use std::net::SocketAddr; use std::sync::Arc; use clap::Parser; use tracing::info; use stove::config; use stove::grpc; use stove::http; use stove::ingest; use stove::proto; use stove::skills; use stove::sse; use stove::storage; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); let config = config::Config::parse(); // Handle a `skills` subcommand if requested. Returns true when handled. if skills::handle_skills_command(&config).await? { return Ok(()); } // Handle --fresh-start: back up and delete the existing database if config.fresh_start { if let Some(backup_path) = config::handle_fresh_start(&config.db)? { info!("Backed up database to {}", backup_path); println!(" Backed up database to {backup_path}"); } println!(" Starting fresh — database will be recreated."); } // Initialize database let db = storage::database::Database::open(&config.db)?; let repository = Arc::new(storage::repository::Repository::new(db)); // Handle --clear flag if config.clear { repository.clear_all()?; info!("Cleared all stored runs."); return Ok(()); } // Suggest or apply Stove agent skills update before serving. // Network/IO errors are swallowed inside; never blocks startup. skills::maybe_update_skills(&config).await; let sse_manager = Arc::new(sse::manager::SseManager::new()); let ingestor = ingest::EventIngestor::new(repository.clone()); // Start gRPC server let grpc_addr: SocketAddr = format!("0.0.0.0:{}", config.grpc_port).parse()?; let grpc_service = grpc::service::DashboardEventServiceImpl::new_with_ingestor( repository.clone(), sse_manager.clone(), ingestor.clone(), ); let grpc_handle = tokio::spawn(async move { info!("gRPC server listening on {}", grpc_addr); tonic::transport::Server::builder() .add_service( proto::dashboard_event_service_server::DashboardEventServiceServer::new(grpc_service), ) .serve(grpc_addr) .await }); // Start HTTP server let http_addr: SocketAddr = format!("0.0.0.0:{}", config.port).parse()?; let router = http::server::create_router_with_ingestor(repository, sse_manager, Some(ingestor)); let http_handle = tokio::spawn(async move { info!("HTTP server listening on {}", http_addr); let listener = tokio::net::TcpListener::bind(http_addr).await?; axum::serve( listener, router.into_make_service_with_connect_info::(), ) .await }); println!( "\n Stove CLI v{} running\n UI: http://localhost:{}\n REST: http://localhost:{}/api/v1\n MCP: http://localhost:{}/mcp\n gRPC: localhost:{}\n", env!("STOVE_VERSION"), config.port, config.port, config.port, config.grpc_port ); // Wait for either server to finish (or error) tokio::select! { result = grpc_handle => { result??; } result = http_handle => { result??; } } Ok(()) } ================================================ FILE: tools/stove-cli/src/mcp/analysis/evidence.rs ================================================ use serde_json::{Value, json}; use super::super::contract::{ArgName, RawEvidenceKind, ToolName}; use crate::storage::models::{Entry, Snapshot, Span}; pub(super) fn entry_preview(entry: &Entry, max_chars: usize) -> Value { json!({ "id": entry.id, "timestamp": entry.timestamp, "system": entry.system, "action": entry.action, "result": entry.result, "input": preview_field(entry.input.as_deref(), max_chars), "output": preview_field(entry.output.as_deref(), max_chars), "metadata": preview_field(entry.metadata.as_deref(), max_chars), "expected": preview_field(entry.expected.as_deref(), max_chars), "actual": preview_field(entry.actual.as_deref(), max_chars), "error": clip_opt(entry.error.as_deref(), max_chars), "trace_id": entry.trace_id, "raw_tool_call": super::tool_call(ToolName::RawEvidence, super::tool_args([ (ArgName::Kind, json!(RawEvidenceKind::Entry.as_str())), (ArgName::Id, json!(entry.id)), (ArgName::RunId, json!(&entry.run_id)), (ArgName::TestId, json!(&entry.test_id)), ])), }) } pub(super) fn span_preview(span: &Span, max_chars: usize) -> Value { json!({ "id": span.id, "trace_id": span.trace_id, "span_id": span.span_id, "parent_span_id": span.parent_span_id, "operation_name": span.operation_name, "service_name": span.service_name, "duration_ms": nanos_to_millis(span.end_time_nanos - span.start_time_nanos), "status": span.status, "attributes": preview_field(span.attributes.as_deref(), max_chars), "exception_type": span.exception_type, "exception_message": clip_opt(span.exception_message.as_deref(), max_chars), "exception_stack_trace": clip_opt(span.exception_stack_trace.as_deref(), max_chars), }) } pub(super) fn snapshot_summary(snapshot: &Snapshot, max_chars: usize) -> Value { let state = parse_state(&snapshot.state_json, max_chars); json!({ "id": snapshot.id, "system": snapshot.system, "summary": clip_string(&snapshot.summary, max_chars), "state_overview": state_overview(&state), "snapshot_tool_call": super::tool_call(ToolName::Snapshot, super::tool_args([ (ArgName::RunId, json!(&snapshot.run_id)), (ArgName::TestId, json!(&snapshot.test_id)), (ArgName::System, json!(&snapshot.system)), ])), }) } pub(super) fn snapshot_detail( snapshot: &Snapshot, pointer: Option<&str>, max_chars: usize, ) -> Value { let parsed = parse_state(&snapshot.state_json, max_chars); let selected_state = pointer.map_or_else( || parsed.clone(), |pointer| { parsed .get("value") .and_then(|value| value.pointer(pointer)) .map_or_else( || json!({ "parse_status": "pointer_not_found", "json_pointer": pointer }), |value| json!({ "parse_status": "ok", "json_pointer": pointer, "value": redact_value(value, max_chars) }), ) }, ); json!({ "id": snapshot.id, "system": snapshot.system, "summary": clip_string(&snapshot.summary, max_chars), "state": selected_state, "raw_tool_call": super::tool_call(ToolName::RawEvidence, super::tool_args([ (ArgName::Kind, json!(RawEvidenceKind::Snapshot.as_str())), (ArgName::Id, json!(snapshot.id)), (ArgName::RunId, json!(&snapshot.run_id)), (ArgName::TestId, json!(&snapshot.test_id)), ])), }) } fn state_overview(parsed: &Value) -> Value { let Some(value) = parsed.get("value") else { return parsed.clone(); }; match value { Value::Object(map) => json!({ "type": "object", "keys": map.keys().take(20).collect::>(), "key_count": map.len(), }), Value::Array(items) => json!({ "type": "array", "item_count": items.len() }), _ => json!({ "type": value_type(value), "value": value }), } } fn parse_state(raw: &str, max_chars: usize) -> Value { match serde_json::from_str::(raw) { Ok(value) => json!({ "parse_status": "ok", "value": redact_value(&value, max_chars) }), Err(error) => json!({ "parse_status": "malformed_json", "parse_error": error.to_string(), "raw_preview": clip_string(raw, max_chars), }), } } fn preview_field(raw: Option<&str>, max_chars: usize) -> Value { let Some(raw) = raw.filter(|value| !value.is_empty()) else { return Value::Null; }; match serde_json::from_str::(raw) { Ok(value) => redact_value(&value, max_chars), Err(error) => json!({ "parse_status": "plain_or_malformed", "parse_error": error.to_string(), "preview": clip_string(raw, max_chars), }), } } fn redact_value(value: &Value, max_chars: usize) -> Value { match value { Value::Object(map) => Value::Object( map .iter() .map(|(key, value)| { if is_sensitive_key(key) { (key.clone(), Value::String("[REDACTED]".to_string())) } else { (key.clone(), redact_value(value, max_chars)) } }) .collect(), ), Value::Array(items) => Value::Array( items .iter() .take(50) .map(|item| redact_value(item, max_chars)) .collect(), ), Value::String(value) => json!(clip_string(value, max_chars)), _ => value.clone(), } } fn is_sensitive_key(key: &str) -> bool { let lower = key.to_ascii_lowercase(); [ "authorization", "cookie", "password", "secret", "token", "apikey", "api_key", "credential", ] .iter() .any(|needle| lower.contains(needle)) } pub(super) fn clip_opt(value: Option<&str>, max_chars: usize) -> Value { value .filter(|value| !value.is_empty()) .map_or(Value::Null, |value| json!(clip_string(value, max_chars))) } fn clip_string(value: &str, max_chars: usize) -> String { let chars = value.chars().count(); if chars <= max_chars { return value.to_string(); } let prefix: String = value.chars().take(max_chars).collect(); format!( "{prefix}...", chars.saturating_sub(max_chars) ) } #[allow(clippy::cast_precision_loss)] fn nanos_to_millis(nanos: i64) -> f64 { nanos as f64 / 1_000_000.0 } fn value_type(value: &Value) -> &'static str { match value { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", } } #[cfg(test)] mod tests { use super::*; #[test] fn redacts_sensitive_keys_recursively() { let value = json!({ "Authorization": "Bearer secret", "nested": { "apiKey": "abc", "safe": "value" }, "items": [{ "password": "pw" }] }); let redacted = redact_value(&value, 100); assert_eq!(redacted["Authorization"], "[REDACTED]"); assert_eq!(redacted["nested"]["apiKey"], "[REDACTED]"); assert_eq!(redacted["nested"]["safe"], "value"); assert_eq!(redacted["items"][0]["password"], "[REDACTED]"); } #[test] fn clips_long_strings_deterministically() { let clipped = clip_string("abcdef", 3); assert_eq!(clipped, "abc..."); } #[test] fn malformed_json_preview_keeps_parse_error() { let preview = preview_field(Some("{bad"), 20); assert_eq!(preview["parse_status"], "plain_or_malformed"); assert!(preview["parse_error"].as_str().unwrap().contains("key")); } } ================================================ FILE: tools/stove-cli/src/mcp/analysis.rs ================================================ mod evidence; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; use serde_json::{Value, json}; use self::evidence::{clip_opt, entry_preview, snapshot_detail, snapshot_summary, span_preview}; use super::args::{ Budget, ExactTestArgs, FailuresArgs, ListArgs, RawEvidenceArgs, RunsArgs, SnapshotArgs, TimelineArgs, TraceArgs, parse, }; use super::contract::{ ArgName, RawEvidenceKind, RunStatusValue, STATUS_ERROR, TimelineFocus, ToolName, }; use crate::ingest::EventIngestor; use crate::storage::models::{Entry, Run, RunStatus, Span, Test, TestStatus}; use crate::storage::repository::Repository; const FLUSH_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Debug, Clone)] pub struct ToolOutput { pub structured: Value, pub text: String, } #[derive(Clone)] pub struct Analyzer { repository: Arc, ingestor: Option, } impl Analyzer { #[must_use] pub fn new(repository: Arc, ingestor: Option) -> Self { Self { repository, ingestor, } } pub async fn call_tool(&self, name: &str, arguments: Value) -> Result { self.flush_pending().await; match ToolName::from_str(name) { Some(ToolName::Apps) => self.apps(arguments), Some(ToolName::Runs) => self.runs(arguments), Some(ToolName::Failures) => self.failures(arguments), Some(ToolName::FailureDetail) => self.failure_detail(arguments), Some(ToolName::Timeline) => self.timeline(arguments), Some(ToolName::Trace) => self.trace(arguments), Some(ToolName::Snapshot) => self.snapshot(arguments), Some(ToolName::RawEvidence) => self.raw_evidence(arguments), None => Err(format!("unknown Stove MCP tool: {name}")), } } async fn flush_pending(&self) { let Some(ingestor) = &self.ingestor else { return; }; let _ = tokio::time::timeout(FLUSH_TIMEOUT, ingestor.flush_pending()).await; } fn apps(&self, arguments: Value) -> Result { let args: ListArgs = parse(arguments)?; let limit = args.limit(); let apps = self.repository.get_apps().map_err(display_error)?; let total_apps = apps.len(); let runs = self.repository.get_runs(None).map_err(display_error)?; let mut failed_runs_by_app: HashMap = HashMap::new(); for run in &runs { if run.status == RunStatus::Failed { *failed_runs_by_app.entry(run.app_name.clone()).or_insert(0) += 1; } } let items: Vec = apps .into_iter() .take(limit) .map(|app| { json!({ "app_name": app.app_name, "latest_run_id": app.latest_run_id, "latest_status": app.latest_status, "stove_version": app.stove_version, "total_runs": app.total_runs, "failed_runs": failed_runs_by_app.get(&app.app_name).copied().unwrap_or_default(), "runs_tool_call": tool_call(ToolName::Runs, tool_args([(ArgName::AppName, json!(&app.app_name))])), "failures_tool_call": tool_call(ToolName::Failures, tool_args([(ArgName::AppName, json!(&app.app_name))])), }) }) .collect(); let structured = json!({ "apps": items, "count": items.len(), "total_apps": total_apps, "omitted_apps": total_apps.saturating_sub(items.len()), "fallback": fallback_message(), }); Ok(output(structured, "Known Stove apps")) } fn runs(&self, arguments: Value) -> Result { let args: RunsArgs = parse(arguments)?; let limit = args.common.limit(); let status_filter = args.status.as_deref().map(str::to_ascii_uppercase); let mut runs = self .repository .get_runs(args.app_name.as_deref()) .map_err(display_error)?; if let Some(status) = &status_filter { runs.retain(|run| run.status.to_string() == *status); } let total_runs = runs.len(); let items: Vec = runs .into_iter() .take(limit) .map(|run| { json!({ "app_name": run.app_name, "run_id": run.id, "status": run.status, "started_at": run.started_at, "ended_at": run.ended_at, "total_tests": run.total_tests, "passed": run.passed, "failed": run.failed, "duration_ms": run.duration_ms, "stove_version": run.stove_version, "systems": run.systems, "failures_tool_call": tool_call(ToolName::Failures, tool_args([(ArgName::RunId, json!(&run.id))])), }) }) .collect(); let structured = json!({ "runs": items, "count": items.len(), "total_runs": total_runs, "omitted_runs": total_runs.saturating_sub(items.len()), "selector_rules": selector_rules(), "fallback": fallback_message(), }); Ok(output(structured, "Stove runs")) } fn failures(&self, arguments: Value) -> Result { let args: FailuresArgs = parse(arguments)?; let limit = args.common.limit(); let runs = selected_runs( &self.repository, args.app_name.as_deref(), args.run_id.as_deref(), )?; let mut groups = Vec::new(); let mut selected_failures = 0_usize; let mut total_failures = 0_usize; for run in runs { let tests = self .repository .get_tests_for_run(&run.id) .map_err(display_error)?; let failed_tests: Vec = tests.into_iter().filter(is_failed_test).collect(); total_failures += failed_tests.len(); let remaining = limit.saturating_sub(selected_failures); let failures: Vec = failed_tests .into_iter() .take(remaining) .map(|test| failure_item(&run, &test)) .collect(); if failures.is_empty() { continue; } selected_failures += failures.len(); groups.push(json!({ "app_name": run.app_name, "run_id": run.id, "run_status": run.status, "started_at": run.started_at, "ended_at": run.ended_at, "stove_version": run.stove_version, "failures": failures, })); } let structured = json!({ "groups": groups, "failure_count": selected_failures, "total_failure_count": total_failures, "omitted_failures": total_failures.saturating_sub(selected_failures), "data_freshness": if groups_have_running_runs(&groups) { "partial" } else { "complete_or_idle" }, "selector_rules": selector_rules(), "fallback": fallback_message(), }); Ok(output( structured, "Stove failed tests grouped by app and run", )) } fn failure_detail(&self, arguments: Value) -> Result { let args: ExactTestArgs = parse(arguments)?; let budget = Budget::from_args(args.common.budget.as_deref(), args.common.max_chars); let (run, test) = self.resolve_test(&args.run_id, &args.test_id)?; let entries = self .repository .get_entries(&args.run_id, &args.test_id) .map_err(display_error)?; let snapshots = self .repository .get_snapshots(&args.run_id, &args.test_id) .map_err(display_error)?; let spans = self .repository .get_spans_for_test(&args.run_id, &args.test_id) .map_err(display_error)?; let failed_entries: Vec<&Entry> = entries .iter() .filter(|entry| is_failed_status(&entry.result)) .collect(); let timeline_summary = timeline_summary( &entries, &args.run_id, &args.test_id, budget.timeline_events, ); let trace_summary = trace_summary( &spans, &entries, &args.run_id, &args.test_id, budget.trace_spans, ); let snapshot_summaries: Vec = snapshots .iter() .take(budget.snapshots) .map(|snapshot| snapshot_summary(snapshot, budget.string_chars)) .collect(); let structured = json!({ "app_name": run.app_name, "run_id": run.id, "run_status": run.status, "test": test_json(&test), "data_freshness": if test.status == TestStatus::Running || run.status == RunStatus::Running { "partial" } else { "complete_or_idle" }, "error_summary": clip_opt(test.error.as_deref(), budget.string_chars), "failed_entries": failed_entries .iter() .take(budget.failed_entries) .map(|entry| entry_preview(entry, budget.string_chars)) .collect::>(), "omitted": { "entries": entries.len().saturating_sub(budget.timeline_events), "failed_entries": failed_entries.len().saturating_sub(budget.failed_entries), "spans": spans.len().saturating_sub(budget.trace_spans), "snapshots": snapshots.len().saturating_sub(snapshot_summaries.len()), }, "timeline_summary": timeline_summary, "trace_summary": trace_summary, "snapshot_summaries": snapshot_summaries, "timeline_tool_call": exact_test_tool_call(ToolName::Timeline, &args.run_id, &args.test_id), "trace_tool_call": exact_test_tool_call(ToolName::Trace, &args.run_id, &args.test_id), "snapshot_tool_call": exact_test_tool_call(ToolName::Snapshot, &args.run_id, &args.test_id), "fallback": fallback_message(), }); Ok(output(structured, "Stove failure detail")) } fn timeline(&self, arguments: Value) -> Result { let args: TimelineArgs = parse(arguments)?; let budget = Budget::from_args( args.exact.common.budget.as_deref(), args.exact.common.max_chars, ); let (run, test) = self.resolve_test(&args.exact.run_id, &args.exact.test_id)?; let entries = self .repository .get_entries(&args.exact.run_id, &args.exact.test_id) .map_err(display_error)?; let focus = args .focus .unwrap_or_else(|| TimelineFocus::Failure.as_str().to_string()); let selected = if focus == TimelineFocus::All.as_str() { entries.iter().take(budget.timeline_events).collect() } else { failure_window(&entries, budget.timeline_events) }; let structured = json!({ "app_name": run.app_name, "run_id": run.id, "test": test_json(&test), "focus": focus, "events": selected .iter() .map(|entry| entry_preview(entry, budget.string_chars)) .collect::>(), "total_events": entries.len(), "omitted_events": entries.len().saturating_sub(selected.len()), "fallback": fallback_message(), }); Ok(output(structured, "Stove test timeline")) } fn trace(&self, arguments: Value) -> Result { let args: TraceArgs = parse(arguments)?; let budget = Budget::from_args(args.common.budget.as_deref(), args.common.max_chars); let (run, test, entries, spans) = if let Some(trace_id) = args.trace_id.as_deref() { let spans = self.repository.get_trace(trace_id).map_err(display_error)?; let run = spans .first() .and_then(|span| self.repository.get_run(&span.run_id).ok().flatten()); let test = run .as_ref() .and_then(|run| correlated_test_for_trace(&self.repository, run, trace_id)); let entries = test.as_ref().map_or_else(Vec::new, |test| { self .repository .get_entries(&test.run_id, &test.id) .unwrap_or_default() }); (run, test, entries, spans) } else { let run_id = args .run_id .as_deref() .ok_or_else(|| "stove_trace requires run_id + test_id or trace_id".to_string())?; let test_id = args .test_id .as_deref() .ok_or_else(|| "stove_trace requires run_id + test_id or trace_id".to_string())?; let (run, test) = self.resolve_test(run_id, test_id)?; let entries = self .repository .get_entries(run_id, test_id) .map_err(display_error)?; let spans = self .repository .get_spans_for_test(run_id, test_id) .map_err(display_error)?; (Some(run), Some(test), entries, spans) }; let view = args.view.unwrap_or_else(|| "critical_path".to_string()); let structured = json!({ "app_name": run.as_ref().map(|run| run.app_name.as_str()), "run_id": run.as_ref().map(|run| run.id.as_str()), "test": test.as_ref().map(test_json), "view": view, "trace": trace_summary(&spans, &entries, run.as_ref().map_or("", |run| &run.id), test.as_ref().map_or("", |test| &test.id), budget.trace_spans), "fallback": fallback_message(), }); Ok(output(structured, "Stove trace evidence")) } fn snapshot(&self, arguments: Value) -> Result { let args: SnapshotArgs = parse(arguments)?; let budget = Budget::from_args( args.exact.common.budget.as_deref(), args.exact.common.max_chars, ); let (run, test) = self.resolve_test(&args.exact.run_id, &args.exact.test_id)?; let mut snapshots = self .repository .get_snapshots(&args.exact.run_id, &args.exact.test_id) .map_err(display_error)?; if let Some(system) = &args.system { snapshots.retain(|snapshot| snapshot.system == *system); } let items = snapshots .iter() .take(budget.snapshots) .map(|snapshot| snapshot_detail(snapshot, args.json_pointer.as_deref(), budget.string_chars)) .collect::>(); let structured = json!({ "app_name": run.app_name, "run_id": run.id, "test": test_json(&test), "snapshots": items, "total_snapshots": snapshots.len(), "omitted_snapshots": snapshots.len().saturating_sub(items.len()), "fallback": fallback_message(), }); Ok(output(structured, "Stove snapshots")) } fn raw_evidence(&self, arguments: Value) -> Result { let args: RawEvidenceArgs = parse(arguments)?; let budget = Budget::from_args(args.common.budget.as_deref(), args.common.max_chars); let kind = args.kind.to_ascii_lowercase(); let evidence = match RawEvidenceKind::from_str(&kind) { Some(RawEvidenceKind::Entry) => { let run_id = args .run_id .as_deref() .ok_or_else(|| "raw entry lookup requires run_id and test_id".to_string())?; let test_id = args .test_id .as_deref() .ok_or_else(|| "raw entry lookup requires run_id and test_id".to_string())?; let entry = self .repository .get_entries(run_id, test_id) .map_err(display_error)? .into_iter() .find(|entry| entry.id == args.id) .ok_or_else(|| format!("entry {} was not found in {run_id}/{test_id}", args.id))?; json!({ "kind": RawEvidenceKind::Entry.as_str(), "evidence": entry_preview(&entry, budget.raw_string_chars) }) } Some(RawEvidenceKind::Span) => { let spans = if let Some(trace_id) = args.trace_id.as_deref() { self.repository.get_trace(trace_id).map_err(display_error)? } else { let run_id = args .run_id .as_deref() .ok_or_else(|| "raw span lookup requires trace_id or run_id + test_id".to_string())?; let test_id = args .test_id .as_deref() .ok_or_else(|| "raw span lookup requires trace_id or run_id + test_id".to_string())?; self .repository .get_spans_for_test(run_id, test_id) .map_err(display_error)? }; let span = spans .into_iter() .find(|span| span.id == args.id) .ok_or_else(|| format!("span {} was not found", args.id))?; json!({ "kind": RawEvidenceKind::Span.as_str(), "evidence": span_preview(&span, budget.raw_string_chars) }) } Some(RawEvidenceKind::Snapshot) => { let run_id = args .run_id .as_deref() .ok_or_else(|| "raw snapshot lookup requires run_id and test_id".to_string())?; let test_id = args .test_id .as_deref() .ok_or_else(|| "raw snapshot lookup requires run_id and test_id".to_string())?; let snapshot = self .repository .get_snapshots(run_id, test_id) .map_err(display_error)? .into_iter() .find(|snapshot| snapshot.id == args.id) .ok_or_else(|| format!("snapshot {} was not found in {run_id}/{test_id}", args.id))?; json!({ "kind": RawEvidenceKind::Snapshot.as_str(), "evidence": snapshot_detail(&snapshot, None, budget.raw_string_chars) }) } None => { return Err(format!( "kind must be one of: {}, {}, {}", RawEvidenceKind::Entry.as_str(), RawEvidenceKind::Span.as_str(), RawEvidenceKind::Snapshot.as_str() )); } }; Ok(output( json!({ "raw_evidence": evidence, "fallback": fallback_message() }), "Raw Stove evidence", )) } fn resolve_test(&self, run_id: &str, test_id: &str) -> Result<(Run, Test), String> { let run = self .repository .get_run(run_id) .map_err(display_error)? .ok_or_else(|| format!("run `{run_id}` was not found"))?; let test = self .repository .get_tests_for_run(run_id) .map_err(display_error)? .into_iter() .find(|test| test.id == test_id) .ok_or_else(|| format!("test `{test_id}` was not found in run `{run_id}`"))?; Ok((run, test)) } } fn selected_runs( repository: &Repository, app_name: Option<&str>, run_id: Option<&str>, ) -> Result, String> { if let Some(run_id) = run_id { return repository .get_run(run_id) .map_err(display_error)? .map_or_else(|| Ok(Vec::new()), |run| Ok(vec![run])); } let mut runs = repository.get_runs(app_name).map_err(display_error)?; runs.retain(|run| run.status == RunStatus::Failed || run.status == RunStatus::Running); Ok(runs) } fn failure_item(run: &Run, test: &Test) -> Value { json!({ "app_name": run.app_name, "run_id": run.id, "test_id": test.id, "spec_name": test.spec_name, "test_path": test.test_path, "test_name": test.test_name, "status": test.status, "duration_ms": test.duration_ms, "error_summary": clip_opt(test.error.as_deref(), 600), "detail_tool_call": exact_test_tool_call(ToolName::FailureDetail, &run.id, &test.id), "timeline_tool_call": exact_test_tool_call(ToolName::Timeline, &run.id, &test.id), "trace_tool_call": exact_test_tool_call(ToolName::Trace, &run.id, &test.id), }) } fn test_json(test: &Test) -> Value { json!({ "test_id": test.id, "test_name": test.test_name, "spec_name": test.spec_name, "test_path": test.test_path, "status": test.status, "started_at": test.started_at, "ended_at": test.ended_at, "duration_ms": test.duration_ms, }) } fn timeline_summary(entries: &[Entry], run_id: &str, test_id: &str, max_events: usize) -> Value { let selected = failure_window(entries, max_events); json!({ "total_events": entries.len(), "failed_entries": entries.iter().filter(|entry| is_failed_status(&entry.result)).count(), "events": selected.iter().map(|entry| compact_event(entry)).collect::>(), "timeline_tool_call": exact_test_tool_call(ToolName::Timeline, run_id, test_id), }) } fn failure_window(entries: &[Entry], max_events: usize) -> Vec<&Entry> { if entries.is_empty() || max_events == 0 { return Vec::new(); } let failed_indexes: Vec = entries .iter() .enumerate() .filter_map(|(index, entry)| is_failed_status(&entry.result).then_some(index)) .collect(); if failed_indexes.is_empty() { return entries.iter().take(max_events).collect(); } let mut selected = BTreeSet::new(); for index in failed_indexes { let start = index.saturating_sub(2); let end = (index + 2).min(entries.len().saturating_sub(1)); for selected_index in start..=end { selected.insert(selected_index); } } selected .into_iter() .take(max_events) .filter_map(|index| entries.get(index)) .collect() } fn trace_summary( spans: &[Span], entries: &[Entry], run_id: &str, test_id: &str, max_spans: usize, ) -> Value { if spans.is_empty() { return json!({ "trace_status": "uncorrelated", "trace_ids": trace_ids_from_entries(entries), "failed_spans": 0, "exception_spans": 0, "message": "No spans were correlated to this test. Fall back to timeline entries and logs if trace evidence is needed.", }); } let ranked_trace_ids = ranked_trace_ids(spans, entries); let failed_spans: Vec<&Span> = spans.iter().filter(|span| is_failed_span(span)).collect(); let exception_spans: Vec<&Span> = spans .iter() .filter(|span| span.exception_type.is_some()) .collect(); let primary_trace_id = ranked_trace_ids.first().cloned(); let critical_path = primary_trace_id.map_or_else(Vec::new, |trace_id| { critical_path_for_trace(spans, &trace_id, max_spans) }); json!({ "trace_status": "correlated", "trace_ids": ranked_trace_ids, "total_spans": spans.len(), "omitted_spans": spans.len().saturating_sub(max_spans), "failed_spans": failed_spans.len(), "exception_spans": exception_spans.len(), "critical_path": critical_path, "exceptions": exception_spans .iter() .take(max_spans) .map(|span| span_preview(span, 600)) .collect::>(), "trace_tool_call": exact_test_tool_call(ToolName::Trace, run_id, test_id), }) } fn trace_ids_from_entries(entries: &[Entry]) -> Vec { entries .iter() .filter_map(|entry| entry.trace_id.clone()) .filter(|trace_id| !trace_id.is_empty()) .collect::>() .into_iter() .collect() } fn ranked_trace_ids(spans: &[Span], entries: &[Entry]) -> Vec { let failed_entry_traces: HashSet = entries .iter() .filter(|entry| is_failed_status(&entry.result)) .filter_map(|entry| entry.trace_id.clone()) .filter(|trace_id| !trace_id.is_empty()) .collect(); let mut scores: BTreeMap = BTreeMap::new(); for span in spans { let mut score = 1; if is_failed_span(span) { score += 10; } if failed_entry_traces.contains(&span.trace_id) { score += 20; } *scores.entry(span.trace_id.clone()).or_insert(0) += score; } let mut ranked: Vec<(String, i32)> = scores.into_iter().collect(); ranked.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); ranked.into_iter().map(|(trace_id, _)| trace_id).collect() } fn critical_path_for_trace(spans: &[Span], trace_id: &str, max_spans: usize) -> Vec { let trace_spans: Vec<&Span> = spans .iter() .filter(|span| span.trace_id == trace_id) .collect(); let target = trace_spans .iter() .find(|span| is_failed_span(span)) .or_else(|| { trace_spans .iter() .max_by_key(|span| span.end_time_nanos - span.start_time_nanos) }); let Some(target) = target else { return Vec::new(); }; let by_span_id: HashMap<&str, &Span> = trace_spans .iter() .map(|span| (span.span_id.as_str(), *span)) .collect(); let mut path = Vec::new(); let mut current = Some(*target); let mut seen = HashSet::new(); while let Some(span) = current { if !seen.insert(span.span_id.clone()) { break; } path.push(span_preview(span, 240)); current = span .parent_span_id .as_deref() .and_then(|parent_id| by_span_id.get(parent_id).copied()); } path.reverse(); path.into_iter().take(max_spans).collect() } fn compact_event(entry: &Entry) -> Value { json!({ "id": entry.id, "timestamp": entry.timestamp, "system": entry.system, "action": entry.action, "result": entry.result, "trace_id": entry.trace_id, }) } fn groups_have_running_runs(groups: &[Value]) -> bool { groups.iter().any(|group| { group.get("run_status").and_then(Value::as_str) == Some(RunStatusValue::Running.as_str()) }) } fn correlated_test_for_trace(repository: &Repository, run: &Run, trace_id: &str) -> Option { repository .get_tests_for_run(&run.id) .ok()? .into_iter() .find(|test| { repository .get_entries(&run.id, &test.id) .is_ok_and(|entries| { entries .iter() .any(|entry| entry.trace_id.as_deref() == Some(trace_id)) }) }) } fn is_failed_test(test: &Test) -> bool { matches!(test.status, TestStatus::Failed | TestStatus::Error) } fn is_failed_status(status: &TestStatus) -> bool { matches!(status, TestStatus::Failed | TestStatus::Error) } fn is_failed_span(span: &Span) -> bool { span.status.eq_ignore_ascii_case(STATUS_ERROR) || span .status .eq_ignore_ascii_case(RunStatusValue::Failed.as_str()) || span.exception_type.is_some() } pub(super) fn tool_call(tool: ToolName, arguments: Value) -> Value { let mut call = serde_json::Map::new(); call.insert("tool".to_string(), Value::String(tool.as_str().to_string())); call.insert("arguments".to_string(), arguments); Value::Object(call) } fn exact_test_tool_call(tool: ToolName, run_id: &str, test_id: &str) -> Value { tool_call( tool, tool_args([ (ArgName::RunId, json!(run_id)), (ArgName::TestId, json!(test_id)), ]), ) } pub(super) fn tool_args(entries: impl IntoIterator) -> Value { Value::Object( entries .into_iter() .map(|(key, value)| (key.as_str().to_string(), value)) .collect(), ) } fn selector_rules() -> Value { json!({ "app_name": "grouping/filter only; multiple runs may exist per app", "run_id": "canonical execution boundary", "test_id": "unique only within run_id", "exact_test_selector": [ArgName::RunId.as_str(), ArgName::TestId.as_str()], }) } fn fallback_message() -> &'static str { "If Stove MCP is unavailable, incomplete, or ambiguous, fall back to normal test output, Stove failure reports, and logs." } fn output(structured: Value, heading: &str) -> ToolOutput { let text = format!("{heading}\n{}", compact_text(&structured)); ToolOutput { structured, text } } fn compact_text(value: &Value) -> String { serde_json::to_string_pretty(value) .unwrap_or_else(|_| "Stove MCP result could not be rendered as JSON".to_string()) } fn display_error(error: impl std::fmt::Display) -> String { error.to_string() } ================================================ FILE: tools/stove-cli/src/mcp/args.rs ================================================ use serde::Deserialize; use serde_json::Value; use super::contract::BudgetValue; const DEFAULT_LIMIT: usize = 20; const MAX_LIMIT: usize = 100; #[derive(Debug, Deserialize, Default)] pub(crate) struct CommonArgs { pub(crate) limit: Option, pub(crate) budget: Option, pub(crate) max_chars: Option, } impl CommonArgs { pub(crate) fn limit(&self) -> usize { self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) } } #[derive(Debug, Deserialize, Default)] pub(crate) struct ListArgs { #[serde(flatten)] common: CommonArgs, } impl ListArgs { pub(crate) fn limit(&self) -> usize { self.common.limit() } } #[derive(Debug, Deserialize, Default)] pub(crate) struct RunsArgs { #[serde(flatten)] pub(crate) common: CommonArgs, pub(crate) app_name: Option, pub(crate) status: Option, } #[derive(Debug, Deserialize, Default)] pub(crate) struct FailuresArgs { #[serde(flatten)] pub(crate) common: CommonArgs, pub(crate) app_name: Option, pub(crate) run_id: Option, } #[derive(Debug, Deserialize)] pub(crate) struct ExactTestArgs { #[serde(flatten)] pub(crate) common: CommonArgs, pub(crate) run_id: String, pub(crate) test_id: String, } #[derive(Debug, Deserialize)] pub(crate) struct TimelineArgs { #[serde(flatten)] pub(crate) exact: ExactTestArgs, pub(crate) focus: Option, } #[derive(Debug, Deserialize, Default)] pub(crate) struct TraceArgs { #[serde(flatten)] pub(crate) common: CommonArgs, pub(crate) run_id: Option, pub(crate) test_id: Option, pub(crate) trace_id: Option, pub(crate) view: Option, } #[derive(Debug, Deserialize)] pub(crate) struct SnapshotArgs { #[serde(flatten)] pub(crate) exact: ExactTestArgs, pub(crate) system: Option, pub(crate) json_pointer: Option, } #[derive(Debug, Deserialize)] pub(crate) struct RawEvidenceArgs { #[serde(flatten)] pub(crate) common: CommonArgs, pub(crate) kind: String, pub(crate) id: i64, pub(crate) run_id: Option, pub(crate) test_id: Option, pub(crate) trace_id: Option, } #[derive(Debug, Clone, Copy)] pub(crate) struct Budget { pub(crate) string_chars: usize, pub(crate) raw_string_chars: usize, pub(crate) timeline_events: usize, pub(crate) trace_spans: usize, pub(crate) failed_entries: usize, pub(crate) snapshots: usize, } impl Budget { pub(crate) fn from_args(name: Option<&str>, max_chars: Option) -> Self { let budget_name = name .and_then(BudgetValue::from_str) .unwrap_or(BudgetValue::Compact); let mut budget = match budget_name { BudgetValue::Tiny => Self { string_chars: 240, raw_string_chars: 800, timeline_events: 5, trace_spans: 8, failed_entries: 3, snapshots: 3, }, BudgetValue::Full => Self { string_chars: 2_000, raw_string_chars: 12_000, timeline_events: 100, trace_spans: 200, failed_entries: 50, snapshots: 50, }, BudgetValue::Compact => Self { string_chars: 600, raw_string_chars: 4_000, timeline_events: 15, trace_spans: 40, failed_entries: 10, snapshots: 10, }, }; if let Some(max_chars) = max_chars { let max_chars = max_chars.clamp(120, 20_000); budget.string_chars = budget.string_chars.min(max_chars); budget.raw_string_chars = budget.raw_string_chars.min(max_chars); } budget } } pub(crate) fn parse(arguments: Value) -> Result where T: for<'de> Deserialize<'de>, { serde_json::from_value(arguments).map_err(|error| format!("invalid tool arguments: {error}")) } ================================================ FILE: tools/stove-cli/src/mcp/contract.rs ================================================ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MethodName { Initialize, Ping, ToolsList, ToolsCall, } impl MethodName { pub(crate) fn from_str(value: &str) -> Option { match value { "initialize" => Some(Self::Initialize), "ping" => Some(Self::Ping), "tools/list" => Some(Self::ToolsList), "tools/call" => Some(Self::ToolsCall), _ => None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ToolName { Apps, Runs, Failures, FailureDetail, Timeline, Trace, Snapshot, RawEvidence, } impl ToolName { pub(crate) const ALL: [Self; 8] = [ Self::Apps, Self::Runs, Self::Failures, Self::FailureDetail, Self::Timeline, Self::Trace, Self::Snapshot, Self::RawEvidence, ]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Apps => "stove_apps", Self::Runs => "stove_runs", Self::Failures => "stove_failures", Self::FailureDetail => "stove_failure_detail", Self::Timeline => "stove_timeline", Self::Trace => "stove_trace", Self::Snapshot => "stove_snapshot", Self::RawEvidence => "stove_raw_evidence", } } pub(crate) fn from_str(value: &str) -> Option { Self::ALL.into_iter().find(|tool| tool.as_str() == value) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ArgName { AppName, Budget, Focus, Id, JsonPointer, Kind, Limit, MaxChars, RunId, Status, System, TestId, TraceId, View, } impl ArgName { pub(crate) const fn as_str(self) -> &'static str { match self { Self::AppName => "app_name", Self::Budget => "budget", Self::Focus => "focus", Self::Id => "id", Self::JsonPointer => "json_pointer", Self::Kind => "kind", Self::Limit => "limit", Self::MaxChars => "max_chars", Self::RunId => "run_id", Self::Status => "status", Self::System => "system", Self::TestId => "test_id", Self::TraceId => "trace_id", Self::View => "view", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum BudgetValue { Tiny, Compact, Full, } impl BudgetValue { pub(crate) const ALL: [Self; 3] = [Self::Tiny, Self::Compact, Self::Full]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Tiny => "tiny", Self::Compact => "compact", Self::Full => "full", } } pub(crate) fn from_str(value: &str) -> Option { Self::ALL .into_iter() .find(|budget| budget.as_str() == value) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TimelineFocus { Failure, All, } impl TimelineFocus { pub(crate) const ALL: [Self; 2] = [Self::Failure, Self::All]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Failure => "failure", Self::All => "all", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TraceView { CriticalPath, Exceptions, Tree, } impl TraceView { pub(crate) const ALL: [Self; 3] = [Self::CriticalPath, Self::Exceptions, Self::Tree]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::CriticalPath => "critical_path", Self::Exceptions => "exceptions", Self::Tree => "tree", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RawEvidenceKind { Entry, Span, Snapshot, } impl RawEvidenceKind { pub(crate) const ALL: [Self; 3] = [Self::Entry, Self::Span, Self::Snapshot]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Entry => "entry", Self::Span => "span", Self::Snapshot => "snapshot", } } pub(crate) fn from_str(value: &str) -> Option { Self::ALL.into_iter().find(|kind| kind.as_str() == value) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RunStatusValue { Running, Passed, Failed, } impl RunStatusValue { pub(crate) const ALL: [Self; 3] = [Self::Running, Self::Passed, Self::Failed]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Running => "RUNNING", Self::Passed => "PASSED", Self::Failed => "FAILED", } } } pub(crate) const STATUS_ERROR: &str = "ERROR"; ================================================ FILE: tools/stove-cli/src/mcp/mod.rs ================================================ mod analysis; mod args; mod contract; mod protocol; mod security; mod tools; use std::net::SocketAddr; use analysis::Analyzer; use axum::body::Bytes; use axum::extract::{ConnectInfo, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use serde_json::{Value, json}; use crate::http::server::AppState; use self::contract::MethodName; use self::protocol::{JsonRpcRequest, RpcError, ToolCallParams}; pub async fn handle_get( State(_state): State, connect_info: ConnectInfo, headers: HeaderMap, ) -> Response { if let Some(response) = security::validate_local_request(&connect_info, &headers) { return response; } ( StatusCode::METHOD_NOT_ALLOWED, axum::Json(json!({ "error": "Stove MCP is a stateless Streamable HTTP endpoint in v1. Use HTTP POST with JSON-RPC requests.", })), ) .into_response() } pub async fn handle_post( State(state): State, connect_info: ConnectInfo, headers: HeaderMap, body: Bytes, ) -> Response { if let Some(response) = security::validate_local_request(&connect_info, &headers) { return response; } if let Some(response) = security::validate_accept_header(&headers) { return response; } let request = match serde_json::from_slice::(&body) { Ok(request) => request, Err(error) => { return protocol::rpc_error( None, StatusCode::BAD_REQUEST, -32700, "Parse error", Some(json!({ "error": error.to_string() })), ); } }; let id = request.id.clone(); if request.id.is_none() { return StatusCode::ACCEPTED.into_response(); } match handle_request(state, request).await { Ok(result) => protocol::rpc_result(id, result), Err(error) => protocol::rpc_error(id, StatusCode::OK, error.code, &error.message, error.data), } } async fn handle_request(state: AppState, request: JsonRpcRequest) -> Result { match MethodName::from_str(&request.method) { Some(MethodName::Initialize) => Ok(protocol::initialize_result()), Some(MethodName::Ping) => Ok(json!({})), Some(MethodName::ToolsList) => Ok(json!({ "tools": tools::definitions() })), Some(MethodName::ToolsCall) => { let params: ToolCallParams = serde_json::from_value(request.params.unwrap_or_else(|| json!({}))).map_err(|error| { RpcError::invalid_params(format!("invalid tools/call params: {error}")) })?; let analyzer = Analyzer::new(state.repository, state.ingestor); let arguments = params.arguments.unwrap_or_else(|| json!({})); let output = analyzer .call_tool(¶ms.name, arguments) .await .map_err(RpcError::tool_error)?; Ok(protocol::tool_result(output)) } None => Err(RpcError::method_not_found(format!( "unsupported MCP method: {}", request.method ))), } } ================================================ FILE: tools/stove-cli/src/mcp/protocol.rs ================================================ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use serde_json::{Value, json}; use super::analysis::ToolOutput; use crate::STOVE_CLI_VERSION; const PROTOCOL_VERSION: &str = "2025-06-18"; pub(crate) fn initialize_result() -> Value { json!({ "protocolVersion": PROTOCOL_VERSION, "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "stove", "version": STOVE_CLI_VERSION, "title": "Stove test observability" }, "instructions": "Use Stove MCP to inspect recorded e2e test failures through compact app/run/test scoped tools. If MCP is unavailable, incomplete, or ambiguous, fall back to normal test output, Stove reports, and logs." }) } pub(crate) fn tool_result(output: ToolOutput) -> Value { let ToolOutput { structured, text } = output; json!({ "content": [ { "type": "text", "text": text } ], "structuredContent": structured, "isError": false }) } pub(crate) fn rpc_result(id: Option, result: Value) -> Response { let mut envelope = serde_json::Map::new(); envelope.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); envelope.insert("id".to_string(), id.unwrap_or(Value::Null)); envelope.insert("result".to_string(), result); (StatusCode::OK, axum::Json(Value::Object(envelope))).into_response() } pub(crate) fn rpc_error( id: Option, status: StatusCode, code: i32, message: &str, data: Option, ) -> Response { let mut error = json!({ "code": code, "message": message, }); if let Some(data) = data { error["data"] = data; } ( status, axum::Json(json!({ "jsonrpc": "2.0", "id": id.unwrap_or(Value::Null), "error": error, })), ) .into_response() } #[derive(Debug, Deserialize)] pub(crate) struct JsonRpcRequest { pub(crate) id: Option, pub(crate) method: String, pub(crate) params: Option, } #[derive(Debug, Deserialize)] pub(crate) struct ToolCallParams { pub(crate) name: String, pub(crate) arguments: Option, } pub(crate) struct RpcError { pub(crate) code: i32, pub(crate) message: String, pub(crate) data: Option, } impl RpcError { pub(crate) fn invalid_params(message: String) -> Self { Self { code: -32602, message, data: None, } } pub(crate) fn method_not_found(message: String) -> Self { Self { code: -32601, message, data: None, } } pub(crate) fn tool_error(message: String) -> Self { Self { code: -32001, message, data: None, } } } ================================================ FILE: tools/stove-cli/src/mcp/security.rs ================================================ use std::net::{IpAddr, SocketAddr}; use axum::extract::ConnectInfo; use axum::http::header::{ACCEPT, HOST, ORIGIN}; use axum::http::{HeaderMap, StatusCode}; use axum::response::Response; use serde_json::json; pub(crate) fn validate_accept_header(headers: &HeaderMap) -> Option { let accept = headers.get(ACCEPT).and_then(|value| value.to_str().ok())?; if accept.contains("application/json") || accept.contains("*/*") || accept.contains("text/event-stream") { None } else { Some(super::protocol::rpc_error( None, StatusCode::NOT_ACCEPTABLE, -32000, "Not acceptable", Some(json!({ "expected_accept": "application/json or text/event-stream" })), )) } } pub(crate) fn validate_local_request( connect_info: &ConnectInfo, headers: &HeaderMap, ) -> Option { let ConnectInfo(addr) = connect_info; if !addr.ip().is_loopback() { return Some(forbidden("MCP is only available to loopback clients")); } let host = headers .get(HOST) .and_then(|value| value.to_str().ok()) .and_then(host_without_port); if !host.as_deref().is_some_and(is_loopback_host) { return Some(forbidden("MCP requests must use a localhost Host header")); } if let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) && !origin_host(origin).is_some_and(|host| is_loopback_host(&host)) { return Some(forbidden("MCP requests must use a localhost Origin")); } None } fn forbidden(message: &str) -> Response { super::protocol::rpc_error( None, StatusCode::FORBIDDEN, -32000, "Forbidden", Some(json!({ "reason": message })), ) } fn host_without_port(host: &str) -> Option { let host = host.trim(); if host.is_empty() { return None; } if let Some(rest) = host.strip_prefix('[') { return rest.find(']').map(|end| rest[..end].to_string()); } Some(host.split(':').next().unwrap_or(host).to_string()) } fn origin_host(origin: &str) -> Option { let after_scheme = origin.split_once("://").map_or(origin, |(_, rest)| rest); let authority = after_scheme.split('/').next().unwrap_or(after_scheme); host_without_port(authority) } fn is_loopback_host(host: &str) -> bool { host.eq_ignore_ascii_case("localhost") || host .parse::() .is_ok_and(|ip_address| ip_address.is_loopback()) } #[cfg(test)] mod tests { use super::*; #[test] fn host_parser_handles_ipv6_loopback() { assert_eq!(host_without_port("[::1]:4040").as_deref(), Some("::1")); assert!(is_loopback_host("::1")); } #[test] fn origin_parser_rejects_remote_hosts() { assert_eq!( origin_host("http://localhost:4040").as_deref(), Some("localhost") ); assert!( !origin_host("https://example.com") .as_deref() .is_some_and(is_loopback_host) ); } } ================================================ FILE: tools/stove-cli/src/mcp/tools.rs ================================================ use serde_json::{Value, json}; use super::contract::{ ArgName, BudgetValue, RawEvidenceKind, RunStatusValue, TimelineFocus, ToolName, TraceView, }; pub(crate) fn definitions() -> Value { Value::Array( ToolName::ALL .into_iter() .map(ToolSpec::for_tool) .map(|spec| spec.to_json()) .collect(), ) } struct ToolSpec { tool: ToolName, description: &'static str, fields: Vec, } impl ToolSpec { fn for_tool(tool: ToolName) -> Self { match tool { ToolName::Apps => Self { tool, description: "List apps recorded in the Stove dashboard database. Use this when multiple apps may have test runs.", fields: list_fields(), }, ToolName::Runs => Self { tool, description: "List Stove runs, optionally filtered by app_name and status. run_id is the canonical execution boundary for detail tools.", fields: vec![ FieldSpec::string(ArgName::AppName).description("Optional app grouping label."), FieldSpec::string_enum(ArgName::Status, SchemaEnum::RunStatus), FieldSpec::limit(), FieldSpec::budget(), FieldSpec::max_chars(), ], }, ToolName::Failures => Self { tool, description: "Default entrypoint for agents. Return failed or errored tests grouped by app and run, with ready-to-use detail tool calls.", fields: vec![ FieldSpec::string(ArgName::AppName) .description("Optional app grouping label. Does not uniquely identify a run."), FieldSpec::string(ArgName::RunId).description("Optional exact run id."), FieldSpec::limit(), FieldSpec::budget(), FieldSpec::max_chars(), ], }, ToolName::FailureDetail => Self { tool, description: "Return compact failure, timeline, trace, and snapshot summaries for one exact failed test.", fields: exact_test_fields(), }, ToolName::Timeline => Self { tool, description: "Return ordered report entries for one exact test. Failure-focused by default.", fields: with_extra( exact_test_fields(), FieldSpec::string_enum(ArgName::Focus, SchemaEnum::TimelineFocus) .string_default(TimelineFocus::Failure.as_str()), ), }, ToolName::Trace => Self { tool, description: "Return trace evidence by run_id + test_id or explicit trace_id. Multiple trace IDs are ranked with failed-entry traces first.", fields: vec![ FieldSpec::string(ArgName::RunId), FieldSpec::string(ArgName::TestId), FieldSpec::string(ArgName::TraceId), FieldSpec::string_enum(ArgName::View, SchemaEnum::TraceView) .string_default(TraceView::CriticalPath.as_str()), FieldSpec::budget(), FieldSpec::max_chars(), ], }, ToolName::Snapshot => Self { tool, description: "Return snapshot summaries and targeted state drill-down for one exact test.", fields: with_extra( with_extra( exact_test_fields(), FieldSpec::string(ArgName::System) .description("Optional system name such as Kafka or WireMock."), ), FieldSpec::string(ArgName::JsonPointer).description( "Optional RFC 6901 JSON pointer into snapshot state, for example /published/0.", ), ), }, ToolName::RawEvidence => Self { tool, description: "Explicit capped raw evidence lookup by entry, span, or snapshot id. Prefer summary tools first.", fields: vec![ FieldSpec::string_enum(ArgName::Kind, SchemaEnum::RawEvidenceKind).required(), FieldSpec::integer(ArgName::Id).required(), FieldSpec::string(ArgName::RunId), FieldSpec::string(ArgName::TestId), FieldSpec::string(ArgName::TraceId), FieldSpec::budget(), FieldSpec::max_chars(), ], }, } } fn to_json(&self) -> Value { json!({ "name": self.tool.as_str(), "description": self.description, "inputSchema": InputSchema::from_fields(&self.fields).to_json(), }) } } struct InputSchema { fields: Vec, } impl InputSchema { fn from_fields(fields: &[FieldSpec]) -> Self { Self { fields: fields.to_vec(), } } fn to_json(&self) -> Value { let properties = Value::Object(self.fields.iter().map(FieldSpec::property_entry).collect()); let required: Vec<&str> = self .fields .iter() .filter(|field| field.required) .map(|field| field.name.as_str()) .collect(); json!({ "type": "object", "properties": properties, "required": required, "additionalProperties": false, }) } } #[derive(Clone)] struct FieldSpec { name: ArgName, kind: FieldKind, required: bool, description: Option<&'static str>, default: Option, } impl FieldSpec { fn string(name: ArgName) -> Self { Self::new(name, FieldKind::String) } fn string_enum(name: ArgName, enum_kind: SchemaEnum) -> Self { Self::new(name, FieldKind::StringEnum(enum_kind)) } fn integer(name: ArgName) -> Self { Self::new(name, FieldKind::Integer) } fn limit() -> Self { Self::integer(ArgName::Limit) .minimum(1) .maximum(100) .integer_default(20) } fn budget() -> Self { Self::string_enum(ArgName::Budget, SchemaEnum::Budget) .string_default(BudgetValue::Compact.as_str()) } fn max_chars() -> Self { Self::integer(ArgName::MaxChars) .minimum(120) .maximum(20_000) .description("Maximum characters for individual large evidence fields.") } fn new(name: ArgName, kind: FieldKind) -> Self { Self { name, kind, required: false, description: None, default: None, } } fn required(mut self) -> Self { self.required = true; self } fn description(mut self, description: &'static str) -> Self { self.description = Some(description); self } fn string_default(mut self, default: &'static str) -> Self { self.default = Some(json!(default)); self } fn integer_default(mut self, default: i64) -> Self { self.default = Some(json!(default)); self } fn minimum(mut self, minimum: i64) -> Self { if let FieldKind::Integer = &mut self.kind { self.kind = FieldKind::IntegerWithBounds { minimum: Some(minimum), maximum: None, }; } else if let FieldKind::IntegerWithBounds { minimum: slot, .. } = &mut self.kind { *slot = Some(minimum); } self } fn maximum(mut self, maximum: i64) -> Self { if let FieldKind::Integer = &mut self.kind { self.kind = FieldKind::IntegerWithBounds { minimum: None, maximum: Some(maximum), }; } else if let FieldKind::IntegerWithBounds { maximum: slot, .. } = &mut self.kind { *slot = Some(maximum); } self } fn property_entry(&self) -> (String, Value) { let mut property = match self.kind { FieldKind::String => json!({ "type": "string" }), FieldKind::StringEnum(enum_kind) => json!({ "type": "string", "enum": enum_kind.values(), }), FieldKind::Integer => json!({ "type": "integer" }), FieldKind::IntegerWithBounds { minimum, maximum } => { let mut property = json!({ "type": "integer" }); if let Some(minimum) = minimum { property["minimum"] = json!(minimum); } if let Some(maximum) = maximum { property["maximum"] = json!(maximum); } property } }; if let Some(description) = self.description { property["description"] = json!(description); } if let Some(default) = &self.default { property["default"] = default.clone(); } (self.name.as_str().to_string(), property) } } #[derive(Debug, Clone, Copy)] enum FieldKind { String, StringEnum(SchemaEnum), Integer, IntegerWithBounds { minimum: Option, maximum: Option, }, } #[derive(Debug, Clone, Copy)] enum SchemaEnum { Budget, TimelineFocus, TraceView, RawEvidenceKind, RunStatus, } impl SchemaEnum { fn values(self) -> Vec<&'static str> { match self { Self::Budget => BudgetValue::ALL .into_iter() .map(BudgetValue::as_str) .collect(), Self::TimelineFocus => TimelineFocus::ALL .into_iter() .map(TimelineFocus::as_str) .collect(), Self::TraceView => TraceView::ALL.into_iter().map(TraceView::as_str).collect(), Self::RawEvidenceKind => RawEvidenceKind::ALL .into_iter() .map(RawEvidenceKind::as_str) .collect(), Self::RunStatus => RunStatusValue::ALL .into_iter() .map(RunStatusValue::as_str) .collect(), } } } fn list_fields() -> Vec { vec![ FieldSpec::limit(), FieldSpec::budget(), FieldSpec::max_chars(), ] } fn exact_test_fields() -> Vec { vec![ FieldSpec::string(ArgName::RunId) .required() .description("Exact Stove run id. This is the canonical execution boundary."), FieldSpec::string(ArgName::TestId) .required() .description("Exact Stove test id. Unique only within run_id."), FieldSpec::budget(), FieldSpec::max_chars(), ] } fn with_extra(mut fields: Vec, field: FieldSpec) -> Vec { fields.push(field); fields } #[cfg(test)] mod tests { use super::*; #[test] fn definitions_include_one_schema_per_tool() { let definitions = definitions(); let tools = definitions.as_array().unwrap(); assert_eq!(tools.len(), ToolName::ALL.len()); assert_eq!(tools[0]["name"], ToolName::Apps.as_str()); assert_eq!( tools[ToolName::ALL.len() - 1]["name"], ToolName::RawEvidence.as_str() ); } #[test] fn exact_test_tools_require_run_and_test_ids() { let detail = ToolSpec::for_tool(ToolName::FailureDetail).to_json(); let required = detail["inputSchema"]["required"].as_array().unwrap(); assert!(required.contains(&json!(ArgName::RunId.as_str()))); assert!(required.contains(&json!(ArgName::TestId.as_str()))); } #[test] fn typed_fields_preserve_defaults_and_enums() { let apps = ToolSpec::for_tool(ToolName::Apps).to_json(); let limit = &apps["inputSchema"]["properties"][ArgName::Limit.as_str()]; let budget = &apps["inputSchema"]["properties"][ArgName::Budget.as_str()]; assert_eq!(limit["default"], 20); assert_eq!(budget["default"], BudgetValue::Compact.as_str()); assert_eq!( budget["enum"], json!([ BudgetValue::Tiny.as_str(), BudgetValue::Compact.as_str(), BudgetValue::Full.as_str() ]) ); } #[test] fn raw_evidence_schema_requires_kind_and_id() { let raw = ToolSpec::for_tool(ToolName::RawEvidence).to_json(); let required = raw["inputSchema"]["required"].as_array().unwrap(); let kind = &raw["inputSchema"]["properties"][ArgName::Kind.as_str()]; assert!(required.contains(&json!(ArgName::Kind.as_str()))); assert!(required.contains(&json!(ArgName::Id.as_str()))); assert_eq!( kind["enum"], json!([ RawEvidenceKind::Entry.as_str(), RawEvidenceKind::Span.as_str(), RawEvidenceKind::Snapshot.as_str() ]) ); } } ================================================ FILE: tools/stove-cli/src/skills.rs ================================================ //! Stove agent skills sync. //! //! Discovers the local Stove skill directory under a project (preferring //! `.agents/skills/stove`, falling back to `.claude/skills/stove` and //! `.agent/skills/stove`), compares it against the canonical copy on GitHub, //! and offers to install or update. //! //! Network and prompt behavior is conservative by default: //! - never modifies anything without explicit user consent on a TTY //! - falls back to a non-blocking warning on network/API errors //! - skips entirely when started outside a git repository on plain `stove` use std::collections::BTreeMap; use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use serde::Deserialize; use tokio::task::JoinSet; use crate::config::{Config, SkillsCommand, StoveCommand}; /// GitHub source coordinates and HTTP defaults. mod github { use std::time::Duration; pub const REPO_OWNER: &str = "Trendyol"; pub const REPO_NAME: &str = "stove"; pub const REPO_REF: &str = "main"; pub const USER_AGENT: &str = concat!("stove-cli/", env!("STOVE_VERSION")); /// Capped low so a slow GitHub call cannot stall server bind on cold start. pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); } /// Candidate skill directory paths, probed in order both locally and remotely. /// First entry is the preferred vendor-neutral path used as the install /// default when nothing exists yet. const SKILL_PATHS: &[&str] = &[ ".agents/skills/stove", ".claude/skills/stove", ".agent/skills/stove", ]; /// Handle a `skills` subcommand if one was requested. /// /// Returns `Ok(true)` when a subcommand was handled and the CLI should exit; /// `Ok(false)` when no subcommand was specified. pub async fn handle_skills_command(config: &Config) -> anyhow::Result { let Some(StoveCommand::Skills { command }) = &config.command else { return Ok(false); }; match command { SkillsCommand::Install { force } => install_skills_command(*force).await?, } Ok(true) } /// Suggest or apply a skills update during normal startup. /// /// Network and IO failures are reported and never abort startup. pub async fn maybe_update_skills(config: &Config) { if config.no_skills_check { return; } let Some(repo_root) = current_git_root() else { println!( " Stove skills can be installed from your project root: cd && stove skills install" ); return; }; let target = resolve_local_target(&repo_root); match decide_sync_action(&target, config.update_skills).await { SyncAction::Skip(reason) => { if let Some(message) = reason { eprintln!(" warning: skipping Stove skills check ({message})"); } } SyncAction::Apply(remote) => apply_install(&target, &remote), SyncAction::Prompt(remote) => { match prompt_yes_no(" Install/update Stove agent skills from GitHub? [y/N] ") { Ok(true) => apply_install(&target, &remote), Ok(false) => {} Err(err) => eprintln!(" warning: skills prompt failed: {err}"), } } } } /// What to do once we know the local target and the remote snapshot. enum SyncAction { /// Nothing to do. `Some(reason)` surfaces a recoverable error to the user. Skip(Option), /// Install without prompting (`--update-skills` or non-TTY in some flows). Apply(RemoteSkills), /// TTY user-facing prompt path. Prompt(RemoteSkills), } async fn decide_sync_action(target: &Path, force_update: bool) -> SyncAction { let remote = match fetch_remote_skills().await { Ok(remote) => remote, Err(err) => return SyncAction::Skip(Some(err.to_string())), }; if remote.is_empty() || skills_match(target, &remote) { return SyncAction::Skip(None); } if force_update { SyncAction::Apply(remote) } else if std::io::stdin().is_terminal() { SyncAction::Prompt(remote) } else { SyncAction::Skip(None) } } fn apply_install(target: &Path, remote: &RemoteSkills) { match install_skills(target, remote) { Ok(()) => println!(" Updated Stove agent skills at {}", target.display()), Err(err) => eprintln!(" warning: failed to install Stove skills: {err}"), } } /// `stove skills install` execution path. /// /// Without `--force`: requires a git repository and installs into the resolved /// target under the repo root. With `--force`: skips git detection and /// installs into the resolved target relative to the current directory. async fn install_skills_command(force: bool) -> anyhow::Result<()> { let cwd = std::env::current_dir()?; let target = if force { resolve_local_target(&cwd) } else { let repo_root = find_git_root(&cwd).ok_or_else(|| { anyhow::anyhow!( "stove skills install must be run inside a git repository (use --force to install in the current directory)" ) })?; resolve_local_target(&repo_root) }; let remote = fetch_remote_skills().await?; if remote.is_empty() { anyhow::bail!("no Stove skills found in remote repository at any known path"); } install_skills(&target, &remote)?; println!( "Installed {} skill files at {}", remote.len(), target.display() ); Ok(()) } fn current_git_root() -> Option { let cwd = std::env::current_dir().ok()?; find_git_root(&cwd) } /// Walk up from `start` until a directory containing a `.git` entry is found. /// `.git` may be a directory (regular repo) or a file (worktree / submodule). #[must_use] pub fn find_git_root(start: &Path) -> Option { start .ancestors() .find(|dir| dir.join(".git").exists()) .map(Path::to_path_buf) } /// Resolve the local skill target for installation. /// /// Picks the first existing skill directory in [`SKILL_PATHS`]. If none /// exist, returns the first candidate (the vendor-neutral default). #[must_use] pub fn resolve_local_target(root: &Path) -> PathBuf { SKILL_PATHS .iter() .map(|candidate| root.join(candidate)) .find(|path| path.is_dir()) .unwrap_or_else(|| root.join(SKILL_PATHS[0])) } /// Snapshot of the canonical Stove skill directory on GitHub. #[derive(Debug, Clone, Default)] pub struct RemoteSkills { files: BTreeMap>, } impl RemoteSkills { #[must_use] pub fn is_empty(&self) -> bool { self.files.is_empty() } #[must_use] pub fn len(&self) -> usize { self.files.len() } pub fn iter(&self) -> impl Iterator)> { self.files.iter() } } #[derive(Debug, Deserialize)] struct ContentsEntry { name: String, #[serde(rename = "type")] kind: String, download_url: Option, } /// Fetch the Stove skill files from GitHub. /// /// Probes [`SKILL_PATHS`] in order and uses the first path that returns a /// non-empty directory listing. async fn fetch_remote_skills() -> anyhow::Result { let client = reqwest::Client::builder() .user_agent(github::USER_AGENT) .timeout(github::REQUEST_TIMEOUT) .build()?; for remote_path in SKILL_PATHS { match fetch_remote_skills_for_path(&client, remote_path).await { Ok(snapshot) if !snapshot.is_empty() => return Ok(snapshot), Ok(_) => {} Err(err) => tracing::debug!("remote skills probe failed for {remote_path}: {err}"), } } anyhow::bail!("no Stove skills found in remote repository at any known path"); } async fn fetch_remote_skills_for_path( client: &reqwest::Client, remote_path: &str, ) -> anyhow::Result { let listing_url = format!( "https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={ref_}", owner = github::REPO_OWNER, repo = github::REPO_NAME, path = remote_path, ref_ = github::REPO_REF, ); let response = client.get(&listing_url).send().await?; if response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(RemoteSkills::default()); } let entries: Vec = response.error_for_status()?.json().await?; let mut downloads: JoinSet)>> = JoinSet::new(); for entry in entries { if entry.kind != "file" { continue; } let Some(url) = entry.download_url else { continue; }; let client = client.clone(); let name = entry.name; downloads.spawn(async move { let body = client .get(&url) .send() .await? .error_for_status()? .bytes() .await?; Ok((name, body.to_vec())) }); } let mut files = BTreeMap::new(); while let Some(result) = downloads.join_next().await { let (name, bytes) = result??; files.insert(name, bytes); } Ok(RemoteSkills { files }) } /// Compare an existing local target directory against a remote snapshot. /// /// Matches when the target exists and contains exactly the same set of files /// with byte-identical contents. Local files outside the remote set are /// considered drift and force a mismatch (so a clean install replaces them). #[must_use] pub fn skills_match(target: &Path, remote: &RemoteSkills) -> bool { let Some(local) = read_local_files(target) else { return false; }; local == remote.files } fn read_local_files(target: &Path) -> Option>> { if !target.is_dir() { return None; } std::fs::read_dir(target) .ok()? .flatten() .map(|entry| entry.path()) .filter(|path| path.is_file()) .map(|path| { let name = path.file_name()?.to_str()?.to_string(); let bytes = std::fs::read(&path).ok()?; Some((name, bytes)) }) .collect() } /// Replace the target directory with the remote snapshot. /// /// Writes files into a sibling staging directory first, then performs a /// best-effort atomic swap (move-aside-old + rename-staging-into-place). pub fn install_skills(target: &Path, remote: &RemoteSkills) -> anyhow::Result<()> { let parent = target .parent() .ok_or_else(|| anyhow::anyhow!("invalid skills target: {}", target.display()))?; std::fs::create_dir_all(parent)?; let target_name = target .file_name() .and_then(|n| n.to_str()) .unwrap_or("stove"); let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S%3f"); let staging = parent.join(format!(".{target_name}-staging-{timestamp}")); let aside = parent.join(format!(".{target_name}-old-{timestamp}")); write_staging(&staging, remote)?; swap_into_place(&staging, target, &aside) } fn write_staging(staging: &Path, remote: &RemoteSkills) -> anyhow::Result<()> { // remove_dir_all on a missing path returns NotFound — discard intentionally. let _ = std::fs::remove_dir_all(staging); std::fs::create_dir_all(staging)?; for (name, bytes) in remote.iter() { std::fs::write(staging.join(name), bytes)?; } Ok(()) } fn swap_into_place(staging: &Path, target: &Path, aside: &Path) -> anyhow::Result<()> { let target_existed = target.exists(); if target_existed { std::fs::rename(target, aside)?; } match std::fs::rename(staging, target) { Ok(()) => { if target_existed { let _ = std::fs::remove_dir_all(aside); } Ok(()) } Err(err) => { if target_existed { let _ = std::fs::rename(aside, target); } let _ = std::fs::remove_dir_all(staging); Err(anyhow::anyhow!("failed to install skills: {err}")) } } } fn prompt_yes_no(message: &str) -> anyhow::Result { print!("{message}"); std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; Ok(matches!( input.trim().to_ascii_lowercase().as_str(), "y" | "yes" )) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn remote_with(files: &[(&str, &[u8])]) -> RemoteSkills { RemoteSkills { files: files .iter() .map(|(name, bytes)| ((*name).to_string(), bytes.to_vec())) .collect(), } } #[test] fn find_git_root_detects_directory() { let dir = TempDir::new().unwrap(); fs::create_dir(dir.path().join(".git")).unwrap(); let nested = dir.path().join("a/b/c"); fs::create_dir_all(&nested).unwrap(); let root = find_git_root(&nested).unwrap(); assert_eq!(root, dir.path()); } #[test] fn find_git_root_detects_file_marker() { let dir = TempDir::new().unwrap(); fs::write(dir.path().join(".git"), "gitdir: /elsewhere\n").unwrap(); let nested = dir.path().join("nested"); fs::create_dir_all(&nested).unwrap(); let root = find_git_root(&nested).unwrap(); assert_eq!(root, dir.path()); } #[test] fn find_git_root_returns_none_when_absent() { let dir = TempDir::new().unwrap(); let nested = dir.path().join("a/b"); fs::create_dir_all(&nested).unwrap(); assert!(find_git_root(&nested).is_none()); } #[test] fn resolve_local_target_prefers_existing_agents() { let dir = TempDir::new().unwrap(); let agents = dir.path().join(".agents/skills/stove"); fs::create_dir_all(&agents).unwrap(); fs::create_dir_all(dir.path().join(".claude/skills/stove")).unwrap(); let target = resolve_local_target(dir.path()); assert_eq!(target, agents); } #[test] fn resolve_local_target_falls_back_to_claude() { let dir = TempDir::new().unwrap(); let claude = dir.path().join(".claude/skills/stove"); fs::create_dir_all(&claude).unwrap(); let target = resolve_local_target(dir.path()); assert_eq!(target, claude); } #[test] fn resolve_local_target_defaults_to_agents_when_none_exist() { let dir = TempDir::new().unwrap(); let target = resolve_local_target(dir.path()); assert_eq!(target, dir.path().join(".agents/skills/stove")); } #[test] fn skills_match_detects_missing_target() { let dir = TempDir::new().unwrap(); let target = dir.path().join("missing"); let remote = remote_with(&[("a.md", b"hello")]); assert!(!skills_match(&target, &remote)); } #[test] fn skills_match_returns_true_for_identical_dirs() { let dir = TempDir::new().unwrap(); let target = dir.path().join("local"); fs::create_dir_all(&target).unwrap(); fs::write(target.join("a.md"), b"hello").unwrap(); fs::write(target.join("b.md"), b"world").unwrap(); let remote = remote_with(&[("a.md", b"hello"), ("b.md", b"world")]); assert!(skills_match(&target, &remote)); } #[test] fn skills_match_detects_content_drift() { let dir = TempDir::new().unwrap(); let target = dir.path().join("local"); fs::create_dir_all(&target).unwrap(); fs::write(target.join("a.md"), b"old content").unwrap(); let remote = remote_with(&[("a.md", b"new content")]); assert!(!skills_match(&target, &remote)); } #[test] fn skills_match_detects_extra_local_file() { let dir = TempDir::new().unwrap(); let target = dir.path().join("local"); fs::create_dir_all(&target).unwrap(); fs::write(target.join("a.md"), b"hello").unwrap(); fs::write(target.join("stale.md"), b"orphan").unwrap(); let remote = remote_with(&[("a.md", b"hello")]); assert!(!skills_match(&target, &remote)); } #[test] fn install_skills_creates_target_when_missing() { let dir = TempDir::new().unwrap(); let target = dir.path().join("nested/.agents/skills/stove"); let remote = remote_with(&[("a.md", b"hello"), ("b.md", b"world")]); install_skills(&target, &remote).unwrap(); assert_eq!(fs::read(target.join("a.md")).unwrap(), b"hello"); assert_eq!(fs::read(target.join("b.md")).unwrap(), b"world"); } #[test] fn install_skills_replaces_existing_target() { let dir = TempDir::new().unwrap(); let target = dir.path().join(".agents/skills/stove"); fs::create_dir_all(&target).unwrap(); fs::write(target.join("old.md"), b"obsolete").unwrap(); fs::write(target.join("a.md"), b"old").unwrap(); let remote = remote_with(&[("a.md", b"new"), ("b.md", b"fresh")]); install_skills(&target, &remote).unwrap(); assert_eq!(fs::read(target.join("a.md")).unwrap(), b"new"); assert_eq!(fs::read(target.join("b.md")).unwrap(), b"fresh"); assert!(!target.join("old.md").exists()); } #[test] fn install_skills_cleans_up_staging_artifacts() { let dir = TempDir::new().unwrap(); let target = dir.path().join(".agents/skills/stove"); let remote = remote_with(&[("a.md", b"hello")]); install_skills(&target, &remote).unwrap(); let parent = target.parent().unwrap(); let leftovers: Vec<_> = fs::read_dir(parent) .unwrap() .flatten() .filter(|entry| { let name = entry.file_name().to_string_lossy().into_owned(); name.contains("-staging-") || name.contains("-old-") }) .collect(); assert!(leftovers.is_empty(), "staging dirs should be removed"); } } ================================================ FILE: tools/stove-cli/src/sse/manager.rs ================================================ use tokio::sync::broadcast; /// Manages SSE (Server-Sent Events) broadcasting to connected browser clients. /// /// Uses `tokio::sync::broadcast` so multiple SSE clients each get their own receiver. /// Events are JSON-serialized dashboard events. pub struct SseManager { sender: broadcast::Sender, } impl SseManager { #[must_use] pub fn new() -> Self { let (sender, _) = broadcast::channel(4096); Self { sender } } /// Broadcast a JSON event to all connected SSE clients. /// /// Ignores `SendError` (no subscribers is fine — nobody is listening yet). pub fn broadcast(&self, json: &str) { if let Err(e) = self.sender.send(json.to_string()) { tracing::debug!("No SSE subscribers to broadcast to: {e}"); } } /// Create a new receiver for SSE clients to subscribe to. #[must_use] pub fn subscribe(&self) -> broadcast::Receiver { self.sender.subscribe() } } impl Default for SseManager { fn default() -> Self { Self::new() } } ================================================ FILE: tools/stove-cli/src/sse/mod.rs ================================================ pub mod manager; ================================================ FILE: tools/stove-cli/src/storage/database.rs ================================================ use crate::error::Result; use rusqlite::{Connection, OpenFlags}; use std::sync::atomic::{AtomicUsize, Ordering}; use tracing::info; /// Versioned SQL migrations, embedded at compile time. /// Add new migrations by creating `V{N}__description.sql` in `src/storage/migrations/`. /// Once deployed, never modify an existing migration — append a new one instead. const MIGRATIONS: &[(&str, &str)] = &[ ( "V1__initial_schema", include_str!("migrations/V1__initial_schema.sql"), ), ( "V2__run_stove_version", include_str!("migrations/V2__run_stove_version.sql"), ), ( "V3__test_path", include_str!("migrations/V3__test_path.sql"), ), ]; /// `SQLite` database wrapper with WAL mode and versioned schema migrations. pub struct Database { path: String, use_uri: bool, conn: Connection, } impl Database { /// Open (or create) the database at the given path. /// /// Uses WAL mode for concurrent reads and runs versioned migrations on startup. pub fn open(path: &str) -> Result { let (path, use_uri) = normalize_db_path(path); let conn = open_connection(&path, use_uri)?; apply_pragmas(&conn, &path)?; run_migrations(&conn)?; Ok(Self { path, use_uri, conn, }) } /// Returns a reference to the underlying connection. pub fn conn(&self) -> &Connection { &self.conn } /// Returns a mutable reference to the underlying connection. pub fn conn_mut(&mut self) -> &mut Connection { &mut self.conn } /// Open another connection to the same database. /// /// The CLI uses this to isolate read traffic from the write path so the UI can /// keep polling while gRPC ingestion is busy. pub fn open_peer(&self) -> Result { let conn = open_connection(&self.path, self.use_uri)?; apply_pragmas(&conn, &self.path)?; Ok(Self { path: self.path.clone(), use_uri: self.use_uri, conn, }) } } /// Run versioned migrations that haven't been applied yet. /// /// Tracks applied migrations in a `schema_migrations` table. Each migration is /// applied inside a transaction and recorded with its name and version number. fn run_migrations(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')) );", )?; let current_version: i64 = conn.query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_migrations", [], |row| row.get(0), )?; for (i, (name, sql)) in MIGRATIONS.iter().enumerate() { #[allow(clippy::cast_possible_wrap)] let version = (i + 1) as i64; if version <= current_version { continue; } let tx = conn.unchecked_transaction()?; tx.execute_batch(sql)?; tx.execute( "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)", rusqlite::params![version, name], )?; tx.commit()?; info!(version, name, "Applied migration"); } Ok(()) } fn normalize_db_path(path: &str) -> (String, bool) { if path == ":memory:" { let id = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed); ( format!("file:stove-test-{id}?mode=memory&cache=shared"), true, ) } else { (path.to_string(), false) } } fn open_connection(path: &str, use_uri: bool) -> Result { let conn = if use_uri { Connection::open_with_flags( path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_URI, )? } else { Connection::open(path)? }; Ok(conn) } fn apply_pragmas(conn: &Connection, path: &str) -> Result<()> { if path.starts_with("file:stove-test-") { conn.execute_batch("PRAGMA foreign_keys=ON;")?; } else { conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; } Ok(()) } static IN_MEMORY_DB_COUNTER: AtomicUsize = AtomicUsize::new(0); #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn open_in_memory_succeeds_and_creates_tables() { let db = Database::open(":memory:").expect("should open in-memory database"); let tables: Vec = db .conn() .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") .unwrap() .query_map([], |row| row.get(0)) .unwrap() .filter_map(|r| r.ok()) .collect(); assert!(tables.contains(&"runs".to_string())); assert!(tables.contains(&"tests".to_string())); assert!(tables.contains(&"entries".to_string())); assert!(tables.contains(&"spans".to_string())); assert!(tables.contains(&"snapshots".to_string())); } #[test] fn migrations_are_idempotent() { let db = Database::open(":memory:").expect("first open"); // Running migrations again should be a no-op run_migrations(db.conn()).expect("re-run should succeed"); let version: i64 = db .conn() .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { row.get(0) }) .unwrap(); assert_eq!(version, MIGRATIONS.len() as i64); } #[test] fn open_upgrades_v1_database_with_run_stove_version_column() { let dir = TempDir::new().unwrap(); let path = dir.path().join("stove-v1.db"); let conn = Connection::open(&path).unwrap(); conn.execute_batch(MIGRATIONS[0].1).unwrap(); conn .execute_batch( "CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')) );", ) .unwrap(); conn .execute( "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)", rusqlite::params![1_i64, "V1__initial_schema"], ) .unwrap(); drop(conn); let db = Database::open(path.to_str().unwrap()).unwrap(); let stove_version_columns: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM pragma_table_info('runs') WHERE name = 'stove_version'", [], |row| row.get(0), ) .unwrap(); let schema_version: i64 = db .conn() .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { row.get(0) }) .unwrap(); assert_eq!(stove_version_columns, 1); assert_eq!(schema_version, MIGRATIONS.len() as i64); } } ================================================ FILE: tools/stove-cli/src/storage/migrations/V1__initial_schema.sql ================================================ CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, app_name TEXT NOT NULL, started_at TEXT NOT NULL, ended_at TEXT, status TEXT NOT NULL DEFAULT 'RUNNING', total_tests INTEGER NOT NULL DEFAULT 0, passed INTEGER NOT NULL DEFAULT 0, failed INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER, systems TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS tests ( id TEXT NOT NULL, run_id TEXT NOT NULL, test_name TEXT NOT NULL, spec_name TEXT NOT NULL DEFAULT '', started_at TEXT NOT NULL, ended_at TEXT, status TEXT NOT NULL DEFAULT 'RUNNING', duration_ms INTEGER, error TEXT, PRIMARY KEY (run_id, id), FOREIGN KEY (run_id) REFERENCES runs(id) ); CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, test_id TEXT NOT NULL, timestamp TEXT NOT NULL, system TEXT NOT NULL, action TEXT NOT NULL, result TEXT NOT NULL, input TEXT, output TEXT, metadata TEXT, expected TEXT, actual TEXT, error TEXT, trace_id TEXT, FOREIGN KEY (run_id) REFERENCES runs(id) ); CREATE TABLE IF NOT EXISTS spans ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, trace_id TEXT NOT NULL, span_id TEXT NOT NULL, parent_span_id TEXT, operation_name TEXT NOT NULL, service_name TEXT NOT NULL, start_time_nanos INTEGER NOT NULL, end_time_nanos INTEGER NOT NULL, status TEXT NOT NULL, attributes TEXT, exception_type TEXT, exception_message TEXT, exception_stack_trace TEXT, FOREIGN KEY (run_id) REFERENCES runs(id) ); CREATE TABLE IF NOT EXISTS snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, test_id TEXT NOT NULL, system TEXT NOT NULL, state_json TEXT NOT NULL, summary TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ); CREATE INDEX IF NOT EXISTS idx_tests_run_id ON tests(run_id); CREATE INDEX IF NOT EXISTS idx_entries_run_test ON entries(run_id, test_id); CREATE INDEX IF NOT EXISTS idx_spans_run_id ON spans(run_id); CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON spans(trace_id); CREATE INDEX IF NOT EXISTS idx_snapshots_run_test ON snapshots(run_id, test_id); CREATE INDEX IF NOT EXISTS idx_runs_app_name ON runs(app_name); ================================================ FILE: tools/stove-cli/src/storage/migrations/V2__run_stove_version.sql ================================================ ALTER TABLE runs ADD COLUMN stove_version TEXT; ================================================ FILE: tools/stove-cli/src/storage/migrations/V3__test_path.sql ================================================ ALTER TABLE tests ADD COLUMN test_path TEXT NOT NULL DEFAULT '[]'; ================================================ FILE: tools/stove-cli/src/storage/mod.rs ================================================ pub mod database; pub mod models; pub mod repository; ================================================ FILE: tools/stove-cli/src/storage/models.rs ================================================ use std::fmt; use std::str::FromStr; use serde::Serialize; /// Status of a test run. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub enum RunStatus { #[serde(rename = "RUNNING")] Running, #[serde(rename = "PASSED")] Passed, #[serde(rename = "FAILED")] Failed, } impl FromStr for RunStatus { type Err = String; fn from_str(s: &str) -> Result { match s { "PASSED" => Ok(RunStatus::Passed), "FAILED" => Ok(RunStatus::Failed), "RUNNING" => Ok(RunStatus::Running), other => Err(format!("unknown run status: {other}")), } } } impl fmt::Display for RunStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { RunStatus::Running => write!(f, "RUNNING"), RunStatus::Passed => write!(f, "PASSED"), RunStatus::Failed => write!(f, "FAILED"), } } } /// Status of an individual test or entry result. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub enum TestStatus { #[serde(rename = "RUNNING")] Running, #[serde(rename = "PASSED")] Passed, #[serde(rename = "FAILED")] Failed, #[serde(rename = "ERROR")] Error, } impl FromStr for TestStatus { type Err = String; fn from_str(s: &str) -> Result { match s { "PASSED" => Ok(TestStatus::Passed), "FAILED" => Ok(TestStatus::Failed), "ERROR" => Ok(TestStatus::Error), "RUNNING" => Ok(TestStatus::Running), other => Err(format!("unknown test status: {other}")), } } } impl fmt::Display for TestStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { TestStatus::Running => write!(f, "RUNNING"), TestStatus::Passed => write!(f, "PASSED"), TestStatus::Failed => write!(f, "FAILED"), TestStatus::Error => write!(f, "ERROR"), } } } /// Summary of an application known to the dashboard. #[derive(Debug, Clone, Serialize)] pub struct AppSummary { pub app_name: String, pub latest_run_id: String, pub latest_status: RunStatus, pub stove_version: Option, pub total_runs: i32, } /// A single test run (one execution of a test suite). #[derive(Debug, Clone, Serialize)] pub struct Run { pub id: String, pub app_name: String, pub started_at: String, pub ended_at: Option, pub status: RunStatus, pub total_tests: i32, pub passed: i32, pub failed: i32, pub duration_ms: Option, pub stove_version: Option, pub systems: Vec, } /// A single test within a run. #[derive(Debug, Clone, Serialize)] pub struct Test { pub id: String, pub run_id: String, pub test_name: String, pub spec_name: String, pub test_path: Vec, pub started_at: String, pub ended_at: Option, pub status: TestStatus, pub duration_ms: Option, pub error: Option, } /// A report entry (action + result) within a test. #[derive(Debug, Clone, Serialize)] pub struct Entry { pub id: i64, pub run_id: String, pub test_id: String, pub timestamp: String, pub system: String, pub action: String, pub result: TestStatus, pub input: Option, pub output: Option, pub metadata: Option, pub expected: Option, pub actual: Option, pub error: Option, pub trace_id: Option, } /// A span in a distributed trace. #[derive(Debug, Clone, Serialize)] pub struct Span { pub id: i64, pub run_id: String, pub trace_id: String, pub span_id: String, pub parent_span_id: Option, pub operation_name: String, pub service_name: String, pub start_time_nanos: i64, pub end_time_nanos: i64, pub status: String, pub attributes: Option, pub exception_type: Option, pub exception_message: Option, pub exception_stack_trace: Option, } /// A system snapshot captured during a test. #[derive(Debug, Clone, Serialize)] pub struct Snapshot { pub id: i64, pub run_id: String, pub test_id: String, pub system: String, pub state_json: String, pub summary: String, } // --- Input structs for write operations --- /// Data required to save a new report entry. #[derive(Clone, Debug)] pub struct NewEntry { pub run_id: String, pub test_id: String, pub timestamp: String, pub system: String, pub action: String, pub result: String, pub input: String, pub output: String, pub metadata: String, pub expected: String, pub actual: String, pub error: String, pub trace_id: String, } /// Data required to save a new span. #[derive(Clone, Debug, Default)] pub struct NewSpan { pub run_id: String, pub trace_id: String, pub span_id: String, pub parent_span_id: String, pub operation_name: String, pub service_name: String, pub start_time_nanos: i64, pub end_time_nanos: i64, pub status: String, pub attributes: String, pub exception_type: String, pub exception_message: String, pub exception_stack_trace: String, } ================================================ FILE: tools/stove-cli/src/storage/repository.rs ================================================ use std::sync::{Arc, Mutex, MutexGuard}; use crate::error::Result; use crate::ingest::PersistedDashboardEvent; use crate::storage::database::Database; use crate::storage::models::{ AppSummary, Entry, NewEntry, NewSpan, Run, RunStatus, Snapshot, Span, Test, TestStatus, }; /// Thread-safe repository for CRUD operations on the `SQLite` database. /// /// Writes and reads use separate `SQLite` connections so the UI can keep polling /// while ingestion is busy. Each side is still serialized through its own mutex /// because a single `rusqlite::Connection` is not `Sync`. pub struct Repository { write_db: Arc>, read_db: Arc>, } impl Repository { pub fn new(db: Database) -> Self { let read_db = db .open_peer() .expect("peer database connection should open for repository reads"); Self { write_db: Arc::new(Mutex::new(db)), read_db: Arc::new(Mutex::new(read_db)), } } fn lock_write_db(&self) -> MutexGuard<'_, Database> { self.write_db.lock().expect("write database lock poisoned") } fn lock_read_db(&self) -> MutexGuard<'_, Database> { self.read_db.lock().expect("read database lock poisoned") } // --- Write operations (called from gRPC handler) --- pub fn save_run_start( &self, run_id: &str, app_name: &str, started_at: &str, systems: &[String], ) -> Result<()> { self.save_run_start_with_version(run_id, app_name, started_at, None, systems) } pub fn save_run_start_with_version( &self, run_id: &str, app_name: &str, started_at: &str, stove_version: Option<&str>, systems: &[String], ) -> Result<()> { let db = self.lock_write_db(); save_run_start_on( db.conn(), run_id, app_name, started_at, stove_version, systems, )?; Ok(()) } pub fn save_run_end( &self, run_id: &str, ended_at: &str, total_tests: i32, passed: i32, failed: i32, duration_ms: i64, ) -> Result<()> { let db = self.lock_write_db(); save_run_end_on( db.conn(), run_id, ended_at, total_tests, passed, failed, duration_ms, )?; Ok(()) } pub fn save_test_start( &self, run_id: &str, test_id: &str, test_name: &str, spec_name: &str, test_path: &[String], started_at: &str, ) -> Result<()> { let db = self.lock_write_db(); save_test_start_on( db.conn(), run_id, test_id, test_name, spec_name, test_path, started_at, )?; Ok(()) } pub fn save_test_end( &self, run_id: &str, test_id: &str, status: &str, duration_ms: i64, error: &str, ended_at: &str, ) -> Result<()> { let db = self.lock_write_db(); save_test_end_on( db.conn(), run_id, test_id, status, duration_ms, error, ended_at, )?; Ok(()) } pub fn save_entry(&self, entry: &NewEntry) -> Result<()> { let db = self.lock_write_db(); save_entry_on(db.conn(), entry)?; Ok(()) } pub fn save_span(&self, span: &NewSpan) -> Result<()> { let db = self.lock_write_db(); save_span_on(db.conn(), span)?; Ok(()) } pub fn save_snapshot( &self, run_id: &str, test_id: &str, system: &str, state_json: &str, summary: &str, ) -> Result<()> { let db = self.lock_write_db(); save_snapshot_on(db.conn(), run_id, test_id, system, state_json, summary)?; Ok(()) } pub fn clear_all(&self) -> Result<()> { let db = self.lock_write_db(); db.conn().execute_batch( "DELETE FROM snapshots; DELETE FROM spans; DELETE FROM entries; DELETE FROM tests; DELETE FROM runs;", )?; Ok(()) } pub fn apply_persisted_events(&self, events: &[PersistedDashboardEvent]) -> Result<()> { let mut db = self.lock_write_db(); let tx = db.conn_mut().unchecked_transaction()?; for event in events { apply_persisted_event(&tx, event)?; } tx.commit()?; Ok(()) } // --- Read operations (called from HTTP handlers) --- pub fn get_apps(&self) -> Result> { let db = self.lock_read_db(); let mut stmt = db.conn().prepare( "SELECT r.app_name, r.id, r.status, r.stove_version, (SELECT COUNT(*) FROM runs r2 WHERE r2.app_name = r.app_name) FROM runs r WHERE r.id = ( SELECT r3.id FROM runs r3 WHERE r3.app_name = r.app_name ORDER BY r3.started_at DESC, r3.rowid DESC LIMIT 1 ) ORDER BY app_name", )?; let rows = stmt .query_map([], |row| { Ok(AppSummary { app_name: row.get(0)?, latest_run_id: row.get(1)?, latest_status: parse_run_status(&row.get::<_, String>(2)?), stove_version: row.get(3)?, total_runs: row.get(4)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(rows) } pub fn get_runs(&self, app_name: Option<&str>) -> Result> { let db = self.lock_read_db(); let filter = match app_name { Some(_) => " WHERE app_name = ?1", None => "", }; let sql = format!("SELECT {RUN_COLUMNS} FROM runs{filter} ORDER BY started_at DESC, rowid DESC"); let mut stmt = db.conn().prepare(&sql)?; let rows = match app_name { Some(name) => stmt.query_map(rusqlite::params![name], run_from_row)?, None => stmt.query_map([], run_from_row)?, }; Ok(rows.filter_map(|r| r.ok()).collect()) } pub fn get_run(&self, run_id: &str) -> Result> { let db = self.lock_read_db(); let sql = format!("SELECT {RUN_COLUMNS} FROM runs WHERE id = ?1"); let mut stmt = db.conn().prepare(&sql)?; let mut rows = stmt.query_map(rusqlite::params![run_id], run_from_row)?; Ok(rows.next().and_then(|r| r.ok())) } pub fn get_tests_for_run(&self, run_id: &str) -> Result> { let db = self.lock_read_db(); let mut stmt = db.conn().prepare( "SELECT id, run_id, test_name, spec_name, test_path, started_at, ended_at, status, duration_ms, error FROM tests WHERE run_id = ?1 ORDER BY started_at", )?; let rows = stmt .query_map(rusqlite::params![run_id], test_from_row)? .filter_map(|r| r.ok()) .collect(); Ok(rows) } pub fn get_entries(&self, run_id: &str, test_id: &str) -> Result> { let db = self.lock_read_db(); let mut stmt = db.conn().prepare( "SELECT id, run_id, test_id, timestamp, system, action, result, input, output, metadata, expected, actual, error, trace_id FROM entries WHERE run_id = ?1 AND test_id = ?2 ORDER BY timestamp", )?; let rows = stmt .query_map(rusqlite::params![run_id, test_id], entry_from_row)? .filter_map(|r| r.ok()) .collect(); Ok(rows) } pub fn get_spans_for_test(&self, run_id: &str, test_id: &str) -> Result> { let db = self.lock_read_db(); let sql = format!( "SELECT {SPAN_COLUMNS} FROM spans \ WHERE run_id = ?1 AND trace_id IN ( \ SELECT trace_id FROM entries WHERE run_id = ?1 AND test_id = ?2 AND trace_id != '' \ UNION \ SELECT DISTINCT trace_id FROM spans WHERE run_id = ?1 AND ( \ json_extract(attributes, '$.\"x-stove-test-id\"') = ?2 OR \ json_extract(attributes, '$.\"X-Stove-Test-Id\"') = ?2 OR \ json_extract(attributes, '$.\"stove.test.id\"') = ?2 OR \ json_extract(attributes, '$.\"stove_test_id\"') = ?2 \ ) \ ) \ ORDER BY start_time_nanos" ); let mut stmt = db.conn().prepare(&sql)?; let rows = stmt .query_map(rusqlite::params![run_id, test_id], span_from_row)? .filter_map(|r| r.ok()) .collect(); Ok(rows) } pub fn get_trace(&self, trace_id: &str) -> Result> { let db = self.lock_read_db(); let sql = format!("SELECT {SPAN_COLUMNS} FROM spans WHERE trace_id = ?1 ORDER BY start_time_nanos"); let mut stmt = db.conn().prepare(&sql)?; let rows = stmt .query_map(rusqlite::params![trace_id], span_from_row)? .filter_map(|r| r.ok()) .collect(); Ok(rows) } pub fn get_snapshots(&self, run_id: &str, test_id: &str) -> Result> { let db = self.lock_read_db(); let mut stmt = db.conn().prepare( "SELECT id, run_id, test_id, system, state_json, summary FROM snapshots WHERE run_id = ?1 AND test_id = ?2", )?; let rows = stmt .query_map(rusqlite::params![run_id, test_id], snapshot_from_row)? .filter_map(|r| r.ok()) .collect(); Ok(rows) } } fn apply_persisted_event( conn: &rusqlite::Connection, event: &PersistedDashboardEvent, ) -> Result<()> { match event { PersistedDashboardEvent::RunStarted { run_id, app_name, started_at, stove_version, systems, } => save_run_start_on( conn, run_id, app_name, started_at, stove_version.as_deref(), systems, ), PersistedDashboardEvent::RunEnded { run_id, ended_at, total_tests, passed, failed, duration_ms, } => save_run_end_on( conn, run_id, ended_at, *total_tests, *passed, *failed, *duration_ms, ), PersistedDashboardEvent::TestStarted { run_id, test_id, test_name, spec_name, test_path, started_at, } => save_test_start_on( conn, run_id, test_id, test_name, spec_name, test_path, started_at, ), PersistedDashboardEvent::TestEnded { run_id, test_id, status, duration_ms, error, ended_at, } => save_test_end_on( conn, run_id, test_id, status, *duration_ms, error.as_deref().unwrap_or_default(), ended_at, ), PersistedDashboardEvent::EntryRecorded(entry) => save_entry_on(conn, entry), PersistedDashboardEvent::SpanRecorded(span) => save_span_on(conn, span), PersistedDashboardEvent::Snapshot { run_id, test_id, system, state_json, summary, } => save_snapshot_on(conn, run_id, test_id, system, state_json, summary), } } fn save_run_start_on( conn: &rusqlite::Connection, run_id: &str, app_name: &str, started_at: &str, stove_version: Option<&str>, systems: &[String], ) -> Result<()> { let systems_json = serde_json::to_string(systems)?; conn.execute( "INSERT OR REPLACE INTO runs (id, app_name, started_at, stove_version, systems) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![run_id, app_name, started_at, stove_version, systems_json], )?; Ok(()) } fn save_run_end_on( conn: &rusqlite::Connection, run_id: &str, ended_at: &str, total_tests: i32, passed: i32, failed: i32, duration_ms: i64, ) -> Result<()> { let status = if failed > 0 { RunStatus::Failed } else { RunStatus::Passed }; conn.execute( "UPDATE runs SET ended_at = ?1, status = ?2, total_tests = ?3, passed = ?4, failed = ?5, duration_ms = ?6 WHERE id = ?7", rusqlite::params![ended_at, status.to_string(), total_tests, passed, failed, duration_ms, run_id], )?; Ok(()) } fn save_test_start_on( conn: &rusqlite::Connection, run_id: &str, test_id: &str, test_name: &str, spec_name: &str, test_path: &[String], started_at: &str, ) -> Result<()> { let test_path_json = serde_json::to_string(test_path)?; conn.execute( "INSERT OR REPLACE INTO tests (id, run_id, test_name, spec_name, test_path, started_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![test_id, run_id, test_name, spec_name, test_path_json, started_at], )?; Ok(()) } fn save_test_end_on( conn: &rusqlite::Connection, run_id: &str, test_id: &str, status: &str, duration_ms: i64, error: &str, ended_at: &str, ) -> Result<()> { conn.execute( "UPDATE tests SET ended_at = ?1, status = ?2, duration_ms = ?3, error = ?4 WHERE run_id = ?5 AND id = ?6", rusqlite::params![ended_at, status, duration_ms, non_empty(error), run_id, test_id], )?; Ok(()) } fn save_entry_on(conn: &rusqlite::Connection, entry: &NewEntry) -> Result<()> { conn.execute( "INSERT INTO entries (run_id, test_id, timestamp, system, action, result, input, output, metadata, expected, actual, error, trace_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", rusqlite::params![ entry.run_id, entry.test_id, entry.timestamp, entry.system, entry.action, entry.result, non_empty(&entry.input), non_empty(&entry.output), non_empty(&entry.metadata), non_empty(&entry.expected), non_empty(&entry.actual), non_empty(&entry.error), non_empty(&entry.trace_id) ], )?; Ok(()) } fn save_span_on(conn: &rusqlite::Connection, span: &NewSpan) -> Result<()> { conn.execute( "INSERT INTO spans (run_id, trace_id, span_id, parent_span_id, operation_name, service_name, start_time_nanos, end_time_nanos, status, attributes, exception_type, exception_message, exception_stack_trace) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", rusqlite::params![ span.run_id, span.trace_id, span.span_id, non_empty(&span.parent_span_id), span.operation_name, span.service_name, span.start_time_nanos, span.end_time_nanos, span.status, non_empty(&span.attributes), non_empty(&span.exception_type), non_empty(&span.exception_message), non_empty(&span.exception_stack_trace) ], )?; Ok(()) } fn save_snapshot_on( conn: &rusqlite::Connection, run_id: &str, test_id: &str, system: &str, state_json: &str, summary: &str, ) -> Result<()> { conn.execute( "INSERT INTO snapshots (run_id, test_id, system, state_json, summary) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![run_id, test_id, system, state_json, summary], )?; Ok(()) } // --- SQL column constants --- const RUN_COLUMNS: &str = "id, app_name, started_at, ended_at, status, total_tests, passed, failed, duration_ms, stove_version, systems"; const SPAN_COLUMNS: &str = "id, run_id, trace_id, span_id, parent_span_id, operation_name, service_name, start_time_nanos, end_time_nanos, status, attributes, exception_type, exception_message, exception_stack_trace"; // --- Row-mapping helpers --- /// Convert empty strings to `None` for optional database fields. fn non_empty(s: &str) -> Option<&str> { if s.is_empty() { None } else { Some(s) } } /// Parse a `RunStatus` from a database string, defaulting to `Running`. fn parse_run_status(s: &str) -> RunStatus { s.parse().unwrap_or(RunStatus::Running) } /// Parse a `TestStatus` from a database string, defaulting to `Running`. fn parse_test_status(s: &str) -> TestStatus { s.parse().unwrap_or(TestStatus::Running) } fn run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let systems_json: String = row.get(10)?; let systems: Vec = serde_json::from_str(&systems_json).unwrap_or_default(); Ok(Run { id: row.get(0)?, app_name: row.get(1)?, started_at: row.get(2)?, ended_at: row.get(3)?, status: parse_run_status(&row.get::<_, String>(4)?), total_tests: row.get(5)?, passed: row.get(6)?, failed: row.get(7)?, duration_ms: row.get(8)?, stove_version: row.get(9)?, systems, }) } fn test_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let test_path_json: String = row.get(4)?; let test_path: Vec = serde_json::from_str(&test_path_json).unwrap_or_default(); Ok(Test { id: row.get(0)?, run_id: row.get(1)?, test_name: row.get(2)?, spec_name: row.get(3)?, test_path, started_at: row.get(5)?, ended_at: row.get(6)?, status: parse_test_status(&row.get::<_, String>(7)?), duration_ms: row.get(8)?, error: row.get(9)?, }) } fn entry_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Entry { id: row.get(0)?, run_id: row.get(1)?, test_id: row.get(2)?, timestamp: row.get(3)?, system: row.get(4)?, action: row.get(5)?, result: parse_test_status(&row.get::<_, String>(6)?), input: row.get(7)?, output: row.get(8)?, metadata: row.get(9)?, expected: row.get(10)?, actual: row.get(11)?, error: row.get(12)?, trace_id: row.get(13)?, }) } fn snapshot_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Snapshot { id: row.get(0)?, run_id: row.get(1)?, test_id: row.get(2)?, system: row.get(3)?, state_json: row.get(4)?, summary: row.get(5)?, }) } fn span_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Span { id: row.get(0)?, run_id: row.get(1)?, trace_id: row.get(2)?, span_id: row.get(3)?, parent_span_id: row.get(4)?, operation_name: row.get(5)?, service_name: row.get(6)?, start_time_nanos: row.get(7)?, end_time_nanos: row.get(8)?, status: row.get(9)?, attributes: row.get(10)?, exception_type: row.get(11)?, exception_message: row.get(12)?, exception_stack_trace: row.get(13)?, }) } #[cfg(test)] mod tests { use super::*; use crate::storage::database::Database; fn test_repo() -> Repository { Repository::new(Database::open(":memory:").unwrap()) } #[test] fn full_event_lifecycle() { let repo = test_repo(); repo .save_run_start_with_version( "run-1", "product-api", "2024-01-01T00:00:00Z", Some("0.23.2"), &["HTTP".into(), "Kafka".into()], ) .unwrap(); repo .save_test_start( "run-1", "test-1", "should create product", "ProductSpec", &[], "2024-01-01T00:00:01Z", ) .unwrap(); repo .save_entry(&NewEntry { run_id: "run-1".into(), test_id: "test-1".into(), timestamp: "2024-01-01T00:00:02Z".into(), system: "HTTP".into(), action: "POST /products".into(), result: "PASSED".into(), input: r#"{"name":"widget"}"#.into(), output: r#"{"id":1}"#.into(), metadata: "{}".into(), expected: String::new(), actual: String::new(), error: String::new(), trace_id: String::new(), }) .unwrap(); repo .save_span(&NewSpan { run_id: "run-1".into(), trace_id: "trace-abc".into(), span_id: "span-1".into(), operation_name: "POST /products".into(), service_name: "product-api".into(), start_time_nanos: 1_000_000_000, end_time_nanos: 1_100_000_000, status: "OK".into(), attributes: r#"{"http.method":"POST"}"#.into(), ..Default::default() }) .unwrap(); repo .save_snapshot( "run-1", "test-1", "Kafka", r#"{"consumed":5}"#, "5 messages consumed", ) .unwrap(); repo .save_test_end( "run-1", "test-1", "PASSED", 1500, "", "2024-01-01T00:00:03Z", ) .unwrap(); repo .save_run_end("run-1", "2024-01-01T00:00:10Z", 1, 1, 0, 10000) .unwrap(); let runs = repo.get_runs(None).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].app_name, "product-api"); assert_eq!(runs[0].status, RunStatus::Passed); assert_eq!(runs[0].stove_version.as_deref(), Some("0.23.2")); assert_eq!(runs[0].systems, vec!["HTTP", "Kafka"]); let run = repo.get_run("run-1").unwrap().unwrap(); assert_eq!(run.total_tests, 1); assert_eq!(run.passed, 1); let tests = repo.get_tests_for_run("run-1").unwrap(); assert_eq!(tests.len(), 1); assert_eq!(tests[0].test_name, "should create product"); assert_eq!(tests[0].status, TestStatus::Passed); let entries = repo.get_entries("run-1", "test-1").unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].system, "HTTP"); assert_eq!(entries[0].action, "POST /products"); let trace = repo.get_trace("trace-abc").unwrap(); assert_eq!(trace.len(), 1); assert_eq!(trace[0].operation_name, "POST /products"); let snapshots = repo.get_snapshots("run-1", "test-1").unwrap(); assert_eq!(snapshots.len(), 1); assert_eq!(snapshots[0].system, "Kafka"); let apps = repo.get_apps().unwrap(); assert_eq!(apps.len(), 1); assert_eq!(apps[0].app_name, "product-api"); assert_eq!(apps[0].stove_version.as_deref(), Some("0.23.2")); assert_eq!(apps[0].total_runs, 1); } #[test] fn latest_app_version_comes_from_latest_run() { let repo = test_repo(); repo .save_run_start_with_version( "run-1", "product-api", "2024-01-01T00:00:00Z", Some("0.23.0"), &[], ) .unwrap(); repo .save_run_start_with_version( "run-2", "product-api", "2024-01-02T00:00:00Z", Some("0.23.2"), &[], ) .unwrap(); let apps = repo.get_apps().unwrap(); assert_eq!(apps.len(), 1); assert_eq!(apps[0].latest_run_id, "run-2"); assert_eq!(apps[0].stove_version.as_deref(), Some("0.23.2")); } #[test] fn get_runs_filters_by_app_name() { let repo = test_repo(); repo .save_run_start("run-1", "product-api", "2024-01-01T00:00:00Z", &[]) .unwrap(); repo .save_run_start("run-2", "order-api", "2024-01-01T00:00:01Z", &[]) .unwrap(); let product_runs = repo.get_runs(Some("product-api")).unwrap(); assert_eq!(product_runs.len(), 1); assert_eq!(product_runs[0].app_name, "product-api"); let all_runs = repo.get_runs(None).unwrap(); assert_eq!(all_runs.len(), 2); } #[test] fn clear_all_removes_everything() { let repo = test_repo(); repo .save_run_start("run-1", "app", "2024-01-01T00:00:00Z", &[]) .unwrap(); repo .save_test_start("run-1", "test-1", "test", "", &[], "2024-01-01T00:00:01Z") .unwrap(); repo.clear_all().unwrap(); assert!(repo.get_runs(None).unwrap().is_empty()); assert!(repo.get_tests_for_run("run-1").unwrap().is_empty()); } #[test] fn get_apps_returns_single_latest_run_when_started_at_ties() { let repo = test_repo(); repo .save_run_start("run-1", "my-app", "2024-06-01T00:00:00Z", &[]) .unwrap(); repo .save_run_start("run-2", "my-app", "2024-06-01T00:00:00Z", &[]) .unwrap(); let apps = repo.get_apps().unwrap(); assert_eq!(apps.len(), 1); assert_eq!(apps[0].app_name, "my-app"); assert_eq!(apps[0].latest_run_id, "run-2"); assert_eq!(apps[0].total_runs, 2); } #[test] fn get_runs_orders_same_timestamp_runs_by_latest_inserted_first() { let repo = test_repo(); repo .save_run_start("run-1", "my-app", "2024-06-01T00:00:00Z", &[]) .unwrap(); repo .save_run_start("run-2", "my-app", "2024-06-01T00:00:00Z", &[]) .unwrap(); let runs = repo.get_runs(Some("my-app")).unwrap(); assert_eq!(runs.len(), 2); assert_eq!(runs[0].id, "run-2"); assert_eq!(runs[1].id, "run-1"); } #[test] fn get_spans_for_test_does_not_cross_match_similar_test_ids() { let repo = test_repo(); repo .save_run_start("run-1", "my-app", "2024-06-01T00:00:00Z", &[]) .unwrap(); repo .save_test_start( "run-1", "test-1", "first test", "Spec", &[], "2024-06-01T00:00:01Z", ) .unwrap(); repo .save_test_start( "run-1", "test-10", "tenth test", "Spec", &[], "2024-06-01T00:00:02Z", ) .unwrap(); repo .save_span(&NewSpan { run_id: "run-1".into(), trace_id: "trace-10".into(), span_id: "span-10".into(), operation_name: "GET /ten".into(), service_name: "my-app".into(), start_time_nanos: 1_000_000_000, end_time_nanos: 1_100_000_000, status: "OK".into(), attributes: r#"{"x-stove-test-id":"test-10"}"#.into(), ..Default::default() }) .unwrap(); let spans = repo.get_spans_for_test("run-1", "test-1").unwrap(); assert!(spans.is_empty()); } } ================================================ FILE: tools/stove-cli/tests/api_e2e.rs ================================================ //! End-to-end tests for the Stove CLI REST API. //! //! Each test spins up a real axum server on an OS-assigned port backed by an //! in-memory SQLite database, then exercises the HTTP endpoints with `reqwest`. //! This gives us true black-box regression coverage of the full request path: //! routing -> handler -> repository -> SQLite -> JSON serialization. mod common; use common::TestServer; use reqwest::StatusCode; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use stove::grpc::service::DashboardEventServiceImpl; use stove::proto; use stove::proto::dashboard_event_service_server::DashboardEventService; use tonic::Request; fn ts(seconds: i64, nanos: i32) -> Option { Some(prost_types::Timestamp { seconds, nanos }) } fn run_started_event( run_id: &str, app_name: &str, seconds: i64, nanos: i32, ) -> proto::DashboardEvent { run_started_event_with_version(run_id, app_name, "", seconds, nanos) } fn run_started_event_with_version( run_id: &str, app_name: &str, stove_version: &str, seconds: i64, nanos: i32, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::RunStarted( proto::RunStartedEvent { timestamp: ts(seconds, nanos), app_name: app_name.to_string(), systems: vec!["HTTP".to_string(), "Kafka".to_string()], stove_version: stove_version.to_string(), }, )), } } fn run_ended_event( run_id: &str, total_tests: i32, passed: i32, failed: i32, duration_ms: i64, seconds: i64, nanos: i32, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::RunEnded( proto::RunEndedEvent { timestamp: ts(seconds, nanos), total_tests, passed, failed, duration_ms, }, )), } } fn test_started_event( run_id: &str, test_id: &str, test_name: &str, spec_name: &str, seconds: i64, nanos: i32, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::TestStarted( proto::TestStartedEvent { test_id: test_id.to_string(), test_name: test_name.to_string(), spec_name: spec_name.to_string(), timestamp: ts(seconds, nanos), test_path: vec![], }, )), } } fn test_ended_event( run_id: &str, test_id: &str, status: &str, duration_ms: i64, error: &str, seconds: i64, nanos: i32, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::TestEnded( proto::TestEndedEvent { test_id: test_id.to_string(), status: status.to_string(), duration_ms, error: error.to_string(), timestamp: ts(seconds, nanos), }, )), } } fn entry_recorded_event( run_id: &str, test_id: &str, action: &str, result: &str, trace_id: &str, seconds: i64, nanos: i32, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::EntryRecorded( proto::EntryRecordedEvent { test_id: test_id.to_string(), timestamp: ts(seconds, nanos), system: "HTTP".to_string(), action: action.to_string(), result: result.to_string(), input: String::new(), output: String::new(), metadata: HashMap::default(), expected: String::new(), actual: String::new(), error: String::new(), trace_id: trace_id.to_string(), }, )), } } fn span_recorded_event( run_id: &str, trace_id: &str, span_id: &str, parent_span_id: &str, operation_name: &str, service_name: &str, start_time_nanos: i64, end_time_nanos: i64, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::SpanRecorded( proto::SpanRecordedEvent { trace_id: trace_id.to_string(), span_id: span_id.to_string(), parent_span_id: parent_span_id.to_string(), operation_name: operation_name.to_string(), service_name: service_name.to_string(), start_time_nanos, end_time_nanos, status: "OK".to_string(), attributes: HashMap::default(), exception: None, }, )), } } fn snapshot_event( run_id: &str, test_id: &str, system: &str, state_json: &str, summary: &str, ) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::Snapshot( proto::SnapshotEvent { test_id: test_id.to_string(), system: system.to_string(), state_json: state_json.to_string(), summary: summary.to_string(), }, )), } } async fn send_event( service: &DashboardEventServiceImpl, event: proto::DashboardEvent, ) -> Result<(), tonic::Status> { DashboardEventService::send_event(service, Request::new(event)) .await .map(|_| ()) } async fn flush_events(service: &DashboardEventServiceImpl) { service .flush_pending() .await .expect("queued dashboard events should flush"); } fn extract_sse_data_frame(frame: &str) -> Option { let data_lines: Vec<&str> = frame .lines() .filter_map(|line| line.strip_prefix("data:").map(str::trim_start)) .collect(); if data_lines.is_empty() { None } else { Some(data_lines.join("\n")) } } async fn next_sse_data( resp: &mut reqwest::Response, buffer: &mut String, ) -> Result> { loop { if let Some(frame_end) = buffer.find("\n\n") { let frame = buffer[..frame_end].to_string(); buffer.drain(..frame_end + 2); if let Some(data) = extract_sse_data_frame(&frame) { return Ok(data); } } let chunk = tokio::time::timeout(std::time::Duration::from_secs(5), resp.chunk()).await??; let chunk = chunk.ok_or("SSE stream ended before the next event")?; buffer.push_str(std::str::from_utf8(&chunk)?); } } // --------------------------------------------------------------------------- // GET /api/v1/meta // --------------------------------------------------------------------------- #[tokio::test] async fn meta_returns_cli_version() { let server = TestServer::start().await; let body = server.get_json("/meta").await; assert_eq!(body["stove_cli_version"], stove::STOVE_CLI_VERSION); assert_eq!(body["mcp"]["enabled"], true); assert_eq!(body["mcp"]["transport"], "streamable-http"); assert_eq!(body["mcp"]["endpoint"], format!("{}/mcp", server.base_url)); } // --------------------------------------------------------------------------- // GET /api/v1/apps // --------------------------------------------------------------------------- #[tokio::test] async fn apps_returns_empty_when_no_data() { let server = TestServer::start().await; let body = server.get_json("/apps").await; assert_eq!(body, Value::Array(vec![])); } #[tokio::test] async fn apps_returns_app_summaries() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/apps").await; let apps = body.as_array().expect("should be array"); assert_eq!(apps.len(), 1); assert_eq!(apps[0]["app_name"], "product-api"); assert_eq!(apps[0]["latest_run_id"], "run-1"); assert_eq!(apps[0]["latest_status"], "FAILED"); assert!(apps[0]["stove_version"].is_null()); assert_eq!(apps[0]["total_runs"], 1); } #[tokio::test] async fn apps_returns_multiple_apps() { let server = TestServer::start().await; server.seed_run("run-a", "alpha-api"); server.seed_run("run-b", "beta-api"); let body = server.get_json("/apps").await; let apps = body.as_array().unwrap(); assert_eq!(apps.len(), 2); let names: Vec<&str> = apps .iter() .map(|a| a["app_name"].as_str().unwrap()) .collect(); assert!(names.contains(&"alpha-api")); assert!(names.contains(&"beta-api")); } #[tokio::test] async fn apps_return_latest_run_stove_version() { let server = TestServer::start().await; server.seed_run_at_with_version( "run-old", "product-api", "2024-01-01T00:00:00Z", Some("0.23.0"), &[], ); server.seed_run_at_with_version( "run-new", "product-api", "2024-06-01T00:00:00Z", Some("0.23.2"), &[], ); let body = server.get_json("/apps").await; let apps = body.as_array().unwrap(); assert_eq!(apps[0]["latest_run_id"], "run-new"); assert_eq!(apps[0]["stove_version"], "0.23.2"); } // --------------------------------------------------------------------------- // GET /api/v1/runs // --------------------------------------------------------------------------- #[tokio::test] async fn runs_returns_all_runs() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs").await; let runs = body.as_array().unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0]["id"], "run-1"); assert_eq!(runs[0]["app_name"], "product-api"); assert_eq!(runs[0]["status"], "FAILED"); assert_eq!(runs[0]["total_tests"], 2); assert_eq!(runs[0]["passed"], 1); assert_eq!(runs[0]["failed"], 1); assert_eq!(runs[0]["duration_ms"], 10000); assert!(runs[0]["stove_version"].is_null()); let systems = runs[0]["systems"].as_array().unwrap(); assert_eq!(systems.len(), 3); assert!(systems.contains(&Value::String("HTTP".into()))); assert!(systems.contains(&Value::String("Kafka".into()))); } #[tokio::test] async fn runs_return_stove_version() { let server = TestServer::start().await; server.seed_run_with_version("run-1", "product-api", "0.23.1"); let body = server.get_json("/runs").await; let runs = body.as_array().unwrap(); assert_eq!(runs[0]["stove_version"], "0.23.1"); } #[tokio::test] async fn runs_filters_by_app_name() { let server = TestServer::start().await; server.seed_run("run-a", "alpha-api"); server.seed_run("run-b", "beta-api"); let body = server.get_json("/runs?app=alpha-api").await; let runs = body.as_array().unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0]["app_name"], "alpha-api"); } #[tokio::test] async fn runs_returns_empty_for_unknown_app() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs?app=nonexistent").await; assert_eq!(body, Value::Array(vec![])); } // --------------------------------------------------------------------------- // GET /api/v1/runs/:run_id // --------------------------------------------------------------------------- #[tokio::test] async fn get_run_returns_single_run() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1").await; assert_eq!(body["id"], "run-1"); assert_eq!(body["app_name"], "product-api"); assert_eq!(body["started_at"], "2024-06-01T10:00:00Z"); assert_eq!(body["ended_at"], "2024-06-01T10:00:10Z"); } #[tokio::test] async fn get_run_returns_null_for_unknown_id() { let server = TestServer::start().await; let body = server.get_json("/runs/nonexistent").await; assert_eq!(body, Value::Null); } // --------------------------------------------------------------------------- // GET /api/v1/runs/:run_id/tests // --------------------------------------------------------------------------- #[tokio::test] async fn get_tests_returns_tests_for_run() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests").await; let tests = body.as_array().unwrap(); assert_eq!(tests.len(), 2); assert_eq!(tests[0]["test_name"], "should create product"); assert_eq!(tests[0]["spec_name"], "ProductSpec"); assert_eq!(tests[0]["status"], "PASSED"); assert_eq!(tests[0]["duration_ms"], 1500); assert!(tests[0]["error"].is_null()); assert_eq!(tests[1]["test_name"], "should reject duplicate"); assert_eq!(tests[1]["status"], "FAILED"); assert_eq!(tests[1]["error"], "Expected conflict but got success"); } #[tokio::test] async fn get_tests_returns_empty_for_unknown_run() { let server = TestServer::start().await; let body = server.get_json("/runs/nonexistent/tests").await; assert_eq!(body, Value::Array(vec![])); } #[tokio::test] async fn concurrent_running_tests_are_visible_via_api_while_run_is_in_progress() { let server = TestServer::start().await; let service = Arc::new(DashboardEventServiceImpl::new( server.repo.clone(), server.sse.clone(), )); send_event( service.as_ref(), run_started_event("run-concurrent", "concurrent-app", 1_704_067_200, 0), ) .await .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), test_started_event( "run-concurrent", "test-a", "handles checkout", "ConcurrentSpec", 1_704_067_201, 0, ), ) .await }, async move { send_event( service_b.as_ref(), test_started_event( "run-concurrent", "test-b", "handles payment", "ConcurrentSpec", 1_704_067_201, 0, ), ) .await }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), entry_recorded_event( "run-concurrent", "test-a", "GET /checkout", "PASSED", "trace-a", 1_704_067_202, 0, ), ) .await }, async move { send_event( service_b.as_ref(), entry_recorded_event( "run-concurrent", "test-b", "POST /payment", "PASSED", "trace-b", 1_704_067_202, 0, ), ) .await }, ) .unwrap(); flush_events(service.as_ref()).await; let run = server.get_json("/runs/run-concurrent").await; assert_eq!(run["status"], "RUNNING"); let tests = server.get_json("/runs/run-concurrent/tests").await; let tests = tests.as_array().unwrap(); assert_eq!(tests.len(), 2); let test_a = tests.iter().find(|test| test["id"] == "test-a").unwrap(); assert_eq!(test_a["status"], "RUNNING"); assert!(test_a["ended_at"].is_null()); let test_b = tests.iter().find(|test| test["id"] == "test-b").unwrap(); assert_eq!(test_b["status"], "RUNNING"); assert!(test_b["ended_at"].is_null()); let entries_a = server .get_json("/runs/run-concurrent/tests/test-a/entries") .await; let entries_a = entries_a.as_array().unwrap(); assert_eq!(entries_a.len(), 1); assert_eq!(entries_a[0]["action"], "GET /checkout"); let entries_b = server .get_json("/runs/run-concurrent/tests/test-b/entries") .await; let entries_b = entries_b.as_array().unwrap(); assert_eq!(entries_b.len(), 1); assert_eq!(entries_b[0]["action"], "POST /payment"); } #[tokio::test] async fn concurrent_interleaved_test_lifecycle_remains_isolated_across_api_views() { let server = TestServer::start().await; let service = Arc::new(DashboardEventServiceImpl::new( server.repo.clone(), server.sse.clone(), )); send_event( service.as_ref(), run_started_event("run-interleaved", "concurrent-app", 1_704_067_300, 0), ) .await .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), test_started_event( "run-interleaved", "test-a", "handles checkout", "ConcurrentSpec", 1_704_067_301, 0, ), ) .await }, async move { send_event( service_b.as_ref(), test_started_event( "run-interleaved", "test-b", "handles payment", "ConcurrentSpec", 1_704_067_301, 0, ), ) .await }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), entry_recorded_event( "run-interleaved", "test-a", "GET /checkout", "PASSED", "trace-a", 1_704_067_302, 0, ), ) .await }, async move { send_event( service_b.as_ref(), entry_recorded_event( "run-interleaved", "test-b", "POST /payment", "FAILED", "trace-b", 1_704_067_302, 0, ), ) .await }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), span_recorded_event( "run-interleaved", "trace-a", "span-a", "", "GET /checkout", "checkout-api", 1_000_000_000, 1_100_000_000, ), ) .await }, async move { send_event( service_b.as_ref(), span_recorded_event( "run-interleaved", "trace-b", "span-b", "", "POST /payment", "payment-api", 1_200_000_000, 1_350_000_000, ), ) .await }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), snapshot_event( "run-interleaved", "test-a", "Kafka", r#"{"published":1}"#, "1 published", ), ) .await }, async move { send_event( service_b.as_ref(), snapshot_event( "run-interleaved", "test-b", "Redis", r#"{"keys":2}"#, "2 keys", ), ) .await }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), test_ended_event( "run-interleaved", "test-a", "PASSED", 1_200, "", 1_704_067_303, 0, ), ) .await }, async move { send_event( service_b.as_ref(), test_ended_event( "run-interleaved", "test-b", "FAILED", 1_500, "payment timeout", 1_704_067_303, 0, ), ) .await }, ) .unwrap(); send_event( service.as_ref(), run_ended_event("run-interleaved", 2, 1, 1, 3_000, 1_704_067_304, 0), ) .await .unwrap(); flush_events(service.as_ref()).await; let run = server.get_json("/runs/run-interleaved").await; assert_eq!(run["status"], "FAILED"); assert_eq!(run["total_tests"], 2); assert_eq!(run["passed"], 1); assert_eq!(run["failed"], 1); let tests = server.get_json("/runs/run-interleaved/tests").await; let tests = tests.as_array().unwrap(); assert_eq!(tests.len(), 2); let test_a = tests.iter().find(|test| test["id"] == "test-a").unwrap(); assert_eq!(test_a["status"], "PASSED"); assert!(test_a["error"].is_null()); let test_b = tests.iter().find(|test| test["id"] == "test-b").unwrap(); assert_eq!(test_b["status"], "FAILED"); assert_eq!(test_b["error"], "payment timeout"); let spans_a = server .get_json("/runs/run-interleaved/tests/test-a/spans") .await; let spans_a = spans_a.as_array().unwrap(); assert_eq!(spans_a.len(), 1); assert_eq!(spans_a[0]["span_id"], "span-a"); let spans_b = server .get_json("/runs/run-interleaved/tests/test-b/spans") .await; let spans_b = spans_b.as_array().unwrap(); assert_eq!(spans_b.len(), 1); assert_eq!(spans_b[0]["span_id"], "span-b"); let snapshots_a = server .get_json("/runs/run-interleaved/tests/test-a/snapshots") .await; let snapshots_a = snapshots_a.as_array().unwrap(); assert_eq!(snapshots_a.len(), 1); assert_eq!(snapshots_a[0]["system"], "Kafka"); let snapshots_b = server .get_json("/runs/run-interleaved/tests/test-b/snapshots") .await; let snapshots_b = snapshots_b.as_array().unwrap(); assert_eq!(snapshots_b.len(), 1); assert_eq!(snapshots_b[0]["system"], "Redis"); } // --------------------------------------------------------------------------- // GET /api/v1/runs/:run_id/tests/:test_id/entries // --------------------------------------------------------------------------- #[tokio::test] async fn get_entries_returns_entries_for_test() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests/test-1/entries").await; let entries = body.as_array().unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0]["system"], "HTTP"); assert_eq!(entries[0]["action"], "POST /products"); assert_eq!(entries[0]["result"], "PASSED"); assert_eq!(entries[0]["input"], r#"{"name":"widget"}"#); assert_eq!(entries[0]["output"], r#"{"id":42}"#); assert_eq!(entries[0]["trace_id"], "trace-abc"); } #[tokio::test] async fn get_entries_isolates_by_test_id() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests/test-2/entries").await; let entries = body.as_array().unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0]["result"], "FAILED"); assert_eq!(entries[0]["expected"], "409 Conflict"); assert_eq!(entries[0]["actual"], "201 Created"); } #[tokio::test] async fn get_entries_returns_empty_for_unknown_test() { let server = TestServer::start().await; server.seed_full_run(); let body = server .get_json("/runs/run-1/tests/nonexistent/entries") .await; assert_eq!(body, Value::Array(vec![])); } // --------------------------------------------------------------------------- // GET /api/v1/runs/:run_id/tests/:test_id/spans // --------------------------------------------------------------------------- #[tokio::test] async fn get_test_spans_returns_spans_linked_via_trace_id() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests/test-1/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 2); assert_eq!(spans[0]["operation_name"], "POST /products"); assert_eq!(spans[0]["status"], "OK"); assert_eq!(spans[1]["operation_name"], "INSERT INTO products"); assert_eq!(spans[1]["parent_span_id"], "span-1"); } #[tokio::test] async fn get_test_spans_returns_empty_when_no_trace_linked() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests/test-2/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 0); } #[tokio::test] async fn get_test_spans_returns_spans_linked_via_attribute() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-attr", "attribute test", "TracingSpec"); // Entry without trace_id — spans linked only via attributes server.seed_entry("run-1", "test-attr", "HTTP", "GET /items", "PASSED"); // Root span with x-stove-test-id attribute server.seed_span_timed( "run-1", "trace-xyz", "s1", "", "GET /items", "my-app", 1_000_000_000, 1_100_000_000, r#"{"x-stove-test-id":"test-attr","http.method":"GET"}"#, ); // Child span in the same trace without the attribute server.seed_span_timed( "run-1", "trace-xyz", "s2", "s1", "SELECT items", "my-db", 1_020_000_000, 1_080_000_000, r#"{"db.system":"postgresql"}"#, ); server.end_test("run-1", "test-attr", 500); let body = server.get_json("/runs/run-1/tests/test-attr/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 2, "both spans in the trace should appear"); assert_eq!(spans[0]["span_id"], "s1"); assert_eq!(spans[1]["span_id"], "s2"); } #[tokio::test] async fn get_test_spans_does_not_cross_match_similar_test_ids_in_attributes() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-1", "first test", "TracingSpec"); server.seed_test("run-1", "test-10", "tenth test", "TracingSpec"); server.seed_span_timed( "run-1", "trace-10", "span-10", "", "GET /ten", "my-app", 1_000_000_000, 1_100_000_000, r#"{"x-stove-test-id":"test-10","http.method":"GET"}"#, ); let body = server.get_json("/runs/run-1/tests/test-1/spans").await; let spans = body.as_array().unwrap(); assert_eq!( spans.len(), 0, "test-1 should not receive spans from test-10" ); } #[tokio::test] async fn get_test_spans_combines_entry_and_attribute_linked_traces() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-combo", "combo test", "TracingSpec"); // Entry links to trace-a server.seed_entry_full( "run-1", "test-combo", "HTTP", "POST /orders", "PASSED", "", "", "", "", "", "trace-a", ); // Span in trace-a (linked via entry) server.seed_span_timed( "run-1", "trace-a", "sa1", "", "POST /orders", "order-svc", 1_000_000_000, 1_100_000_000, "{}", ); // Span in trace-b (linked via attribute only) server.seed_span_timed( "run-1", "trace-b", "sb1", "", "process-event", "worker", 2_000_000_000, 2_200_000_000, r#"{"x-stove-test-id":"test-combo"}"#, ); server.end_test("run-1", "test-combo", 1000); let body = server.get_json("/runs/run-1/tests/test-combo/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 2); let span_ids: Vec<&str> = spans .iter() .map(|s| s["span_id"].as_str().unwrap()) .collect(); assert!(span_ids.contains(&"sa1"), "entry-linked span"); assert!(span_ids.contains(&"sb1"), "attribute-linked span"); } #[tokio::test] async fn get_test_spans_returns_full_trace_when_one_span_has_attribute() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-full", "full trace test", "TracingSpec"); server.seed_entry("run-1", "test-full", "HTTP", "GET /", "PASSED"); // Only root span has the test-id attribute server.seed_span_timed( "run-1", "trace-full", "root", "", "GET /", "gateway", 1_000_000_000, 1_500_000_000, r#"{"x-stove-test-id":"test-full"}"#, ); // Children don't have the attribute server.seed_span_timed( "run-1", "trace-full", "child-1", "root", "auth-check", "auth-svc", 1_050_000_000, 1_150_000_000, r#"{"auth.type":"jwt"}"#, ); server.seed_span_timed( "run-1", "trace-full", "child-2", "root", "db-query", "db-svc", 1_200_000_000, 1_400_000_000, r#"{"db.system":"mysql"}"#, ); server.end_test("run-1", "test-full", 2000); let body = server.get_json("/runs/run-1/tests/test-full/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 3, "all spans in the trace should be returned"); assert_eq!(spans[0]["span_id"], "root"); assert_eq!(spans[1]["span_id"], "child-1"); assert_eq!(spans[2]["span_id"], "child-2"); } #[tokio::test] async fn get_test_spans_ordered_by_start_time() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-order", "ordering test", "TracingSpec"); server.seed_entry_full( "run-1", "test-order", "HTTP", "GET /", "PASSED", "", "", "", "", "", "trace-ord", ); // Insert spans out of chronological order server.seed_span_timed( "run-1", "trace-ord", "late", "", "late-op", "svc", 3_000_000_000, 3_500_000_000, "{}", ); server.seed_span_timed( "run-1", "trace-ord", "early", "", "early-op", "svc", 1_000_000_000, 1_500_000_000, "{}", ); server.seed_span_timed( "run-1", "trace-ord", "mid", "", "mid-op", "svc", 2_000_000_000, 2_500_000_000, "{}", ); server.end_test("run-1", "test-order", 1000); let body = server.get_json("/runs/run-1/tests/test-order/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 3); assert_eq!(spans[0]["operation_name"], "early-op"); assert_eq!(spans[1]["operation_name"], "mid-op"); assert_eq!(spans[2]["operation_name"], "late-op"); } #[tokio::test] async fn get_test_spans_includes_exception_data() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-exc", "exception test", "TracingSpec"); server.seed_entry_full( "run-1", "test-exc", "HTTP", "POST /fail", "FAILED", "", "", "", "", "", "trace-exc", ); server.seed_span_with_exception( "run-1", "trace-exc", "err-span", "", "POST /fail", "my-svc", "ERROR", "java.lang.NullPointerException", "Cannot invoke method on null", "at com.example.Service.process(Service.java:42)", ); server.end_test_failed("run-1", "test-exc", 200, "NPE"); let body = server.get_json("/runs/run-1/tests/test-exc/spans").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 1); assert_eq!(spans[0]["status"], "ERROR"); assert_eq!(spans[0]["exception_type"], "java.lang.NullPointerException"); assert_eq!( spans[0]["exception_message"], "Cannot invoke method on null" ); assert!( spans[0]["exception_stack_trace"] .as_str() .unwrap() .contains("Service.java:42") ); } #[tokio::test] async fn get_test_spans_isolates_between_tests() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "t1", "test one", "Spec"); server.seed_entry_full( "run-1", "t1", "HTTP", "GET /a", "PASSED", "", "", "", "", "", "trace-t1", ); server.seed_span("run-1", "trace-t1", "s-t1", "", "GET /a", "svc"); server.end_test("run-1", "t1", 100); server.seed_test("run-1", "t2", "test two", "Spec"); server.seed_entry_full( "run-1", "t2", "HTTP", "GET /b", "PASSED", "", "", "", "", "", "trace-t2", ); server.seed_span("run-1", "trace-t2", "s-t2", "", "GET /b", "svc"); server.end_test("run-1", "t2", 100); server.end_run("run-1", 2, 0, 500); let t1_spans = server.get_json("/runs/run-1/tests/t1/spans").await; assert_eq!(t1_spans.as_array().unwrap().len(), 1); assert_eq!(t1_spans[0]["span_id"], "s-t1"); let t2_spans = server.get_json("/runs/run-1/tests/t2/spans").await; assert_eq!(t2_spans.as_array().unwrap().len(), 1); assert_eq!(t2_spans[0]["span_id"], "s-t2"); } // --------------------------------------------------------------------------- // GET /api/v1/runs/:run_id/tests/:test_id/snapshots // --------------------------------------------------------------------------- #[tokio::test] async fn get_snapshots_returns_snapshots_for_test() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests/test-1/snapshots").await; let snapshots = body.as_array().unwrap(); assert_eq!(snapshots.len(), 1); assert_eq!(snapshots[0]["system"], "Kafka"); assert_eq!(snapshots[0]["summary"], "3 consumed, 1 published"); assert_eq!( snapshots[0]["state_json"], r#"{"consumed":3,"published":1}"# ); } #[tokio::test] async fn get_snapshots_returns_empty_for_test_without_snapshots() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/runs/run-1/tests/test-2/snapshots").await; assert_eq!(body, Value::Array(vec![])); } #[tokio::test] async fn get_snapshots_returns_multiple_systems() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-snap", "snapshot test", "SnapshotSpec"); server.seed_snapshot( "run-1", "test-snap", "Kafka", r#"{"consumed":5,"published":2}"#, "5 consumed, 2 published", ); server.seed_snapshot( "run-1", "test-snap", "PostgreSQL", r#"{"rows_inserted":10}"#, "10 rows inserted", ); server.seed_snapshot( "run-1", "test-snap", "Redis", r#"{"keys_set":3}"#, "3 keys set", ); server.end_test("run-1", "test-snap", 300); let body = server .get_json("/runs/run-1/tests/test-snap/snapshots") .await; let snaps = body.as_array().unwrap(); assert_eq!(snaps.len(), 3); let systems: Vec<&str> = snaps .iter() .map(|s| s["system"].as_str().unwrap()) .collect(); assert!(systems.contains(&"Kafka")); assert!(systems.contains(&"PostgreSQL")); assert!(systems.contains(&"Redis")); } #[tokio::test] async fn get_snapshots_isolates_between_tests() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "t1", "test one", "Spec"); server.seed_snapshot("run-1", "t1", "Kafka", r#"{"consumed":1}"#, "1 consumed"); server.end_test("run-1", "t1", 100); server.seed_test("run-1", "t2", "test two", "Spec"); server.seed_snapshot("run-1", "t2", "Redis", r#"{"keys":5}"#, "5 keys"); server.end_test("run-1", "t2", 100); server.end_run("run-1", 2, 0, 500); let t1_snaps = server.get_json("/runs/run-1/tests/t1/snapshots").await; let t1_arr = t1_snaps.as_array().unwrap(); assert_eq!(t1_arr.len(), 1); assert_eq!(t1_arr[0]["system"], "Kafka"); let t2_snaps = server.get_json("/runs/run-1/tests/t2/snapshots").await; let t2_arr = t2_snaps.as_array().unwrap(); assert_eq!(t2_arr.len(), 1); assert_eq!(t2_arr[0]["system"], "Redis"); } #[tokio::test] async fn snapshot_state_json_preserves_complex_json() { let server = TestServer::start().await; server.seed_run("run-1", "my-app"); server.seed_test("run-1", "test-json", "json test", "Spec"); let complex_json = r#"{"messages":[{"topic":"orders","key":"k1","value":{"orderId":123}},{"topic":"orders","key":"k2","value":{"orderId":456}}],"count":2}"#; server.seed_snapshot("run-1", "test-json", "Kafka", complex_json, "2 messages"); server.end_test("run-1", "test-json", 100); let body = server .get_json("/runs/run-1/tests/test-json/snapshots") .await; let snaps = body.as_array().unwrap(); assert_eq!(snaps[0]["state_json"], complex_json); } // --------------------------------------------------------------------------- // GET /api/v1/traces/:trace_id // --------------------------------------------------------------------------- #[tokio::test] async fn get_trace_returns_all_spans_for_trace() { let server = TestServer::start().await; server.seed_full_run(); let body = server.get_json("/traces/trace-abc").await; let spans = body.as_array().unwrap(); assert_eq!(spans.len(), 2); assert_eq!(spans[0]["span_id"], "span-1"); assert!(spans[0]["parent_span_id"].is_null()); assert_eq!(spans[0]["service_name"], "product-api"); assert_eq!(spans[1]["span_id"], "span-2"); assert_eq!(spans[1]["parent_span_id"], "span-1"); assert_eq!(spans[1]["service_name"], "product-db"); } #[tokio::test] async fn get_trace_returns_empty_for_unknown_trace() { let server = TestServer::start().await; let body = server.get_json("/traces/nonexistent").await; assert_eq!(body, Value::Array(vec![])); } // --------------------------------------------------------------------------- // SSE endpoint // --------------------------------------------------------------------------- #[tokio::test] async fn sse_endpoint_returns_200_with_event_stream_content_type() { let server = TestServer::start().await; let resp = server.get("/events/stream").await; assert_eq!(resp.status(), StatusCode::OK); let content_type = resp .headers() .get("content-type") .unwrap() .to_str() .unwrap(); assert!( content_type.contains("text/event-stream"), "Expected text/event-stream, got: {content_type}", ); } #[tokio::test] async fn sse_stream_pushes_full_events_before_database_flush() { let server = TestServer::start().await; let service = Arc::new(DashboardEventServiceImpl::new_with_ingest_config( server.repo.clone(), server.sse.clone(), 50, Duration::from_secs(60), )); let mut resp = server.get("/events/stream").await; let mut buffer = String::new(); assert_eq!(resp.status(), StatusCode::OK); send_event( service.as_ref(), run_started_event_with_version("run-live-sse", "live-sse-app", "0.23.2", 1_704_067_200, 0), ) .await .unwrap(); send_event( service.as_ref(), test_started_event( "run-live-sse", "test-live", "streams before sqlite", "LiveSpec", 1_704_067_201, 0, ), ) .await .unwrap(); let first_event: Value = serde_json::from_str(&next_sse_data(&mut resp, &mut buffer).await.unwrap()).unwrap(); assert_eq!(first_event["seq"], 1); assert_eq!(first_event["run_id"], "run-live-sse"); assert_eq!(first_event["event_type"], "run_started"); assert_eq!(first_event["payload"]["app_name"], "live-sse-app"); assert_eq!(first_event["payload"]["stove_version"], "0.23.2"); let second_event: Value = serde_json::from_str(&next_sse_data(&mut resp, &mut buffer).await.unwrap()).unwrap(); assert_eq!(second_event["seq"], 2); assert_eq!(second_event["event_type"], "test_started"); assert_eq!(second_event["payload"]["test_id"], "test-live"); assert_eq!(second_event["payload"]["status"], "RUNNING"); let run_before_flush = server.get_json("/runs/run-live-sse").await; assert_eq!(run_before_flush, Value::Null); let tests_before_flush = server.get_json("/runs/run-live-sse/tests").await; assert_eq!(tests_before_flush, Value::Array(vec![])); flush_events(service.as_ref()).await; let run_after_flush = server.get_json("/runs/run-live-sse").await; assert_eq!(run_after_flush["status"], "RUNNING"); let tests_after_flush = server.get_json("/runs/run-live-sse/tests").await; let tests_after_flush = tests_after_flush.as_array().unwrap(); assert_eq!(tests_after_flush.len(), 1); assert_eq!(tests_after_flush[0]["id"], "test-live"); assert_eq!(tests_after_flush[0]["status"], "RUNNING"); } #[tokio::test] async fn sse_broadcast_data_is_readable_after_notification() { // Simulates the real-world SSE flow: when the frontend receives an SSE // event and immediately refetches, the data must already be in the DB. // // The broadcast must fire AFTER the DB write, not before. let server = TestServer::start().await; let mut rx = server.sse.subscribe(); // Seed a run via the repo so the FK is satisfied server.seed_run("run-sse", "sse-app"); // Seed a test via the repo (bypasses gRPC, but simulates the write) server.seed_test("run-sse", "t-1", "my test", "Spec"); // Broadcast an SSE event (simulates what process_event does after writing) server .sse .broadcast(r#"{"run_id":"run-sse","event_type":"test_started"}"#); // Subscriber receives the notification let msg = rx.try_recv().expect("should receive broadcast"); assert!(msg.contains("run-sse")); // Immediately refetch — data must be present (this is what the browser does) let body = server.get_json("/runs/run-sse/tests").await; let tests = body.as_array().unwrap(); assert_eq!( tests.len(), 1, "Data must be readable when SSE notification arrives" ); } #[tokio::test] async fn sse_stream_sends_keep_alive() { // Without keep-alive, browsers and proxies close idle SSE connections // after 30-90 seconds, causing the UI to stop updating during long tests. // // With keep-alive enabled, the server sends a comment (`: keep-alive`) // periodically. We verify by reading the first chunk with a timeout. let server = TestServer::start().await; let mut resp = server.get("/events/stream").await; assert_eq!(resp.status(), StatusCode::OK); // Read the first chunk — with keep-alive, the server should send a // comment within the interval (15s). Without it, this times out. let result = tokio::time::timeout(std::time::Duration::from_secs(20), resp.chunk()).await; assert!( result.is_ok(), "SSE stream should send a keep-alive comment within 20 seconds" ); let chunk = result.unwrap().unwrap(); assert!(chunk.is_some(), "Keep-alive chunk should not be empty"); } #[tokio::test] async fn sse_stream_delivers_interleaved_notifications_for_concurrent_test_load() { let server = TestServer::start().await; let service = Arc::new(DashboardEventServiceImpl::new( server.repo.clone(), server.sse.clone(), )); let mut resp = server.get("/events/stream").await; let mut buffer = String::new(); let entry_count_per_test = 80usize; assert_eq!(resp.status(), StatusCode::OK); send_event( service.as_ref(), run_started_event("run-sse-load", "sse-load-app", 1_704_067_400, 0), ) .await .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), test_started_event( "run-sse-load", "test-a", "handles checkout", "ConcurrentSpec", 1_704_067_401, 0, ), ) .await }, async move { send_event( service_b.as_ref(), test_started_event( "run-sse-load", "test-b", "handles payment", "ConcurrentSpec", 1_704_067_401, 1, ), ) .await }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { for index in 0..entry_count_per_test { send_event( service_a.as_ref(), entry_recorded_event( "run-sse-load", "test-a", &format!("GET /checkout/{index}"), "PASSED", "trace-a", 1_704_067_402 + index as i64, 0, ), ) .await?; } Ok::<(), tonic::Status>(()) }, async move { for index in 0..entry_count_per_test { send_event( service_b.as_ref(), entry_recorded_event( "run-sse-load", "test-b", &format!("POST /payment/{index}"), "FAILED", "trace-b", 1_704_067_402 + index as i64, 1, ), ) .await?; } Ok::<(), tonic::Status>(()) }, ) .unwrap(); let service_a = service.clone(); let service_b = service.clone(); tokio::try_join!( async move { send_event( service_a.as_ref(), test_ended_event( "run-sse-load", "test-a", "PASSED", 2_000, "", 1_704_067_500, 0, ), ) .await }, async move { send_event( service_b.as_ref(), test_ended_event( "run-sse-load", "test-b", "FAILED", 2_200, "payment timeout", 1_704_067_500, 1, ), ) .await }, ) .unwrap(); send_event( service.as_ref(), run_ended_event("run-sse-load", 2, 1, 1, 4_200, 1_704_067_501, 0), ) .await .unwrap(); let expected_events = 1 + 2 + (entry_count_per_test * 2) + 2 + 1; let mut event_counts: HashMap = HashMap::new(); for _ in 0..expected_events { let payload = next_sse_data(&mut resp, &mut buffer).await.unwrap(); let event: Value = serde_json::from_str(&payload).unwrap(); assert_eq!(event["run_id"], "run-sse-load"); let event_type = event["event_type"] .as_str() .expect("event_type should be present") .to_string(); *event_counts.entry(event_type).or_default() += 1; } assert_eq!(event_counts.get("run_started"), Some(&1)); assert_eq!(event_counts.get("test_started"), Some(&2)); assert_eq!( event_counts.get("entry_recorded"), Some(&(entry_count_per_test * 2)) ); assert_eq!(event_counts.get("test_ended"), Some(&2)); assert_eq!(event_counts.get("run_ended"), Some(&1)); flush_events(service.as_ref()).await; let tests = server.get_json("/runs/run-sse-load/tests").await; let tests = tests.as_array().unwrap(); assert_eq!(tests.len(), 2); let test_a = tests.iter().find(|test| test["id"] == "test-a").unwrap(); assert_eq!(test_a["status"], "PASSED"); let test_b = tests.iter().find(|test| test["id"] == "test-b").unwrap(); assert_eq!(test_b["status"], "FAILED"); let entries_a = server .get_json("/runs/run-sse-load/tests/test-a/entries") .await; assert_eq!(entries_a.as_array().unwrap().len(), entry_count_per_test); let entries_b = server .get_json("/runs/run-sse-load/tests/test-b/entries") .await; assert_eq!(entries_b.as_array().unwrap().len(), entry_count_per_test); } // --------------------------------------------------------------------------- // CORS headers // --------------------------------------------------------------------------- #[tokio::test] async fn cors_headers_are_present() { let server = TestServer::start().await; let resp = server.get("/apps").await; assert!( resp.headers().contains_key("access-control-allow-origin"), "CORS header should be present" ); } // --------------------------------------------------------------------------- // Running status (in-progress run) // --------------------------------------------------------------------------- #[tokio::test] async fn in_progress_run_has_running_status() { let server = TestServer::start().await; server.seed_run_at("run-live", "live-api", "2024-06-01T12:00:00Z", &["HTTP"]); let body = server.get_json("/runs/run-live").await; assert_eq!(body["status"], "RUNNING"); assert!(body["ended_at"].is_null()); assert!(body["duration_ms"].is_null()); let apps = server.get_json("/apps").await; assert_eq!(apps[0]["latest_status"], "RUNNING"); } // --------------------------------------------------------------------------- // SPA fallback // --------------------------------------------------------------------------- #[tokio::test] async fn spa_fallback_serves_index_html_for_unknown_paths() { let server = TestServer::start().await; let resp = server .client .get(format!("{}/some/frontend/route", server.base_url)) .send() .await .unwrap(); assert_ne!( resp.status(), StatusCode::METHOD_NOT_ALLOWED, "Should not return 405 for SPA routes" ); } #[tokio::test] async fn missing_spa_assets_return_404_instead_of_index_html() { let server = TestServer::start().await; let resp = server.get("/assets/does-not-exist.js").await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // --------------------------------------------------------------------------- // Multiple runs (ordering and latest-run logic) // --------------------------------------------------------------------------- #[tokio::test] async fn runs_are_ordered_by_started_at_desc() { let server = TestServer::start().await; server.seed_run_at("run-old", "my-app", "2024-01-01T00:00:00Z", &[]); server.seed_run_at("run-new", "my-app", "2024-06-01T00:00:00Z", &[]); let body = server.get_json("/runs?app=my-app").await; let runs = body.as_array().unwrap(); assert_eq!(runs.len(), 2); assert_eq!(runs[0]["id"], "run-new"); assert_eq!(runs[1]["id"], "run-old"); } #[tokio::test] async fn runs_with_same_started_at_use_latest_inserted_as_tie_breaker() { let server = TestServer::start().await; server.seed_run_at("run-1", "my-app", "2024-06-01T00:00:00Z", &[]); server.seed_run_at("run-2", "my-app", "2024-06-01T00:00:00Z", &[]); let body = server.get_json("/runs?app=my-app").await; let runs = body.as_array().unwrap(); assert_eq!(runs.len(), 2); assert_eq!(runs[0]["id"], "run-2"); assert_eq!(runs[1]["id"], "run-1"); } #[tokio::test] async fn apps_returns_latest_run_id_for_multi_run_app() { let server = TestServer::start().await; server.seed_run_at("run-1", "my-app", "2024-01-01T00:00:00Z", &[]); server.seed_run_at("run-2", "my-app", "2024-06-01T00:00:00Z", &[]); let body = server.get_json("/apps").await; let apps = body.as_array().unwrap(); assert_eq!(apps.len(), 1); assert_eq!(apps[0]["latest_run_id"], "run-2"); assert_eq!(apps[0]["total_runs"], 2); } #[tokio::test] async fn apps_does_not_duplicate_app_when_latest_runs_share_same_timestamp() { let server = TestServer::start().await; server.seed_run_at("run-1", "my-app", "2024-06-01T00:00:00Z", &[]); server.seed_run_at("run-2", "my-app", "2024-06-01T00:00:00Z", &[]); let body = server.get_json("/apps").await; let apps = body.as_array().unwrap(); assert_eq!( apps.len(), 1, "same app should appear only once in the sidebar" ); assert_eq!(apps[0]["latest_run_id"], "run-2"); assert_eq!(apps[0]["total_runs"], 2); } ================================================ FILE: tools/stove-cli/tests/common/mod.rs ================================================ //! Shared test infrastructure for e2e tests. //! //! Provides `TestServer` (a real axum server on a random port with in-memory SQLite) //! and ergonomic seed helpers that hide `.unwrap()` noise and struct boilerplate. #![allow(dead_code)] use std::sync::Arc; use serde_json::Value; use stove::http::server::create_router; use stove::http::server::create_router_with_ingestor; use stove::ingest::EventIngestor; use stove::sse::manager::SseManager; use stove::storage::database::Database; use stove::storage::models::{NewEntry, NewSpan}; use stove::storage::repository::Repository; /// A running test server with its base URL and repository handle. pub struct TestServer { pub base_url: String, pub repo: Arc, pub sse: Arc, pub client: reqwest::Client, } impl TestServer { /// Start a test server on an OS-assigned port with an in-memory database. pub async fn start() -> Self { let db = Database::open(":memory:").expect("in-memory database should open"); let repo = Arc::new(Repository::new(db)); let sse_manager = Arc::new(SseManager::new()); let router = create_router(repo.clone(), sse_manager.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("should bind to a free port"); let port = listener.local_addr().unwrap().port(); let base_url = format!("http://127.0.0.1:{port}"); tokio::spawn(async move { axum::serve( listener, router.into_make_service_with_connect_info::(), ) .await .unwrap(); }); Self { base_url, repo, sse: sse_manager, client: reqwest::Client::new(), } } /// Start a test server that shares an ingest queue with callers. pub async fn start_with_ingestor() -> (Self, EventIngestor) { let db = Database::open(":memory:").expect("in-memory database should open"); let repo = Arc::new(Repository::new(db)); let sse_manager = Arc::new(SseManager::new()); let ingestor = EventIngestor::with_config(repo.clone(), 50, std::time::Duration::from_secs(60)); let router = create_router_with_ingestor(repo.clone(), sse_manager.clone(), Some(ingestor.clone())); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("should bind to a free port"); let port = listener.local_addr().unwrap().port(); let base_url = format!("http://127.0.0.1:{port}"); tokio::spawn(async move { axum::serve( listener, router.into_make_service_with_connect_info::(), ) .await .unwrap(); }); ( Self { base_url, repo, sse: sse_manager, client: reqwest::Client::new(), }, ingestor, ) } // ── HTTP helpers ────────────────────────────────────────────────── pub fn url(&self, path: &str) -> String { format!("{}/api/v1{path}", self.base_url) } pub fn mcp_url(&self) -> String { format!("{}/mcp", self.base_url) } pub async fn get(&self, path: &str) -> reqwest::Response { self .client .get(self.url(path)) .send() .await .expect("request should succeed") } pub async fn get_json(&self, path: &str) -> Value { self .get(path) .await .json::() .await .expect("response should be valid JSON") } // ── Seed helpers ────────────────────────────────────────────────── // // Each helper wraps a repository call with sensible defaults and panics // on failure (tests should never hit DB errors with in-memory SQLite). /// Start a run (status = RUNNING until `end_run` is called). pub fn seed_run(&self, run_id: &str, app_name: &str) { self.seed_run_at(run_id, app_name, "2024-06-01T10:00:00Z", &[]); } pub fn seed_run_with_version(&self, run_id: &str, app_name: &str, stove_version: &str) { self.seed_run_at_with_version( run_id, app_name, "2024-06-01T10:00:00Z", Some(stove_version), &[], ); } /// Start a run with explicit timestamp and systems. pub fn seed_run_at(&self, run_id: &str, app_name: &str, started_at: &str, systems: &[&str]) { self.seed_run_at_with_version(run_id, app_name, started_at, None, systems); } pub fn seed_run_at_with_version( &self, run_id: &str, app_name: &str, started_at: &str, stove_version: Option<&str>, systems: &[&str], ) { let systems: Vec = systems.iter().map(|s| (*s).to_string()).collect(); self .repo .save_run_start_with_version(run_id, app_name, started_at, stove_version, &systems) .unwrap(); } /// End a run with stats. pub fn end_run(&self, run_id: &str, passed: i32, failed: i32, duration_ms: i64) { self .repo .save_run_end( run_id, "2024-06-01T10:00:10Z", passed + failed, passed, failed, duration_ms, ) .unwrap(); } /// Start a test within a run. pub fn seed_test(&self, run_id: &str, test_id: &str, name: &str, spec: &str) { self .repo .save_test_start(run_id, test_id, name, spec, &[], "2024-06-01T10:00:01Z") .unwrap(); } /// End a test (pass). For failures, use `end_test_failed`. pub fn end_test(&self, run_id: &str, test_id: &str, duration_ms: i64) { self .repo .save_test_end( run_id, test_id, "PASSED", duration_ms, "", "2024-06-01T10:00:03Z", ) .unwrap(); } /// End a test with FAILED status and an error message. pub fn end_test_failed(&self, run_id: &str, test_id: &str, duration_ms: i64, error: &str) { self .repo .save_test_end( run_id, test_id, "FAILED", duration_ms, error, "2024-06-01T10:00:05Z", ) .unwrap(); } /// Save a test entry with only the important fields; the rest default to empty. pub fn seed_entry(&self, run_id: &str, test_id: &str, system: &str, action: &str, result: &str) { self.seed_entry_full( run_id, test_id, system, action, result, "", "", "", "", "", "", ); } /// Save a test entry with all fields specified. #[allow(clippy::too_many_arguments)] pub fn seed_entry_full( &self, run_id: &str, test_id: &str, system: &str, action: &str, result: &str, input: &str, output: &str, expected: &str, actual: &str, error: &str, trace_id: &str, ) { self .repo .save_entry(&NewEntry { run_id: run_id.into(), test_id: test_id.into(), timestamp: "2024-06-01T10:00:02Z".into(), system: system.into(), action: action.into(), result: result.into(), input: input.into(), output: output.into(), metadata: "{}".into(), expected: expected.into(), actual: actual.into(), error: error.into(), trace_id: trace_id.into(), }) .unwrap(); } /// Save a span with only the key fields; the rest default to empty/zero. pub fn seed_span( &self, run_id: &str, trace_id: &str, span_id: &str, parent_span_id: &str, operation: &str, service: &str, ) { self .repo .save_span(&NewSpan { run_id: run_id.into(), trace_id: trace_id.into(), span_id: span_id.into(), parent_span_id: parent_span_id.into(), operation_name: operation.into(), service_name: service.into(), start_time_nanos: 1_000_000_000, end_time_nanos: 1_250_000_000, status: "OK".into(), attributes: "{}".into(), ..Default::default() }) .unwrap(); } /// Save a span with explicit timing (for ordering assertions). #[allow(clippy::too_many_arguments)] pub fn seed_span_timed( &self, run_id: &str, trace_id: &str, span_id: &str, parent_span_id: &str, operation: &str, service: &str, start_nanos: i64, end_nanos: i64, attributes: &str, ) { self .repo .save_span(&NewSpan { run_id: run_id.into(), trace_id: trace_id.into(), span_id: span_id.into(), parent_span_id: parent_span_id.into(), operation_name: operation.into(), service_name: service.into(), start_time_nanos: start_nanos, end_time_nanos: end_nanos, status: "OK".into(), attributes: attributes.into(), ..Default::default() }) .unwrap(); } /// Save a span with exception details. #[allow(clippy::too_many_arguments)] pub fn seed_span_with_exception( &self, run_id: &str, trace_id: &str, span_id: &str, parent_span_id: &str, operation: &str, service: &str, status: &str, exception_type: &str, exception_message: &str, exception_stack_trace: &str, ) { self .repo .save_span(&NewSpan { run_id: run_id.into(), trace_id: trace_id.into(), span_id: span_id.into(), parent_span_id: parent_span_id.into(), operation_name: operation.into(), service_name: service.into(), start_time_nanos: 1_000_000_000, end_time_nanos: 1_250_000_000, status: status.into(), attributes: "{}".into(), exception_type: exception_type.into(), exception_message: exception_message.into(), exception_stack_trace: exception_stack_trace.into(), }) .unwrap(); } /// Save a snapshot for a test. pub fn seed_snapshot( &self, run_id: &str, test_id: &str, system: &str, state_json: &str, summary: &str, ) { self .repo .save_snapshot(run_id, test_id, system, state_json, summary) .unwrap(); } /// Seed a complete run with one passing test and one failing test. /// /// Creates: run-1 (product-api), test-1 (PASSED), test-2 (FAILED), /// entries for both, 2 spans with trace-abc, a Kafka snapshot on test-1. pub fn seed_full_run(&self) { self.seed_run_at( "run-1", "product-api", "2024-06-01T10:00:00Z", &["HTTP", "Kafka", "PostgreSQL"], ); // Test 1: passing self.seed_test("run-1", "test-1", "should create product", "ProductSpec"); self.seed_entry_full( "run-1", "test-1", "HTTP", "POST /products", "PASSED", r#"{"name":"widget"}"#, r#"{"id":42}"#, "", "", "", "trace-abc", ); self.seed_span_timed( "run-1", "trace-abc", "span-1", "", "POST /products", "product-api", 1_000_000_000, 1_250_000_000, r#"{"http.method":"POST","http.status_code":"201"}"#, ); self.seed_span_timed( "run-1", "trace-abc", "span-2", "span-1", "INSERT INTO products", "product-db", 1_050_000_000, 1_200_000_000, r#"{"db.system":"postgresql"}"#, ); self.seed_snapshot( "run-1", "test-1", "Kafka", r#"{"consumed":3,"published":1}"#, "3 consumed, 1 published", ); self.end_test("run-1", "test-1", 1500); // Test 2: failing self.seed_test("run-1", "test-2", "should reject duplicate", "ProductSpec"); self.seed_entry_full( "run-1", "test-2", "HTTP", "POST /products", "FAILED", r#"{"name":"widget"}"#, "", "409 Conflict", "201 Created", "Expected conflict but got success", "", ); self.end_test_failed("run-1", "test-2", 800, "Expected conflict but got success"); // End run self.end_run("run-1", 1, 1, 10000); } } ================================================ FILE: tools/stove-cli/tests/mcp_e2e.rs ================================================ //! End-to-end tests for the Stove MCP endpoint. mod common; use common::TestServer; use reqwest::StatusCode; use serde_json::{Value, json}; use stove::grpc::service::DashboardEventServiceImpl; use stove::proto; use stove::proto::dashboard_event_service_server::DashboardEventService; use tonic::Request; async fn mcp_call(server: &TestServer, method: &str, params: Value) -> Value { server .client .post(server.mcp_url()) .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, })) .send() .await .expect("MCP request should succeed") .json::() .await .expect("MCP response should be valid JSON") } async fn mcp_tool(server: &TestServer, name: &str, arguments: Value) -> Value { mcp_call( server, "tools/call", json!({ "name": name, "arguments": arguments, }), ) .await } #[tokio::test] async fn mcp_lists_tools_and_initializes() { let server = TestServer::start().await; let initialized = mcp_call( &server, "initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1" } }), ) .await; let tools = mcp_call(&server, "tools/list", json!({})).await; assert_eq!(initialized["result"]["serverInfo"]["name"], "stove"); assert_eq!(tools["result"]["tools"][0]["name"], "stove_apps"); assert!( tools["result"]["tools"] .as_array() .unwrap() .iter() .any(|tool| tool["name"] == "stove_failure_detail") ); } #[tokio::test] async fn failures_are_grouped_by_app_and_run_with_exact_detail_calls() { let server = TestServer::start().await; seed_multi_app_failures(&server); let response = mcp_tool(&server, "stove_failures", json!({ "limit": 10 })).await; let groups = response["result"]["structuredContent"]["groups"] .as_array() .unwrap(); assert_eq!(groups.len(), 3); assert_eq!(groups[0]["app_name"], "checkout-api"); assert_eq!(groups[0]["failures"][0]["run_id"], "run-checkout-2"); assert_eq!( groups[0]["failures"][0]["detail_tool_call"]["arguments"]["test_id"], "duplicate-name" ); assert_eq!(groups[1]["app_name"], "catalog-api"); assert_eq!(groups[2]["app_name"], "checkout-api"); } #[tokio::test] async fn failure_detail_includes_timeline_trace_and_snapshot_summaries() { let server = TestServer::start().await; server.seed_run_at( "run-1", "checkout-api", "2024-06-01T10:00:00Z", &["HTTP", "Kafka"], ); server.seed_test("run-1", "test-1", "declines expired card", "CheckoutSpec"); server.seed_entry_full( "run-1", "test-1", "HTTP", "POST /checkout", "PASSED", r#"{"card":"expired","authorization":"secret"}"#, r#"{"status":"PENDING"}"#, "", "", "", "trace-1", ); server.seed_entry_full( "run-1", "test-1", "Kafka", "should publish OrderRejected", "FAILED", r#"{"authorization":"secret"}"#, "", "OrderRejected", "nothing", "Expected rejection event", "trace-1", ); server.seed_span_timed( "run-1", "trace-1", "span-1", "", "POST /checkout", "checkout-api", 1_000, 2_000, r#"{"x-stove-test-id":"test-1"}"#, ); server.seed_span_with_exception( "run-1", "trace-1", "span-2", "span-1", "PaymentClient.authorize", "checkout-api", "ERROR", "PaymentDeclinedException", "expired card", "stack line 1\nstack line 2", ); server.seed_snapshot( "run-1", "test-1", "Kafka", r#"{"published":[],"failed":[{"topic":"orders","token":"secret"}]}"#, "Published: 0\nFailed: 1", ); server.end_test_failed("run-1", "test-1", 800, "Expected rejection event"); server.end_run("run-1", 0, 1, 900); let response = mcp_tool( &server, "stove_failure_detail", json!({ "run_id": "run-1", "test_id": "test-1" }), ) .await; let content = &response["result"]["structuredContent"]; assert_eq!(content["app_name"], "checkout-api"); assert_eq!(content["timeline_summary"]["failed_entries"], 1); assert_eq!(content["trace_summary"]["trace_status"], "correlated"); assert_eq!(content["trace_summary"]["exception_spans"], 1); assert_eq!(content["snapshot_summaries"][0]["system"], "Kafka"); assert_eq!( content["failed_entries"][0]["input"]["authorization"], "[REDACTED]" ); } #[tokio::test] async fn mcp_handles_no_failures_and_caps_oversized_detail() { let server = TestServer::start().await; server.seed_run("run-pass", "checkout-api"); server.seed_test("run-pass", "test-pass", "happy path", "CheckoutSpec"); server.end_test("run-pass", "test-pass", 100); server.end_run("run-pass", 1, 0, 100); let no_failures = mcp_tool(&server, "stove_failures", json!({ "run_id": "run-pass" })).await; let no_failure_content = &no_failures["result"]["structuredContent"]; assert_eq!(no_failure_content["failure_count"], 0); assert_eq!(no_failure_content["groups"].as_array().unwrap().len(), 0); server.seed_run("run-big", "checkout-api"); server.seed_test("run-big", "test-big", "large payload", "CheckoutSpec"); let oversized = format!(r#"{{"payload":"{}"}}"#, "x".repeat(400)); server.seed_entry_full( "run-big", "test-big", "HTTP", "POST /checkout", "FAILED", &oversized, "", "", "", "large failure", "", ); for index in 0..5 { server.seed_snapshot( "run-big", "test-big", "Kafka", r#"{"published":[]}"#, &format!("snapshot {index}"), ); } server.end_test_failed("run-big", "test-big", 200, "large failure"); server.end_run("run-big", 0, 1, 200); let detail = mcp_tool( &server, "stove_failure_detail", json!({ "run_id": "run-big", "test_id": "test-big", "budget": "tiny", "max_chars": 120 }), ) .await; let detail_content = &detail["result"]["structuredContent"]; assert!( detail_content["failed_entries"][0]["input"]["payload"] .as_str() .unwrap() .contains(" Option { Some(prost_types::Timestamp { seconds: 1_704_067_200, nanos: 0, }) } fn run_started(run_id: &str, app_name: &str) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::RunStarted( proto::RunStartedEvent { timestamp: timestamp(), app_name: app_name.to_string(), systems: vec!["HTTP".to_string()], stove_version: "0.23.2".to_string(), }, )), } } fn test_started(run_id: &str, test_id: &str) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::TestStarted( proto::TestStartedEvent { test_id: test_id.to_string(), test_name: "pending failure".to_string(), spec_name: "PendingSpec".to_string(), timestamp: timestamp(), test_path: vec!["pending".to_string()], }, )), } } fn test_ended_failed(run_id: &str, test_id: &str) -> proto::DashboardEvent { proto::DashboardEvent { run_id: run_id.to_string(), event: Some(proto::dashboard_event::Event::TestEnded( proto::TestEndedEvent { test_id: test_id.to_string(), status: "FAILED".to_string(), duration_ms: 42, error: "pending failure".to_string(), timestamp: timestamp(), }, )), } }