diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..8f760711 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +COMPOSE_PATH_SEPARATOR=: +COMPOSE_FILE=postgresql-compose.yml:docker-compose.yml + +SENDGRID_API_KEY=YOUR_API_KEY diff --git a/.gitignore b/.gitignore index 15f52922..19676fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,6 @@ yarn-error.log* pnpm-debug.log* # environment variables -.env .env.production # macOS-specific files diff --git a/README.md b/README.md index b486f90d..9b0459bb 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,14 @@
-![Astro Badge](https://img.shields.io/badge/Astro-BC52EE?logo=astro&logoColor=fff&style=flat) -![Typescript Badge](https://img.shields.io/badge/Typescript-007ACC?logo=typescript&logoColor=fff&style=flat) [![Deploy to Main Stage 💫](https://github.com/yacosta738/lyra/actions/workflows/deploy-main-stage.yml/badge.svg)](https://github.com/yacosta738/lyra/actions/workflows/deploy-main-stage.yml) +[![codecov](https://codecov.io/gh/yacosta738/lyra/graph/badge.svg?token=4MN8TEOGD9)](https://codecov.io/gh/yacosta738/lyra) ![GitHub stars](https://img.shields.io/github/stars/yacosta738/lyra) ![GitHub issues](https://img.shields.io/github/issues/yacosta738/lyra) ![GitHub forks](https://img.shields.io/github/forks/yacosta738/lyra) ![GitHub PRs](https://img.shields.io/github/issues-pr/yacosta738/lyra) +![Astro Badge](https://img.shields.io/badge/Astro-BC52EE?logo=astro&logoColor=fff&style=flat) +![Typescript Badge](https://img.shields.io/badge/Typescript-007ACC?logo=typescript&logoColor=fff&style=flat) ![Tailwind CSS Badge](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff&style=flat) ![Spring Boot Badge](https://img.shields.io/badge/Spring%20Boot-6DB33F?logo=springboot&logoColor=fff&style=flat) ![Kotlin Badge](https://img.shields.io/badge/Kotlin-0095D5?logo=kotlin&logoColor=fff&style=flat) diff --git a/apps/backend/backend.gradle.kts b/apps/backend/backend.gradle.kts index d4aa5c17..2e3161a2 100644 --- a/apps/backend/backend.gradle.kts +++ b/apps/backend/backend.gradle.kts @@ -24,6 +24,23 @@ repositories { dependencies { // L O C A L D E P E N D E N C I E S implementation(project(":shared:common")) + implementation(project(":shared:spring-boot-common")) + + implementation(libs.spring.boot.starter.data.r2dbc) + implementation("org.springframework:spring-r2dbc") + implementation("org.springframework.data:spring-data-r2dbc") + implementation("org.postgresql:r2dbc-postgresql") + + implementation(libs.spring.dotenv) + implementation(libs.sendgrid) + + testImplementation(libs.faker) + testImplementation(libs.mockk) + + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:r2dbc") } tasks.register("processFrontendResources") { diff --git a/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt b/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt index 84709a43..68d98d96 100644 --- a/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt +++ b/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt @@ -1,6 +1,7 @@ package com.lyra.app import com.lyra.common.domain.Service +import com.lyra.spring.boot.bus.event.EventConfiguration import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication @@ -9,7 +10,13 @@ import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.FilterType @SpringBootApplication -@ComponentScan(includeFilters = [ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Service::class])]) +@ComponentScan( + basePackages = ["com.lyra"], + basePackageClasses = [ + EventConfiguration::class, + ], + includeFilters = [ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Service::class])], +) class LyraApplication private val log: Logger = LoggerFactory.getLogger(LyraApplication::class.java) diff --git a/apps/backend/src/main/kotlin/com/lyra/app/config/db/DatabaseConfig.kt b/apps/backend/src/main/kotlin/com/lyra/app/config/db/DatabaseConfig.kt new file mode 100644 index 00000000..0f1cb125 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/config/db/DatabaseConfig.kt @@ -0,0 +1,61 @@ +package com.lyra.app.config.db + +import com.lyra.app.newsletter.domain.SubscriberStatus +import com.lyra.app.newsletter.infrastructure.persistence.converter.SubscriberConverter +import com.lyra.app.newsletter.infrastructure.persistence.converter.SubscriberStatusWriterConverter +import io.r2dbc.postgresql.codec.EnumCodec +import io.r2dbc.postgresql.codec.EnumCodec.Builder.RegistrationPriority +import io.r2dbc.postgresql.extension.CodecRegistrar +import io.r2dbc.spi.ConnectionFactoryOptions +import io.r2dbc.spi.Option +import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.convert.CustomConversions +import org.springframework.data.r2dbc.config.EnableR2dbcAuditing +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions +import org.springframework.data.r2dbc.dialect.DialectResolver +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.transaction.annotation.EnableTransactionManagement + +@Configuration +@EnableTransactionManagement +@EnableR2dbcRepositories +@EnableR2dbcAuditing +class DatabaseConfig { + /** + * Use the customizer to add EnumCodec to R2DBC + */ + @Bean + fun connectionFactoryOptionsBuilderCustomizer(): ConnectionFactoryOptionsBuilderCustomizer { + return ConnectionFactoryOptionsBuilderCustomizer { builder: ConnectionFactoryOptions.Builder -> + builder.option( + Option.valueOf("extensions"), + listOf( + EnumCodec.builder() + .withEnum("subscriber_status", SubscriberStatus::class.java) + .withRegistrationPriority(RegistrationPriority.FIRST) + .build(), + ), + ) + } + } + + /** + * Register converter to make sure Spring data treat enum correctly + */ + @Bean + fun r2dbcCustomConversions(databaseClient: DatabaseClient): R2dbcCustomConversions { + val dialect = DialectResolver.getDialect(databaseClient.connectionFactory) + val converters: MutableList = ArrayList(dialect.converters) + converters.addAll(R2dbcCustomConversions.STORE_CONVERTERS) + return R2dbcCustomConversions( + CustomConversions.StoreConversions.of(dialect.simpleTypeHolder, converters), + listOf( + SubscriberConverter(), + SubscriberStatusWriterConverter(), + ), + ) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/controllers/GlobalExceptionHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/controllers/GlobalExceptionHandler.kt new file mode 100644 index 00000000..92a0b34a --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/controllers/GlobalExceptionHandler.kt @@ -0,0 +1,70 @@ +package com.lyra.app.controllers + +import com.lyra.common.domain.error.BusinessRuleValidationException +import java.net.URI +import java.time.Instant +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler + +private const val ERROR_PAGE = "https://lyra.io/errors" + +/** + * This class provides a global exception handling mechanism for the application. + * + * It extends the [ResponseEntityExceptionHandler] class to handle exceptions and return appropriate responses. + * + * @created 4/8/23 + */ +@RestControllerAdvice +class GlobalExceptionHandler : ResponseEntityExceptionHandler() { + /** + * Handles the [UserAuthenticationException] by creating a ProblemDetail object with the appropriate status, + * detail and properties. + * + * @param e The UserAuthenticationException that was thrown. + * @return The ProblemDetail object representing the exception. + */ +// @ResponseStatus(HttpStatus.UNAUTHORIZED) +// @ExceptionHandler(UserAuthenticationException::class) +// fun handleUserAuthenticationException(e: UserAuthenticationException): ProblemDetail { +// val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.message) +// problemDetail.title = "User authentication failed" +// problemDetail.setType(URI.create("$ERROR_PAGE/user-authentication-failed")) +// problemDetail.setProperty("errorCategory", "AUTHENTICATION") +// problemDetail.setProperty("timestamp", Instant.now()) +// return problemDetail +// } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler( + IllegalArgumentException::class, + BusinessRuleValidationException::class, +// UserRefreshTokenException::class + ) + fun handleIllegalArgumentException(e: Exception): ProblemDetail { + val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.message ?: "Bad request") + problemDetail.title = "Bad request" + problemDetail.setType(URI.create("$ERROR_PAGE/bad-request")) + problemDetail.setProperty("errorCategory", "BAD_REQUEST") + problemDetail.setProperty("timestamp", Instant.now()) + return problemDetail + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ProblemDetail { + val problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + e.message ?: "Internal server error", + ) + problemDetail.title = "Internal server error" + problemDetail.setType(URI.create("$ERROR_PAGE/internal-server-error")) + problemDetail.setProperty("errorCategory", "INTERNAL_SERVER_ERROR") + problemDetail.setProperty("timestamp", Instant.now()) + return problemDetail + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandler.kt new file mode 100644 index 00000000..86a3eedd --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandler.kt @@ -0,0 +1,21 @@ +package com.lyra.app.newsletter.application + +import com.lyra.app.newsletter.domain.FirstName +import com.lyra.app.newsletter.domain.LastName +import com.lyra.app.newsletter.domain.Name +import com.lyra.app.newsletter.domain.SubscriberId +import com.lyra.common.domain.Service +import com.lyra.common.domain.bus.command.CommandHandler +import com.lyra.common.domain.email.Email + +@Service +class CreateSubscribeNewsletterCommandHandler( + private val subscriberRegistrator: SubscriberRegistrator +) : CommandHandler { + override suspend fun handle(command: SubscribeNewsletterCommand) { + val id = SubscriberId(command.id) + val email = Email(command.email) + val name = Name(FirstName(command.firstname), command.lastname?.let { LastName(it) }) + subscriberRegistrator.register(id, email, name) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribeNewsletterCommand.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribeNewsletterCommand.kt new file mode 100644 index 00000000..f9b0b72f --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribeNewsletterCommand.kt @@ -0,0 +1,14 @@ +package com.lyra.app.newsletter.application + +import com.lyra.common.domain.bus.command.Command + +/** + * + * @created 7/1/24 + */ +data class SubscribeNewsletterCommand( + val id: String, + val email: String, + val firstname: String, + val lastname: String? +) : Command diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscriberRegistrator.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscriberRegistrator.kt new file mode 100644 index 00000000..fd488b9f --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscriberRegistrator.kt @@ -0,0 +1,45 @@ +package com.lyra.app.newsletter.application + +import com.lyra.app.newsletter.domain.Name +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberId +import com.lyra.app.newsletter.domain.SubscriberRepository +import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent +import com.lyra.common.domain.Service +import com.lyra.common.domain.bus.event.EventBroadcaster +import com.lyra.common.domain.bus.event.EventPublisher +import com.lyra.common.domain.email.Email +import org.slf4j.LoggerFactory + +/** + * + * @created 6/1/24 + */ +@Service +class SubscriberRegistrator( + private val subscriberRepository: SubscriberRepository, + eventPublisher: EventPublisher +) { + private val eventPublisher = EventBroadcaster() + + init { + this.eventPublisher.use(eventPublisher) + } + + suspend fun register(id: SubscriberId, email: Email, name: Name) { + log.info("Registering subscriber with email: $email") + + val subscriber = Subscriber.create(id, email, name) + subscriberRepository.create(subscriber) + val domainEvents = subscriber.pullDomainEvents() + log.debug("Pulling {} events from subscriber", domainEvents.size) + + domainEvents.forEach { + eventPublisher.publish(it as SubscriberCreatedEvent) + } + } + + companion object { + private val log = LoggerFactory.getLogger(SubscriberRegistrator::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribersResponse.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribersResponse.kt new file mode 100644 index 00000000..574a953b --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribersResponse.kt @@ -0,0 +1,12 @@ +package com.lyra.app.newsletter.application + +import com.lyra.common.domain.bus.query.Response + +data class SubscribersResponse(val subscribers: List) : Response + +data class SubscriberResponse( + val id: String, + val email: String, + val name: String, + val status: String, +) : Response diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/ActiveSubscriberSearcher.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/ActiveSubscriberSearcher.kt new file mode 100644 index 00000000..286fb42a --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/ActiveSubscriberSearcher.kt @@ -0,0 +1,37 @@ +package com.lyra.app.newsletter.application.search.active + +import com.lyra.app.newsletter.application.SubscriberResponse +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberRepository +import com.lyra.common.domain.Service +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.slf4j.LoggerFactory + +/** + * + * @created 10/1/24 + */ +@Service +class ActiveSubscriberSearcher(private val repository: SubscriberRepository) { + + suspend fun search(): SubscribersResponse { + log.info("Searching active subscribers") + val subscribers: Flow = repository.searchActive() + return SubscribersResponse( + subscribers.map { + SubscriberResponse( + it.id.toString(), + it.email.value, + it.name.fullName(), + it.status.name, + ) + }.toList(), + ) + } + companion object { + private val log = LoggerFactory.getLogger(ActiveSubscriberSearcher::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQuery.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQuery.kt new file mode 100644 index 00000000..d2574321 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQuery.kt @@ -0,0 +1,18 @@ +package com.lyra.app.newsletter.application.search.active + +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.common.domain.bus.query.Query + +/** + * + * @created 9/1/24 + */ +class SearchAllActiveSubscribersQuery : Query { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SearchAllActiveSubscribersQuery) return false + return true + } + + override fun hashCode(): Int = javaClass.hashCode() +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandler.kt new file mode 100644 index 00000000..2e5f4ae2 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandler.kt @@ -0,0 +1,23 @@ +package com.lyra.app.newsletter.application.search.active + +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.common.domain.Service +import com.lyra.common.domain.bus.query.QueryHandler +import org.slf4j.LoggerFactory + +/** + * + * @created 9/1/24 + */ +@Service +class SearchAllActiveSubscribersQueryHandler( + private val searcher: ActiveSubscriberSearcher, +) : QueryHandler { + override suspend fun handle(query: SearchAllActiveSubscribersQuery): SubscribersResponse { + log.info("Searching all active subscribers") + return searcher.search() + } + companion object { + private val log = LoggerFactory.getLogger(SearchAllActiveSubscribersQueryHandler::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/AllSubscriberSearcher.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/AllSubscriberSearcher.kt new file mode 100644 index 00000000..bb48b79f --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/AllSubscriberSearcher.kt @@ -0,0 +1,33 @@ +package com.lyra.app.newsletter.application.search.all + +import com.lyra.app.newsletter.application.SubscriberResponse +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberRepository +import com.lyra.common.domain.Service +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.slf4j.LoggerFactory + +@Service +class AllSubscriberSearcher(private val repository: SubscriberRepository) { + suspend fun search(): SubscribersResponse { + log.info("Searching all subscribers") + val subscribers: Flow = repository.searchAll() + return SubscribersResponse( + subscribers.map { + SubscriberResponse( + it.id.toString(), + it.email.value, + it.name.fullName(), + it.status.name, + ) + }.toList(), + ) + } + + companion object { + private val log = LoggerFactory.getLogger(AllSubscriberSearcher::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQuery.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQuery.kt new file mode 100644 index 00000000..d1338082 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQuery.kt @@ -0,0 +1,18 @@ +package com.lyra.app.newsletter.application.search.all + +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.common.domain.bus.query.Query + +/** + * + * @created 9/1/24 + */ +class SearchAllSubscribersQuery : Query { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SearchAllSubscribersQuery) return false + return true + } + + override fun hashCode(): Int = javaClass.hashCode() +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandler.kt new file mode 100644 index 00000000..235903b2 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandler.kt @@ -0,0 +1,23 @@ +package com.lyra.app.newsletter.application.search.all + +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.common.domain.Service +import com.lyra.common.domain.bus.query.QueryHandler +import org.slf4j.LoggerFactory + +/** + * + * @created 9/1/24 + */ +@Service +class SearchAllSubscribersQueryHandler( + private val searcher: AllSubscriberSearcher, +) : QueryHandler { + override suspend fun handle(query: SearchAllSubscribersQuery): SubscribersResponse { + log.info("Searching all subscribers") + return searcher.search() + } + companion object { + private val log = LoggerFactory.getLogger(SearchAllSubscribersQueryHandler::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/FirstName.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/FirstName.kt new file mode 100644 index 00000000..68b7804e --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/FirstName.kt @@ -0,0 +1,36 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.newsletter.domain.exceptions.FirstNameNotValidException +import com.lyra.common.domain.BaseValidateValueObject + +private const val NAME_LEN = 150 + +/** + * Email value object + * @param firstname first name value + * @throws FirstNameNotValidException if first name is not valid + * @see BaseValidateValueObject + * @see BaseValueObject + * @see FirstNameNotValidException + */ +data class FirstName(val firstname: String) : BaseValidateValueObject(firstname) { + /** + * Validate first name value object with regex + * @param value first name value + * @throws FirstNameNotValidException if first name is not valid + */ + override fun validate(value: String) { + val firstname = value.trim() + if (firstname.isEmpty() || firstname.length > NAME_LEN || containsInvalidCharacters( + firstname, + ) + ) { + throw FirstNameNotValidException(value) + } + } + + private fun containsInvalidCharacters(firstname: String): Boolean = + !firstname.matches(Regex("^[a-zA-ZÀ-ÿ\\s]{1,150}\$")) + + override fun toString(): String = firstname +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/LastName.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/LastName.kt new file mode 100644 index 00000000..c6087e1f --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/LastName.kt @@ -0,0 +1,30 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.newsletter.domain.exceptions.LastNameNotValidException +import com.lyra.common.domain.BaseValidateValueObject + +private const val NAME_LEN = 150 + +/** + * Email value object + * @param lastname last name value + * @throws LastNameNotValidException if last name is not valid + * @see BaseValidateValueObject + * @see BaseValueObject + * @see LastNameNotValidException + */ +data class LastName(val lastname: String) : BaseValidateValueObject(lastname) { + /** + * Validate last name value object + * @param value last name value + * @throws LastNameNotValidException if last name is not valid + */ + override fun validate(value: String) { + val lastname = value.trim() + if (lastname.isEmpty() || lastname.length > NAME_LEN) { + throw LastNameNotValidException(value) + } + } + + override fun toString(): String = lastname +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Name.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Name.kt new file mode 100644 index 00000000..1622712d --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Name.kt @@ -0,0 +1,22 @@ +package com.lyra.app.newsletter.domain + +data class Name(val firstName: FirstName, val lastName: LastName?) : Comparable { + + constructor(firstName: String, lastName: String) : this( + FirstName(firstName), + LastName(lastName), + ) + + /** + * Returns the full name of the user (first name + last name) + * @return the full name of the user + */ + fun fullName(): String = "$firstName $lastName" + + /** + * Compares this object with the specified object for order. Returns zero if this object is equal + * to the specified [other] object, a negative number if it's less than [other], or a positive number + * if it's greater than [other]. + */ + override operator fun compareTo(other: Name): Int = fullName().compareTo(other.fullName()) +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Subscriber.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Subscriber.kt new file mode 100644 index 00000000..e5e9d3bc --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Subscriber.kt @@ -0,0 +1,57 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent +import com.lyra.common.domain.BaseEntity +import com.lyra.common.domain.email.Email +import java.util.* + +class Subscriber( + override val id: SubscriberId, + val email: Email, + var name: Name, + var status: SubscriberStatus = SubscriberStatus.ENABLED +) : BaseEntity() { + fun updateName(name: Name) { + this.name = name + } + + fun updateStatus(status: SubscriberStatus) { + this.status = status + } + + companion object { + fun create( + id: SubscriberId, + email: Email, + name: Name, + status: SubscriberStatus = SubscriberStatus.ENABLED + ): Subscriber { + val subscriber = Subscriber( + id = id, + email = email, + name = name, + status = status, + ) + subscriber.record( + SubscriberCreatedEvent( + subscriber.id.toString(), + subscriber.email.email, + subscriber.name.fullName(), + subscriber.status.name, + ), + ) + return subscriber + } + fun create( + email: String, + firstname: String, + lastname: String, + status: SubscriberStatus = SubscriberStatus.ENABLED + ): Subscriber = create( + id = SubscriberId(UUID.randomUUID().toString()), + email = Email(email), + name = Name(FirstName(firstname), LastName(lastname)), + status = status, + ) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberId.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberId.kt new file mode 100644 index 00000000..e3a1004e --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberId.kt @@ -0,0 +1,8 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.common.domain.BaseId +import java.util.* + +class SubscriberId(private val id: UUID) : BaseId(id) { + constructor(id: String) : this(UUID.fromString(id)) +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberRepository.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberRepository.kt new file mode 100644 index 00000000..4ef146c4 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberRepository.kt @@ -0,0 +1,13 @@ +package com.lyra.app.newsletter.domain + +import kotlinx.coroutines.flow.Flow + +/** + * + * @created 6/1/24 + */ +interface SubscriberRepository { + suspend fun create(subscriber: Subscriber) + suspend fun searchAll(): Flow + suspend fun searchActive(): Flow +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberStatus.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberStatus.kt new file mode 100644 index 00000000..a1dbf40a --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberStatus.kt @@ -0,0 +1,5 @@ +package com.lyra.app.newsletter.domain + +enum class SubscriberStatus { + ENABLED, DISABLED, BLOCKLISTED +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionList.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionList.kt new file mode 100644 index 00000000..1438a8c6 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionList.kt @@ -0,0 +1,42 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.common.domain.BaseEntity +import java.util.* + +/** + * A subscription list is a list of subscribers. + * It can be used to send newsletters to a group of subscribers. + * @created 6/1/24 + */ +data class SubscriptionList( + override val id: SubscriptionListId, + var name: String, + var description: String? = null, + val subscribers: MutableList = mutableListOf() +) : BaseEntity() { + fun addSubscriber(subscriber: Subscriber) { + subscribers.add(subscriber) + } + + fun removeSubscriber(subscriber: Subscriber) { + subscribers.remove(subscriber) + } + + fun updateName(name: String) { + this.name = name + } + + fun updateDescription(description: String) { + this.description = description + } + + companion object { + fun create(name: String, description: String? = null): SubscriptionList { + return SubscriptionList( + id = SubscriptionListId(UUID.randomUUID()), + name = name, + description = description, + ) + } + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionListId.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionListId.kt new file mode 100644 index 00000000..b1059877 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionListId.kt @@ -0,0 +1,8 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.common.domain.BaseId +import java.util.* + +class SubscriptionListId(private val id: UUID) : BaseId(id) { + constructor(id: String) : this(UUID.fromString(id)) +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/event/SubscriberCreatedEvent.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/event/SubscriberCreatedEvent.kt new file mode 100644 index 00000000..ca4f4412 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/event/SubscriberCreatedEvent.kt @@ -0,0 +1,14 @@ +package com.lyra.app.newsletter.domain.event + +import com.lyra.common.domain.bus.event.BaseDomainEvent + +/** + * + * @created 6/1/24 + */ +data class SubscriberCreatedEvent( + val subscriberId: String, + val email: String, + val name: String, + val status: String +) : BaseDomainEvent() diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/exceptions/NameExceptions.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/exceptions/NameExceptions.kt new file mode 100644 index 00000000..d5dbc724 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/exceptions/NameExceptions.kt @@ -0,0 +1,29 @@ +package com.lyra.app.newsletter.domain.exceptions + +import com.lyra.common.domain.error.BusinessRuleValidationException + +/** + * Exception thrown when the name is not valid + * + * @created 2/7/23 + */ +sealed class InvalidArgumentNameException( + override val message: String, + override val cause: Throwable? = null +) : BusinessRuleValidationException(message, cause) + +/** + * Exception thrown when the name is not valid + * @param id the name that is not valid + * @param cause the cause of the exception + */ +data class FirstNameNotValidException(val id: String, override val cause: Throwable? = null) : + InvalidArgumentNameException("The first name <$id> is not valid", cause) + +/** + * Exception thrown when the name is not valid + * @param id the name that is not valid + * @param cause the cause of the exception + */ +data class LastNameNotValidException(val id: String, override val cause: Throwable? = null) : + InvalidArgumentNameException("The last name <$id> is not valid", cause) diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberController.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberController.kt new file mode 100644 index 00000000..ff2eda9b --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberController.kt @@ -0,0 +1,46 @@ +package com.lyra.app.newsletter.infrastructure.http + +import com.lyra.app.newsletter.application.SubscribeNewsletterCommand +import com.lyra.app.newsletter.infrastructure.http.request.SubscribeNewsletterRequest +import com.lyra.common.domain.bus.Mediator +import com.lyra.spring.boot.ApiController +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.Valid +import java.net.URI +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = ["/api"], produces = ["application/vnd.api.v1+json"]) +class NewsletterSubscriberController( + mediator: Mediator, +) : ApiController(mediator) { + @Operation(summary = "Subscribe to newsletter") + @ApiResponses( + ApiResponse(responseCode = "201", description = "Created"), + ApiResponse(responseCode = "500", description = "Internal server error"), + ) + @PutMapping("/newsletter/subscribers/{id}") + suspend fun subscribe( + @PathVariable id: String, + @Valid @RequestBody request: SubscribeNewsletterRequest + ): ResponseEntity { + + dispatch( + SubscribeNewsletterCommand( + id, + request.email, + request.firstname, + request.lastname, + ), + ) + + return ResponseEntity.created(URI.create("/api/newsletter/subscribers/$id")).build() + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/request/SubscribeNewsletterRequest.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/request/SubscribeNewsletterRequest.kt new file mode 100644 index 00000000..4dc7af7b --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/request/SubscribeNewsletterRequest.kt @@ -0,0 +1,13 @@ +package com.lyra.app.newsletter.infrastructure.http.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class SubscribeNewsletterRequest( + @field:NotBlank(message = "Email is required") + @field:Email(message = "Email is not valid") + val email: String, + @field:NotBlank(message = "Firstname is required") + var firstname: String, + var lastname: String? = null +) diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepository.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepository.kt new file mode 100644 index 00000000..da8e3aa5 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepository.kt @@ -0,0 +1,50 @@ +package com.lyra.app.newsletter.infrastructure.persistence + +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberRepository +import com.lyra.app.newsletter.domain.SubscriberStatus +import com.lyra.app.newsletter.infrastructure.persistence.mapper.SubscriberMapper.toDomain +import com.lyra.app.newsletter.infrastructure.persistence.mapper.SubscriberMapper.toEntity +import com.lyra.app.newsletter.infrastructure.persistence.repository.SubscriberRegistratorR2dbcRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.reactive.asFlow +import org.slf4j.LoggerFactory +import org.springframework.dao.DuplicateKeyException +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono + +@Repository +class SubscriberRepositoryRepository( + private val subscriberRegistratorR2dbcRepository: SubscriberRegistratorR2dbcRepository +) : SubscriberRepository { + override suspend fun create(subscriber: Subscriber) { + val entity = subscriber.toEntity() + + subscriberRegistratorR2dbcRepository + .save(entity) + .onErrorResume { throwable -> + if (throwable is DuplicateKeyException) { + log.info("Subscriber already exists in the database: ${subscriber.email.email}") + Mono.empty() // Ignore the exception and continue with an empty Mono + } else { + log.error("Error while saving subscriber to database: ${subscriber.email.email}", throwable) + Mono.error(throwable) // Propagate the exception + } + } + .subscribe() + } + + override suspend fun searchAll(): Flow { + return subscriberRegistratorR2dbcRepository.findAll() + .map { it.toDomain() }.asFlow() + } + + override suspend fun searchActive(): Flow { + return subscriberRegistratorR2dbcRepository.findAllByStatus(SubscriberStatus.ENABLED) + .map { it.toDomain() }.asFlow() + } + + companion object { + private val log = LoggerFactory.getLogger(SubscriberRepositoryRepository::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverter.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverter.kt new file mode 100644 index 00000000..6909ed30 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverter.kt @@ -0,0 +1,41 @@ +package com.lyra.app.newsletter.infrastructure.persistence.converter + +import com.lyra.app.newsletter.domain.SubscriberStatus +import com.lyra.app.newsletter.infrastructure.persistence.entity.SubscriberEntity +import io.r2dbc.spi.Row +import java.time.LocalDateTime +import java.util.UUID +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter + +/** + * + * @created 7/1/24 + */ +@ReadingConverter +class SubscriberConverter : Converter { + /** + * Convert the source object of type `S` to target type `T`. + * @param source the source object to convert, which must be an instance of `S` (never `null`) + * @return the converted object, which must be an instance of `T` (potentially `null`) + * @throws IllegalArgumentException if the source cannot be converted to the desired target type + */ + override fun convert(source: Row): SubscriberEntity { + val sourceId = source.get("id", UUID::class.java) + val sourceEmail = source.get("email", String::class.java) + val sourceFirstname = source.get("firstname", String::class.java) + val sourceLastname = source.get("lastname", String::class.java) + val sourceStatus = source.get("status", SubscriberStatus::class.java) + val sourceCreatedAt = source.get("created_at", LocalDateTime::class.java) + val sourceUpdatedAt = source.get("updated_at", LocalDateTime::class.java) + return SubscriberEntity( + id = sourceId!!, + email = sourceEmail!!, + firstname = sourceFirstname!!, + lastname = sourceLastname!!, + status = sourceStatus!!, + createdAt = sourceCreatedAt!!, + updatedAt = sourceUpdatedAt!!, + ) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberStatusWriterConverter.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberStatusWriterConverter.kt new file mode 100644 index 00000000..6d2e7b53 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberStatusWriterConverter.kt @@ -0,0 +1,8 @@ +package com.lyra.app.newsletter.infrastructure.persistence.converter + +import com.lyra.app.newsletter.domain.SubscriberStatus +import org.springframework.data.convert.WritingConverter +import org.springframework.data.r2dbc.convert.EnumWriteSupport + +@WritingConverter +class SubscriberStatusWriterConverter : EnumWriteSupport() diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/AuditableEntity.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/AuditableEntity.kt new file mode 100644 index 00000000..db595c33 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/AuditableEntity.kt @@ -0,0 +1,12 @@ +package com.lyra.app.newsletter.infrastructure.persistence.entity + +import java.time.LocalDateTime + +/** + * + * @created 7/1/24 + */ +abstract class AuditableEntity( + open var createdAt: LocalDateTime = LocalDateTime.now(), + open var updatedAt: LocalDateTime? = null +) diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/SubscriberEntity.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/SubscriberEntity.kt new file mode 100644 index 00000000..0ff9f298 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/SubscriberEntity.kt @@ -0,0 +1,40 @@ +package com.lyra.app.newsletter.infrastructure.persistence.entity + +import com.lyra.app.newsletter.domain.SubscriberStatus +import java.time.LocalDateTime +import java.util.* +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table("subscribers") +data class SubscriberEntity( + // id is generated by the frontend and sent to the backend as a UUID + val id: UUID, + var email: String, + var firstname: String, + var lastname: String? = null, + @Column("status") + var status: SubscriberStatus, + override var createdAt: LocalDateTime = LocalDateTime.now(), + override var updatedAt: LocalDateTime? = LocalDateTime.now(), +) : AuditableEntity() { + + companion object { + fun create( + id: UUID, + email: String, + firstname: String, + lastname: String, + status: SubscriberStatus + ): SubscriberEntity { + val entity = SubscriberEntity( + id = id, + email = email, + firstname = firstname, + lastname = lastname, + status = status, + ) + return entity + } + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/mapper/SubscriberMapper.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/mapper/SubscriberMapper.kt new file mode 100644 index 00000000..d3161046 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/mapper/SubscriberMapper.kt @@ -0,0 +1,33 @@ +package com.lyra.app.newsletter.infrastructure.persistence.mapper + +import com.lyra.app.newsletter.domain.FirstName +import com.lyra.app.newsletter.domain.LastName +import com.lyra.app.newsletter.domain.Name +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberId +import com.lyra.app.newsletter.infrastructure.persistence.entity.SubscriberEntity +import com.lyra.common.domain.email.Email + +object SubscriberMapper { + fun Subscriber.toEntity(): SubscriberEntity { + return SubscriberEntity.create( + id = id.value, + email = email.value, + firstname = name.firstName.toString(), + lastname = name.lastName.toString(), + status = status, + ) + } + + fun SubscriberEntity.toDomain(): Subscriber { + return Subscriber( + id = SubscriberId(id), + email = Email(email), + name = Name( + firstName = FirstName(firstname), + lastName = lastname?.let { LastName(it) }, + ), + status = status, + ) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/repository/SubscriberRegistratorR2dbcRepository.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/repository/SubscriberRegistratorR2dbcRepository.kt new file mode 100644 index 00000000..2f556e83 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/repository/SubscriberRegistratorR2dbcRepository.kt @@ -0,0 +1,13 @@ +package com.lyra.app.newsletter.infrastructure.persistence.repository + +import com.lyra.app.newsletter.domain.SubscriberStatus +import com.lyra.app.newsletter.infrastructure.persistence.entity.SubscriberEntity +import java.util.UUID +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface SubscriberRegistratorR2dbcRepository : ReactiveCrudRepository { + fun findAllByStatus(status: SubscriberStatus): Flux +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/NewContentNewsletterSender.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/NewContentNewsletterSender.kt new file mode 100644 index 00000000..33014ae4 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/NewContentNewsletterSender.kt @@ -0,0 +1,53 @@ +package com.lyra.app.notifications.application + +import com.lyra.app.newsletter.application.SubscriberResponse +import com.lyra.app.newsletter.application.search.active.SearchAllActiveSubscribersQuery +import com.lyra.app.notifications.domain.EmailMessageId +import com.lyra.app.notifications.domain.EmailSender +import com.lyra.app.notifications.domain.NewContentNewsletter +import com.lyra.app.notifications.domain.event.NewContentNewsletterEmailSent +import com.lyra.common.domain.Service +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.event.EventPublisher +import org.slf4j.LoggerFactory + +/** + * + * @created 12/1/24 + */ +@Service +class NewContentNewsletterSender( + private val emailSender: EmailSender, + private val mediator: Mediator, + private val eventPublisher: EventPublisher +) { + + suspend fun sendNewContentNewsletter() { + log.info("Sending new content newsletter") + val subscribers = mediator.send(SearchAllActiveSubscribersQuery()) + .also { log.info("Found ${it.subscribers.size} subscribers") }.subscribers + + subscribers.forEach { + send(it) + } + } + + private suspend fun send(subscriber: SubscriberResponse) { + val contentNewsletter = NewContentNewsletter.send( + id = EmailMessageId(subscriber.id), + subscriber = subscriber, + ) + emailSender.send(contentNewsletter) + eventPublisher.publish( + NewContentNewsletterEmailSent( + id = contentNewsletter.id.value.toString(), + subscriberId = subscriber.id, + email = subscriber.email, + ), + ) + } + + companion object { + private val log = LoggerFactory.getLogger(NewContentNewsletterSender::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommand.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommand.kt new file mode 100644 index 00000000..707deaaf --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommand.kt @@ -0,0 +1,9 @@ +package com.lyra.app.notifications.application + +import com.lyra.common.domain.bus.command.Command + +/** + * + * @created 12/1/24 + */ +class SendNewContentNewsletterCommand : Command diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandler.kt new file mode 100644 index 00000000..2b58e78a --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandler.kt @@ -0,0 +1,23 @@ +package com.lyra.app.notifications.application + +import com.lyra.common.domain.Service +import com.lyra.common.domain.bus.command.CommandHandler +import org.slf4j.LoggerFactory + +/** + * + * @created 12/1/24 + */ +@Service +class SendNewContentNewsletterCommandHandler( + private val sender: NewContentNewsletterSender +) : CommandHandler { + override suspend fun handle(command: SendNewContentNewsletterCommand) { + log.info("Handling command: $command") + sender.sendNewContentNewsletter() + } + + companion object { + private val log = LoggerFactory.getLogger(SendNewContentNewsletterCommandHandler::class.java) + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessage.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessage.kt new file mode 100644 index 00000000..a35f71b5 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessage.kt @@ -0,0 +1,43 @@ +package com.lyra.app.notifications.domain + +import com.lyra.common.domain.AggregateRoot +import com.lyra.common.domain.Generated +import com.lyra.common.domain.email.Email + +/** + * + * @created 9/1/24 + */ +abstract class EmailMessage( + override val id: EmailMessageId, + val from: Email, + val to: Email, + val subject: String, + val body: String +) : AggregateRoot() { + @Generated + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EmailMessage) return false + if (!super.equals(other)) return false + + if (id != other.id) return false + if (from != other.from) return false + if (to != other.to) return false + if (subject != other.subject) return false + if (body != other.body) return false + + return true + } + + @Generated + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + from.hashCode() + result = 31 * result + to.hashCode() + result = 31 * result + subject.hashCode() + result = 31 * result + body.hashCode() + return result + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessageId.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessageId.kt new file mode 100644 index 00000000..472d3110 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessageId.kt @@ -0,0 +1,8 @@ +package com.lyra.app.notifications.domain + +import com.lyra.common.domain.BaseId +import java.util.* + +class EmailMessageId(private val id: UUID) : BaseId(id) { + constructor(id: String) : this(UUID.fromString(id)) +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailSender.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailSender.kt new file mode 100644 index 00000000..0fbfd486 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailSender.kt @@ -0,0 +1,9 @@ +package com.lyra.app.notifications.domain + +/** + * + * @created 9/1/24 + */ +interface EmailSender { + suspend fun send(emailMessage: EmailMessage) +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/NewContentNewsletter.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/NewContentNewsletter.kt new file mode 100644 index 00000000..985d5342 --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/NewContentNewsletter.kt @@ -0,0 +1,48 @@ +package com.lyra.app.notifications.domain + +import com.lyra.app.newsletter.application.SubscriberResponse +import com.lyra.app.notifications.domain.event.NewContentNewsletterEmailSent +import com.lyra.common.domain.Generated +import com.lyra.common.domain.email.Email + +class NewContentNewsletter( + override val id: EmailMessageId, + subscriber: SubscriberResponse +) : EmailMessage( + id, + Email("news@lyra.com"), + Email(subscriber.email), + "Welcome to Lyra", + subscriber.name.plus(" welcome to Lyra!"), +) { + @Generated + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is NewContentNewsletter) return false + if (!super.equals(other)) return false + + if (id != other.id) return false + + return true + } + + @Generated + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + id.hashCode() + return result + } + + companion object { + fun send(id: EmailMessageId, subscriber: SubscriberResponse): NewContentNewsletter { + val emailMessage = NewContentNewsletter(id, subscriber) + emailMessage.record( + NewContentNewsletterEmailSent( + id.value.toString(), + subscriber.id, subscriber.email, + ), + ) + return emailMessage + } + } +} diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/event/NewContentNewsletterEmailSent.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/event/NewContentNewsletterEmailSent.kt new file mode 100644 index 00000000..6bf395fb --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/event/NewContentNewsletterEmailSent.kt @@ -0,0 +1,13 @@ +package com.lyra.app.notifications.domain.event + +import com.lyra.common.domain.bus.event.BaseDomainEvent + +/** + * + * @created 9/1/24 + */ +data class NewContentNewsletterEmailSent( + val id: String, + val subscriberId: String, + val email: String, +) : BaseDomainEvent() diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSender.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSender.kt new file mode 100644 index 00000000..18e54dad --- /dev/null +++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSender.kt @@ -0,0 +1,42 @@ +package com.lyra.app.notifications.infrastructure + +import com.lyra.app.notifications.domain.EmailMessage +import com.lyra.app.notifications.domain.EmailSender +import com.lyra.common.domain.Service +import com.sendgrid.Method +import com.sendgrid.Request +import com.sendgrid.SendGrid +import com.sendgrid.helpers.mail.Mail +import com.sendgrid.helpers.mail.objects.Content +import com.sendgrid.helpers.mail.objects.Email +import java.io.IOException +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value + +@Service +class SendgridEmailSender( + @Value("\${notification.email.sendgrid-api-key}") + private val apiKey: String, + private val sg: SendGrid = SendGrid(apiKey) +) : EmailSender { + override suspend fun send(emailMessage: EmailMessage) { + log.info("Sending email to ${emailMessage.to.value}") + val from = Email(emailMessage.from.value) + val to = Email(emailMessage.to.value) + val content = Content("text/plain", emailMessage.body) + val mail = Mail(from, emailMessage.subject, to, content) + val request = Request() + try { + request.method = Method.POST + request.endpoint = "mail/send" + request.body = mail.build() + val response = sg.api(request) + log.info("Email sent to ${emailMessage.to.value} with status code ${response.statusCode}") + } catch (ex: IOException) { + log.error("Error sending email to ${emailMessage.to.value}", ex) + } + } + companion object { + private val log = LoggerFactory.getLogger(SendgridEmailSender::class.java) + } +} diff --git a/apps/backend/src/main/resources/application.yml b/apps/backend/src/main/resources/application.yml index ddfb1436..b6d517d4 100644 --- a/apps/backend/src/main/resources/application.yml +++ b/apps/backend/src/main/resources/application.yml @@ -1,11 +1,23 @@ # Configuration file for the application server: port: 8080 +logging: + level: + org.springframework.r2dbc: DEBUG + com.lyra: DEBUG spring: application: name: ${application.name} main: allow-bean-definition-overriding: true + r2dbc: + url: r2dbc:postgresql://localhost:5432/postgres + username: postgres + password: postgres + pool: + enabled: true + initial-size: 10 + max-size: 20 application: name: lyra @@ -17,7 +29,9 @@ management: web: exposure: include: info, health, beans, openapi, swagger-ui - +notification: + email: + sendgrid-api-key: ${SENDGRID_API_KEY:WILL_BE_REPLACED} springdoc: show-actuator: true api-docs: diff --git a/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt b/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt index 7a78bbfc..adb55a40 100644 --- a/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt +++ b/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt @@ -1,10 +1,15 @@ package com.lyra.app +import com.lyra.spring.boot.bus.event.EventConfiguration import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class LyraApplicationTests { + @Suppress("UnusedPrivateProperty") + @Autowired + private lateinit var eventConfiguration: EventConfiguration @Suppress("EmptyFunctionBlock") @Test diff --git a/apps/backend/src/test/kotlin/com/lyra/app/TestDemoApplication.kt b/apps/backend/src/test/kotlin/com/lyra/app/TestDemoApplication.kt new file mode 100644 index 00000000..f9af0158 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/TestDemoApplication.kt @@ -0,0 +1,21 @@ +package com.lyra.app + +import org.springframework.boot.fromApplication +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.boot.with +import org.springframework.context.annotation.Bean +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName + +@TestConfiguration(proxyBeanMethods = false) +class TestDemoApplication { + + @Bean + @ServiceConnection + fun postgresContainer(): PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres:latest")) +} + +fun main(args: Array) { + fromApplication().with(TestDemoApplication::class).run(*args) +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/SubscriberStub.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/SubscriberStub.kt new file mode 100644 index 00000000..f84b8d19 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/SubscriberStub.kt @@ -0,0 +1,44 @@ +package com.lyra.app.newsletter + +import com.lyra.app.newsletter.domain.FirstName +import com.lyra.app.newsletter.domain.LastName +import com.lyra.app.newsletter.domain.Name +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberId +import com.lyra.app.newsletter.domain.SubscriberStatus +import com.lyra.common.domain.email.Email +import java.util.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import net.datafaker.Faker + +/** + * + * @created 11/1/24 + */ +object SubscriberStub { + private val faker = Faker() + fun create( + id: String = UUID.randomUUID().toString(), + email: String = faker.internet().emailAddress(), + firstname: String = faker.name().firstName(), + lastname: String = faker.name().lastName(), + status: SubscriberStatus = SubscriberStatus.ENABLED, + ): Subscriber { + return Subscriber( + id = SubscriberId(id), + email = Email(email), + name = Name(FirstName(firstname), LastName(lastname)), + status = status, + ) + } + + fun dummyRandomSubscribersList(size: Int = 10): List { + return (1..size).map { + create() + } + } + + fun dummyRandomSubscribersFlow(size: Int = 10): Flow = + dummyRandomSubscribersList(size).asFlow() +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandlerTest.kt new file mode 100644 index 00000000..8d9fde48 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandlerTest.kt @@ -0,0 +1,52 @@ +package com.lyra.app.newsletter.application + +import com.lyra.app.UnitTest +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.domain.SubscriberRepository +import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent +import com.lyra.common.domain.bus.event.EventPublisher +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkClass +import java.util.* +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@UnitTest +internal class CreateSubscribeNewsletterCommandHandlerTest { + + private var eventPublisher: EventPublisher = mockk() + + private var subscriberRepository = mockkClass(SubscriberRepository::class) + + private var subscriberRegistrator: SubscriberRegistrator = SubscriberRegistrator( + subscriberRepository, + eventPublisher, + ) + + private var createSubscribeNewsletterCommandHandler: CreateSubscribeNewsletterCommandHandler = + CreateSubscribeNewsletterCommandHandler(subscriberRegistrator) + private val email = "john.doe@lyra.com" + private val firstname = "John" + private val lastname = "Doe" + + @BeforeEach + fun setUp() { + coEvery { subscriberRepository.create(any(Subscriber::class)) } returns Unit + coEvery { eventPublisher.publish(any(SubscriberCreatedEvent::class)) } returns Unit + } + + @Test + fun `should register a subscriber`() = runBlocking { + // Given + val command = + SubscribeNewsletterCommand(UUID.randomUUID().toString(), email, firstname, lastname) + // When + createSubscribeNewsletterCommandHandler.handle(command) + // Then + coVerify(exactly = 1) { subscriberRepository.create(any(Subscriber::class)) } + coVerify(exactly = 1) { eventPublisher.publish(any(SubscriberCreatedEvent::class)) } + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandlerTest.kt new file mode 100644 index 00000000..ebf47c64 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandlerTest.kt @@ -0,0 +1,38 @@ +package com.lyra.app.newsletter.application.search.active + +import com.lyra.app.newsletter.SubscriberStub +import com.lyra.app.newsletter.domain.SubscriberRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class SearchAllActiveSubscribersQueryHandlerTest { + private val repository: SubscriberRepository = mockk() + + private var searcher: ActiveSubscriberSearcher = ActiveSubscriberSearcher(repository) + + private var searchAllActiveSubscribersQueryHandler: SearchAllActiveSubscribersQueryHandler = + SearchAllActiveSubscribersQueryHandler(searcher) + + @BeforeEach + fun setUp() { + coEvery { repository.searchActive() } returns SubscriberStub.dummyRandomSubscribersFlow() + } + + @Test + fun `should search all active subscribers`() = runBlocking { + // Given + val command = SearchAllActiveSubscribersQuery() + // When + val response = searchAllActiveSubscribersQueryHandler.handle(command) + // Then + assertTrue(response.subscribers.isNotEmpty()) + assertEquals(10, response.subscribers.size) + coVerify(exactly = 1) { repository.searchActive() } + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandlerTest.kt new file mode 100644 index 00000000..95255475 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandlerTest.kt @@ -0,0 +1,39 @@ +package com.lyra.app.newsletter.application.search.all + +import com.lyra.app.newsletter.SubscriberStub +import com.lyra.app.newsletter.domain.SubscriberRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockkClass +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +private const val NUM_SUBSCRIBER = 100 + +internal class SearchAllSubscribersQueryHandlerTest { + private val repository = mockkClass(SubscriberRepository::class) + private val searcher = AllSubscriberSearcher(repository) + private val searchAllSubscribersQueryHandler = SearchAllSubscribersQueryHandler(searcher) + + @BeforeEach + fun setUp() { + coEvery { repository.searchAll() } returns SubscriberStub.dummyRandomSubscribersFlow( + NUM_SUBSCRIBER, + ) + } + + @Test + fun `should search all subscribers`() = runBlocking { + // Given + val command = SearchAllSubscribersQuery() + // When + val response = searchAllSubscribersQueryHandler.handle(command) + // Then + assertTrue(response.subscribers.isNotEmpty()) + assertEquals(NUM_SUBSCRIBER, response.subscribers.size) + coVerify(exactly = 1) { repository.searchAll() } + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/FirstNameTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/FirstNameTest.kt new file mode 100644 index 00000000..84a7301c --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/FirstNameTest.kt @@ -0,0 +1,76 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.UnitTest +import com.lyra.app.newsletter.domain.exceptions.FirstNameNotValidException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +@UnitTest +internal class FirstNameTest { + @Test + fun `should create a valid first name`() { + val firstName = FirstName("Yuniel") + assertEquals("Yuniel", firstName.value) + } + + @Test + fun `should throw an exception when create a first name with invalid value`() { + val invalidFirstNames = listOf( + "123abc", "abc!@#", "abc.def", "abc-def", + "abc_def", "abc+def", "abc*def", "abc/def", "abc=def", "abc?def", + "abc#def", "abc\$def", "abc%def", "abc^def", "abc&def", "abc(def", + "abc)def", "abc[def", "abc]def", "abc{def", "abc}def", "abc:def", + "abc;def", "abc\"def", "abc'def", "abcdef", "abc,def", + "abc~def", "abc`def", "abc|def", "abc\\def", "abc¡def", "abc¿def", + "abc©def", "abc®def", "abc™def", "abc€def", "abc£def", "abc¥def", + "abc¢def", "abc§def", "abc¶def", "abc•def", "abc†def", "abc‡def", + "abc°def", "abc±def", "abc¶def", "abc•def", "abc†def", "abc‡def", + "abc²def", "abc³def", "abc¼def", "abc½def", "yuniel-acosta", "yuniel_acosta", + ) + invalidFirstNames.forEach { + assertThrows(FirstNameNotValidException::class.java) { + FirstName(it) + } + } + } + + @Test + fun `should throw an exception when create a first name with empty value`() { + assertThrows(FirstNameNotValidException::class.java) { + FirstName("") + } + } + + @Test + fun `should throw an exception when create a first name with blank value`() { + assertThrows(FirstNameNotValidException::class.java) { + FirstName(" ") + } + } + + @Test + fun `should throw an exception when create a first name with length greater than 150`() { + val firstName = (1..256).joinToString("") { "a" } + assertThrows(FirstNameNotValidException::class.java) { + FirstName(firstName) + } + } + + @Test + fun `compare first name`() { + val firstName1 = FirstName("Yuniel") + val firstName2 = FirstName("Yuniel") + assertEquals(firstName1, firstName2) + assertEquals(firstName1.hashCode(), firstName2.hashCode()) + } + + @Test + fun `compare first name with different values`() { + val firstName1 = FirstName("Yuniel") + val firstName2 = FirstName("David") + assertNotEquals(firstName1, firstName2) + assertNotEquals(firstName1.hashCode(), firstName2.hashCode()) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/LastNameTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/LastNameTest.kt new file mode 100644 index 00000000..9bb0d60b --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/LastNameTest.kt @@ -0,0 +1,80 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.UnitTest +import com.lyra.app.newsletter.domain.exceptions.LastNameNotValidException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +@UnitTest +internal class LastNameTest { + @Test + fun `should create a valid last name`() { + val lastNames = listOf( + "Acosta", + "Acosta Ortiz", + "Acosta Pérez", + "O'Neil", + "D'Angelo", + ) + lastNames.forEach { + println("Last Name: $it") + val lastName = LastName(it) + assertEquals(it, lastName.value) + } + } + + @Test + fun `should throw an exception when create a last name with invalid value`() { + val invalidLastNames = listOf( + """ + A voluptatum ex. Ratione adipisci eveniet expedita iste consectetur voluptatibus illum odio optio. + Eum facilis autem. Explicabo sequi corrupti eius quis eius adipisci. + """.trimIndent(), + ) + invalidLastNames.forEach { + println("Last Name: $it") + assertThrows(LastNameNotValidException::class.java) { + LastName(it) + } + } + } + + @Test + fun `should throw an exception when create a last name with empty value`() { + assertThrows(LastNameNotValidException::class.java) { + LastName("") + } + } + + @Test + fun `should throw an exception when create a last name with blank value`() { + assertThrows(LastNameNotValidException::class.java) { + LastName(" ") + } + } + + @Test + fun `should throw an exception when create a last name with length greater than 150`() { + val lastName = (1..256).joinToString("") { "a" } + assertThrows(LastNameNotValidException::class.java) { + LastName(lastName) + } + } + + @Test + fun `compare last name`() { + val lastName1 = LastName("Acosta") + val lastName2 = LastName("Acosta") + assertEquals(lastName1, lastName2) + } + + @Test + fun `compare last name with different values`() { + val lastName1 = LastName("Acosta") + val lastName2 = LastName("Acosta Ortiz") + assertNotEquals(lastName1, lastName2) + assertNotEquals(lastName1.hashCode(), lastName2.hashCode()) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/NameTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/NameTest.kt new file mode 100644 index 00000000..66b18aad --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/NameTest.kt @@ -0,0 +1,90 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.UnitTest +import com.lyra.app.newsletter.domain.exceptions.FirstNameNotValidException +import com.lyra.app.newsletter.domain.exceptions.LastNameNotValidException +import java.util.stream.Stream +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +@UnitTest +internal class NameTest { + + @Test + fun `should create a valid name and lastname`() { + val names = mapOf( + "Yuniel" to "Acosta Pérez", + "Neil" to "O'Neil", + "Ramón" to "González Pérez", + "Ñico" to "López", + ) + + names.forEach { (firstname, lastname) -> + println("Fullname: $firstname $lastname") + val name = Name(firstname, lastname) + assertEquals(firstname, name.firstName?.value) + assertEquals(lastname, name.lastName?.value) + assertEquals(name.fullName(), "$firstname $lastname") + } + } + + @Test + fun `should not build without firstname`() { + assertThrows(FirstNameNotValidException::class.java) { + Name("", "Acosta Pérez") + } + } + + @Test + fun `should not build without lastname`() { + assertThrows(LastNameNotValidException::class.java) { + Name("Yuniel", "") + } + } + + @Test + fun `should not build with firstname greater than 150 characters`() { + val firstname = (1..256).joinToString("") { "a" } + assertThrows(FirstNameNotValidException::class.java) { + Name(firstname, "Acosta Pérez") + } + } + + @Test + fun `should not build with lastname greater than 150 characters`() { + val lastname = (1..256).joinToString("") { "a" } + assertThrows(LastNameNotValidException::class.java) { + Name("Yuniel", lastname) + } + } + + @Test + fun `should not build with firstname and lastname greater than 150 characters`() { + val firstname = (1..256).joinToString("") { "a" } + val lastname = (1..256).joinToString("") { "a" } + assertThrows(FirstNameNotValidException::class.java) { + Name(firstname, lastname) + } + } + + @Test + fun `should get fullname`() { + val name = Name("Yuniel", "Acosta Pérez") + assertEquals("Yuniel Acosta Pérez", name.fullName()) + } + + @Test + fun shouldSortNames() { + val names: List = Stream + .of(Name("paul", "Dupond"), Name("jean", "Dupont"), Name("jean", "Dupond")) + .sorted() + .toList() + assertThat(names).containsExactly( + Name("jean", "Dupond"), + Name("jean", "Dupont"), + Name("paul", "Dupond"), + ) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriberTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriberTest.kt new file mode 100644 index 00000000..7dc4f2f2 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriberTest.kt @@ -0,0 +1,34 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.UnitTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +@UnitTest +internal class SubscriberTest { + private val email = "john.doe@lyra.com" + private val firstname = "John" + private val lastname = "Doe" + + @Test + fun `should update name`() { + val subscriber = Subscriber.create(email, firstname, lastname) + val newName = Name("Jane", "Doe") + subscriber.updateName(newName) + assertEquals(newName, subscriber.name) + } + + @Test + fun `should update status`() { + val subscriber = Subscriber.create(email, firstname, lastname) + val newStatus = SubscriberStatus.BLOCKLISTED + subscriber.updateStatus(newStatus) + assertEquals(newStatus, subscriber.status) + } + + @Test + fun `should create a subscriber disabled`() { + val subscriber = Subscriber.create(email, firstname, lastname, SubscriberStatus.DISABLED) + assertEquals(SubscriberStatus.DISABLED, subscriber.status) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriptionListTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriptionListTest.kt new file mode 100644 index 00000000..98c53457 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriptionListTest.kt @@ -0,0 +1,56 @@ +package com.lyra.app.newsletter.domain + +import com.lyra.app.UnitTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +@UnitTest +internal class SubscriptionListTest { + private val name = "My list" + private val email = "john.doe@lyra.com" + private val firstname = "John" + private val lastname = "Doe" + + @Test + fun `should update name`() { + val subscriptionList = SubscriptionList.create(name) + val newName = "My new list" + subscriptionList.updateName(newName) + assertEquals(newName, subscriptionList.name) + } + + @Test + fun `should update description`() { + val subscriptionList = SubscriptionList.create(name) + val newDescription = "My new description" + subscriptionList.updateDescription(newDescription) + assertEquals(newDescription, subscriptionList.description) + } + + @Test + fun `should add subscriber`() { + val subscriptionList = SubscriptionList.create(name) + val subscriber = Subscriber.create(email, firstname, lastname) + subscriptionList.addSubscriber(subscriber) + assertEquals(1, subscriptionList.subscribers.size) + } + + @Test + fun `should remove subscriber`() { + val subscriptionList = SubscriptionList.create(name) + val subscriber = Subscriber.create(email, firstname, lastname) + subscriptionList.addSubscriber(subscriber) + subscriptionList.removeSubscriber(subscriber) + assertEquals(0, subscriptionList.subscribers.size) + } + + @Test + fun `should update subscriber`() { + val subscriptionList = SubscriptionList.create(name) + val subscriber = Subscriber.create(email, firstname, lastname) + subscriptionList.addSubscriber(subscriber) + val newName = Name("Jane", "Doe") + subscriber.updateName(newName) + assertEquals(newName, subscriptionList.subscribers[0].name) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerIntegrationTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerIntegrationTest.kt new file mode 100644 index 00000000..ec68268a --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerIntegrationTest.kt @@ -0,0 +1,74 @@ +package com.lyra.app.newsletter.infrastructure.http + +import com.lyra.app.IntegrationTest +import java.util.* +import net.datafaker.Faker +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient +import org.springframework.http.MediaType +import org.springframework.test.web.reactive.server.WebTestClient + +private const val ENDPOINT = "/api/newsletter/subscribers" + +@IntegrationTest +@AutoConfigureWebTestClient +internal class NewsletterSubscriberControllerIntegrationTest { + private val faker = Faker() + + @Autowired + private lateinit var webTestClient: WebTestClient + + @BeforeEach + fun setUp() { + // Set Test container here + } + + @Test + fun `should subscribe a new subscriber`() { + val request = generateRequest() + webTestClient.put() + .uri("$ENDPOINT/${UUID.randomUUID()}") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isCreated + .expectBody().isEmpty + } + + @Test + fun `should not subscribe a new subscriber if email is invalid`() { + val request = generateRequest(email = "invalid-email") + webTestClient.put() + .uri("$ENDPOINT/${UUID.randomUUID()}") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + request, + ) + .exchange() + .expectStatus().isBadRequest + .expectBody().jsonPath("$.type") + .isEqualTo("https://lyra.io/errors/bad-request") + .jsonPath("$.title") + .isEqualTo("Bad request") + .jsonPath("$.status") + .isEqualTo(400) + .jsonPath("$.detail") + .isEqualTo("The email is not valid") + .jsonPath("$.instance").isNotEmpty + } + + @Suppress("MultilineRawStringIndentation") + private fun generateRequest( + email: String = faker.internet().emailAddress(), + firstname: String = faker.name().firstName(), + lastname: String = faker.name().lastName() + ): String = """ + { + "email": "$email", + "firstname": "$firstname", + "lastname": "$lastname" + } + """.trimIndent() +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerTest.kt new file mode 100644 index 00000000..3120cc71 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerTest.kt @@ -0,0 +1,46 @@ +package com.lyra.app.newsletter.infrastructure.http + +import com.lyra.app.newsletter.application.SubscribeNewsletterCommand +import com.lyra.app.newsletter.infrastructure.http.request.SubscribeNewsletterRequest +import com.lyra.common.domain.bus.Mediator +import io.mockk.coEvery +import io.mockk.mockk +import java.util.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.reactive.server.WebTestClient + +private const val ENDPOINT = "/api/newsletter/subscribers" +class NewsletterSubscriberControllerTest { + private val mediator = mockk() + private val id = UUID.randomUUID().toString() + private val email = "john.doe@example.com" + private val firstname = "John" + private val lastname = "Doe" + private val command = SubscribeNewsletterCommand(id, email, firstname, lastname) + private val controller = NewsletterSubscriberController(mediator) + private val webTestClient = WebTestClient.bindToController(controller).build() + @BeforeEach + fun setUp() { + coEvery { mediator.send(eq(command)) } returns Unit + } + + @Test + fun `should subscribe a new subscriber`() { + // Given + val request = SubscribeNewsletterRequest( + email = email, + firstname = firstname, + lastname = lastname, + ) + // When + webTestClient.put() + .uri("$ENDPOINT/$id") + .bodyValue(request) + .exchange() + .expectStatus().isCreated + .expectBody().isEmpty + // Then + coEvery { mediator.send(eq(command)) } + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepositoryTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepositoryTest.kt new file mode 100644 index 00000000..c2724670 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepositoryTest.kt @@ -0,0 +1,102 @@ +package com.lyra.app.newsletter.infrastructure.persistence + +import com.lyra.app.UnitTest +import com.lyra.app.newsletter.SubscriberStub +import com.lyra.app.newsletter.domain.Subscriber +import com.lyra.app.newsletter.infrastructure.persistence.mapper.SubscriberMapper.toEntity +import com.lyra.app.newsletter.infrastructure.persistence.repository.SubscriberRegistratorR2dbcRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.dao.DuplicateKeyException +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@UnitTest +internal class SubscriberRepositoryRepositoryTest { + private val subscriberRegistratorR2dbcRepository: SubscriberRegistratorR2dbcRepository = mockk() + private val subscriberRepositoryRepository = + SubscriberRepositoryRepository(subscriberRegistratorR2dbcRepository) + private lateinit var subscribers: List + + @BeforeEach + fun setUp() { + subscribers = runBlocking { SubscriberStub.dummyRandomSubscribersFlow(10).toList() } + val subscribersEntities = subscribers.map { it.toEntity() }.toList() + coEvery { subscriberRegistratorR2dbcRepository.save(any()) } returns Mono.just( + subscribersEntities.first(), + ) + coEvery { subscriberRegistratorR2dbcRepository.findAll() } returns Flux.fromIterable( + subscribersEntities, + ) + coEvery { subscriberRegistratorR2dbcRepository.findAllByStatus(any()) } returns Flux.fromIterable( + subscribersEntities, + ) + } + + @Test + fun `should save a new subscriber`() { + // Given + val subscriber = subscribers.first() + // When + runBlocking { + subscriberRepositoryRepository.create(subscriber) + } + // Then + coEvery { subscriberRegistratorR2dbcRepository.save(any()) } + } + + @Test + fun `should not save a new subscriber if it already exists`() { + // Given + val subscriber = subscribers.first() + coEvery { subscriberRegistratorR2dbcRepository.save(any()) } returns Mono.error( + DuplicateKeyException("Duplicate key"), + ) + // When + runBlocking { + subscriberRepositoryRepository.create(subscriber) + } + // Then + coEvery { subscriberRegistratorR2dbcRepository.save(any()) } + } + + @Test + fun `should not save a new subscriber if an unknown exception occur`() { + // Given + val subscriber = subscribers.first() + coEvery { subscriberRegistratorR2dbcRepository.save(any()) } returns Mono.error( + Exception("Unknown exception"), + ) + // When + runBlocking { + subscriberRepositoryRepository.create(subscriber) + } + // Then + coEvery { subscriberRegistratorR2dbcRepository.save(any()) } + } + + @Test + fun `should search all subscribers`() = runBlocking { + // Given + // When + val response = subscriberRepositoryRepository.searchAll().toList() + // Then + coEvery { subscriberRegistratorR2dbcRepository.findAll() } + assertEquals(subscribers, response) + } + + @Test + fun `should search all active subscribers`() = runBlocking { + // Given + // When + val response = subscriberRepositoryRepository.searchActive().toList() + // Then + coEvery { subscriberRegistratorR2dbcRepository.findAllByStatus(any()) } + assertEquals(subscribers, response) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverterTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverterTest.kt new file mode 100644 index 00000000..042ea2fa --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverterTest.kt @@ -0,0 +1,37 @@ +package com.lyra.app.newsletter.infrastructure.persistence.converter + +import com.lyra.app.newsletter.domain.SubscriberStatus +import io.mockk.every +import io.mockk.mockk +import io.r2dbc.spi.Row +import java.time.LocalDateTime +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SubscriberConverterTest { + + @Test + fun `should convert Row to SubscriberEntity`() { + // Arrange + val converter = SubscriberConverter() + val mockRow: Row = mockk { + every { get("id", UUID::class.java) } returns UUID.randomUUID() + every { get("email", String::class.java) } returns "test@example.com" + every { get("firstname", String::class.java) } returns "John" + every { get("lastname", String::class.java) } returns "Doe" + every { get("status", SubscriberStatus::class.java) } returns SubscriberStatus.ENABLED + every { get("created_at", LocalDateTime::class.java) } returns LocalDateTime.now() + every { get("updated_at", LocalDateTime::class.java) } returns LocalDateTime.now() + } + + // Act + val result = converter.convert(mockRow) + + // Assert + assertEquals("test@example.com", result.email) + assertEquals("John", result.firstname) + assertEquals("Doe", result.lastname) + assertEquals(SubscriberStatus.ENABLED, result.status) + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandlerTest.kt new file mode 100644 index 00000000..785d587b --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandlerTest.kt @@ -0,0 +1,62 @@ +package com.lyra.app.notifications.application + +import com.lyra.app.newsletter.SubscriberStub +import com.lyra.app.newsletter.application.SubscriberResponse +import com.lyra.app.newsletter.application.SubscribersResponse +import com.lyra.app.newsletter.application.search.active.SearchAllActiveSubscribersQuery +import com.lyra.app.notifications.domain.EmailSender +import com.lyra.app.notifications.domain.event.NewContentNewsletterEmailSent +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.event.EventPublisher +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SendNewContentNewsletterCommandHandlerTest { + private var eventPublisher: EventPublisher = mockk() + private var emailSender: EmailSender = mockk() + private var mediator: Mediator = mockk() + private val newContentNewsletterSender = NewContentNewsletterSender( + emailSender, + mediator, + eventPublisher, + ) + private val sendNewContentNewsletterCommandHandler = SendNewContentNewsletterCommandHandler( + newContentNewsletterSender, + ) + @BeforeEach + fun setUp() { + coEvery { mediator.send(any(SearchAllActiveSubscribersQuery::class)) } returns subscribersResponse() + coEvery { eventPublisher.publish(any(NewContentNewsletterEmailSent::class)) } returns Unit + coEvery { emailSender.send(any()) } returns Unit + } + + @Test + fun `should send new content newsletter`() = runBlocking { + // Given + val command = SendNewContentNewsletterCommand() + // When + sendNewContentNewsletterCommandHandler.handle(command) + // Then + coVerify(exactly = 1) { mediator.send(any(SearchAllActiveSubscribersQuery::class)) } + coVerify(exactly = 10) { emailSender.send(any()) } + coVerify(exactly = 10) { eventPublisher.publish(any(NewContentNewsletterEmailSent::class)) } + } + + private fun subscribersResponse(): SubscribersResponse { + return SubscriberStub.dummyRandomSubscribersList().toList() + .map { + SubscriberResponse( + id = it.id.value.toString(), + email = it.email.value, + name = it.name.fullName(), + status = it.status.name, + ) + }.let { + SubscribersResponse(it) + } + } +} diff --git a/apps/backend/src/test/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSenderTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSenderTest.kt new file mode 100644 index 00000000..78793b93 --- /dev/null +++ b/apps/backend/src/test/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSenderTest.kt @@ -0,0 +1,68 @@ +package com.lyra.app.notifications.infrastructure + +import com.lyra.app.notifications.domain.EmailMessage +import com.lyra.app.notifications.domain.EmailMessageId +import com.lyra.common.domain.email.Email +import com.sendgrid.Request +import com.sendgrid.Response +import com.sendgrid.SendGrid +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.IOException +import java.util.* +import kotlinx.coroutines.runBlocking +import net.datafaker.Faker +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SendgridEmailSenderTest { + private val faker = Faker() + private val sg: SendGrid = mockk() + private lateinit var sendgridEmailSender: SendgridEmailSender + + @BeforeEach + fun setUp() { + val response: Response = mockk() + val request: Request = mockk() + sendgridEmailSender = SendgridEmailSender("SOME_API_KEY", sg) + every { sg.api(any(Request::class)) } returns response + every { response.statusCode } returns 200 + every { request.method = any() } returns Unit + } + + @Test + fun `send email`() = runBlocking { + val emailMessage = ExampleEmailMessage( + id = EmailMessageId(UUID.randomUUID().toString()), + from = Email(faker.internet().emailAddress()), + to = Email(faker.internet().emailAddress()), + subject = faker.lorem().sentence(), + body = faker.lorem().paragraph(), + ) + sendgridEmailSender.send(emailMessage) + verify(exactly = 1) { sg.api(any()) } + } + + @Test + fun `send email with error`() = runBlocking { + val emailMessage = ExampleEmailMessage( + id = EmailMessageId(UUID.randomUUID().toString()), + from = Email(faker.internet().emailAddress()), + to = Email(faker.internet().emailAddress()), + subject = faker.lorem().sentence(), + body = faker.lorem().paragraph(), + ) + every { sg.api(any()) } throws IOException("Error sending email") + sendgridEmailSender.send(emailMessage) + verify(exactly = 1) { sg.api(any()) } + } +} + +class ExampleEmailMessage( + override val id: EmailMessageId, + from: Email, + to: Email, + subject: String, + body: String +) : EmailMessage(id, from, to, subject, body) diff --git a/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt b/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt index 6f1ed798..cdc8207c 100644 --- a/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt +++ b/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt @@ -23,5 +23,15 @@ class LibraryConventionPlugin : ConventionPlugin { add("implementation", catalogBundle("kotlin-jvm")) } + +// testing { +// suites { +// // Configure the built-in test suite +// val test by getting(JvmTestSuite::class) { +// // Use JUnit Jupiter test framework +// useJUnitJupiter("5.9.1") +// } +// } +// } } } diff --git a/build-logic/spring-boot-convention/build.gradle.kts b/build-logic/spring-boot-convention/build.gradle.kts index 17ce3423..9f2a8197 100644 --- a/build-logic/spring-boot-convention/build.gradle.kts +++ b/build-logic/spring-boot-convention/build.gradle.kts @@ -16,5 +16,9 @@ gradlePlugin { id = "app.spring.boot.convention" implementationClass = "com.lyra.buildlogic.springboot.SpringBootConventionPlugin" } + register("spring-boot-library-convention") { + id = "app.spring.boot.library.convention" + implementationClass = "com.lyra.buildlogic.springboot.SpringBootLibraryConventionPlugin" + } } } diff --git a/build-logic/spring-boot-convention/src/main/kotlin/com/lyra/buildlogic/springboot/SpringBootLibraryConventionPlugin.kt b/build-logic/spring-boot-convention/src/main/kotlin/com/lyra/buildlogic/springboot/SpringBootLibraryConventionPlugin.kt new file mode 100644 index 00000000..ca4aaaed --- /dev/null +++ b/build-logic/spring-boot-convention/src/main/kotlin/com/lyra/buildlogic/springboot/SpringBootLibraryConventionPlugin.kt @@ -0,0 +1,31 @@ +package com.lyra.buildlogic.springboot + +import com.lyra.buildlogic.common.ConventionPlugin +import com.lyra.buildlogic.common.extensions.catalogBundle +import com.lyra.buildlogic.common.extensions.catalogPlugin +import com.lyra.buildlogic.common.extensions.commonExtensions +import com.lyra.buildlogic.common.extensions.commonTasks +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class SpringBootLibraryConventionPlugin : ConventionPlugin { + override fun Project.configure() { + apply(plugin = catalogPlugin("kotlin-jvm").get().pluginId) + apply(plugin = catalogPlugin("kotlin-spring").get().pluginId) + + with(extensions) { + commonExtensions() + } + + tasks.commonTasks() + + dependencies { + add("implementation", catalogBundle("spring-boot")) + + add("implementation", catalogBundle("kotlin-jvm")) + + add("testImplementation", catalogBundle("spring-boot-test")) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 0ed1d000..cf24d73e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,9 +36,6 @@ frontend { verboseModeEnabled.set(true) } -dependencies { -} - tasks.named("installFrontend") { val ciPlatformPresent = providers.environmentVariable("CI").isPresent val lockFilePath = "$projectDir/pnpm-lock.yaml" diff --git a/compose.yaml b/compose.yaml index 39549c7a..6c08ab16 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,15 @@ services: db: - image: postgres:16.1 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - "5432:5432" - volumes: - - ./infra/postgresql/data:/var/lib/postgresql/data + extends: + file: ./infra/postgresql/postgresql-compose.yml + service: postgresql +# image: postgres:16.1 +# environment: +# POSTGRES_USER: postgres +# POSTGRES_PASSWORD: postgres +# POSTGRES_DB: postgres +# ports: +# - "5432:5432" +# volumes: +# - ./infra/postgresql/data:/var/lib/postgresql/data +# - ./infra/postgresql/init-scripts:/docker-entrypoint-initdb.d diff --git a/config/detekt.yml b/config/detekt.yml index 4e846d5d..930482cd 100644 --- a/config/detekt.yml +++ b/config/detekt.yml @@ -504,7 +504,7 @@ naming: FunctionMaxLength: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] - maximumFunctionNameLength: 35 + maximumFunctionNameLength: 45 FunctionMinLength: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] @@ -862,7 +862,7 @@ style: active: true ReturnCount: active: true - max: 2 + max: 3 excludedFunctions: - 'equals' excludeLabeled: false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cda25f0d..f899100a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ kotlin = "1.9.22" springBoot = "3.2.1" springdoc = "2.3.0" -junit = "4.13.2" -klint-plugin = "12.1.0" +junit = "5.10.1" +klint-plugin = "12.0.3" detekt = "1.23.4" owasp = "9.0.8" asciidoctor = "4.0.1" @@ -25,6 +25,8 @@ gradle-updates = { module = "com.github.ben-manes:gradle-versions-plugin", versi gradle-owasp-depcheck = { module = "org.owasp:dependency-check-gradle", version.ref = "owasp" } gradle-kover = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version = "0.7.5" } gradle-asciidoctor = { module = "org.asciidoctor:asciidoctor-gradle-jvm", version.ref = "asciidoctor" } +spring-dotenv = 'me.paulschwarz:spring-dotenv:4.0.0' +sendgrid = 'com.sendgrid:sendgrid-java:4.10.1' # Detekt gradle-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } @@ -38,9 +40,11 @@ spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-s springdoc-openapi-starter-webflux-ui = { module = "org.springdoc:springdoc-openapi-starter-webflux-ui", version.ref = "springdoc" } springdoc-openapi-starter-webflux-api = { module = "org.springdoc:springdoc-openapi-starter-webflux-api", version.ref = "springdoc" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" } - +# Spring Boot Dev spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "springBoot" } spring-boot-docker-compose = { module = "org.springframework.boot:spring-boot-docker-compose", version.ref = "springBoot" } +# Spring Boot Database +spring-boot-starter-data-r2dbc = { module = "org.springframework.boot:spring-boot-starter-data-r2dbc", version.ref = "springBoot" } # Kotlin Libraries jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version = "2.16.1" } @@ -50,9 +54,11 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version = "1.7.3" } # Testing -junit = { module = "junit:junit", version.ref = "junit" } +junit = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springBoot" } reactor-test = { module = "io.projectreactor:reactor-test", version = "3.6.2" } +mockk = { module = "io.mockk:mockk", version = "1.13.9" } +faker = { module = "net.datafaker:datafaker", version = "2.0.2" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -67,4 +73,5 @@ spring-boot = ["spring-boot-starter", "spring-boot-starter-webflux", "spring-boot-starter-actuator"] spring-boot-dev = ["spring-boot-devtools", "spring-boot-docker-compose"] spring-boot-test = ["spring-boot-starter-test", "reactor-test"] -kotlin-jvm = ["kotlin-reflect", "kotlinx-coroutines-reactor", "jackson-module-kotlin", "reactor-kotlin-extensions", "reflections"] +kotlin-jvm = ["kotlin-reflect", "kotlinx-coroutines-reactor", "jackson-module-kotlin", + "reactor-kotlin-extensions", "reflections"] diff --git a/infra/postgresql/init-scripts/schema.sql b/infra/postgresql/init-scripts/schema.sql new file mode 100644 index 00000000..c1be5069 --- /dev/null +++ b/infra/postgresql/init-scripts/schema.sql @@ -0,0 +1,22 @@ +-- Define ENUM types +DROP TYPE IF EXISTS subscriber_status CASCADE; +CREATE TYPE subscriber_status AS ENUM ('ENABLED', 'DISABLED', 'BLOCKLISTED'); + +-- Create subscribers table +DROP TABLE IF EXISTS subscribers CASCADE; +CREATE TABLE subscribers ( + id uuid NOT NULL, + email TEXT NOT NULL UNIQUE, + firstname TEXT NOT NULL, + lastname TEXT, + status subscriber_status NOT NULL DEFAULT 'ENABLED', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT pk_emp_id PRIMARY KEY (id) +); + +-- Create indexes +DROP INDEX IF EXISTS idx_subs_email; +CREATE UNIQUE INDEX idx_subs_email ON subscribers(LOWER(email)); +DROP INDEX IF EXISTS idx_subs_status; +CREATE INDEX idx_subs_status ON subscribers(status); diff --git a/infra/postgresql/postgresql-compose.yml b/infra/postgresql/postgresql-compose.yml new file mode 100644 index 00000000..34825075 --- /dev/null +++ b/infra/postgresql/postgresql-compose.yml @@ -0,0 +1,12 @@ +services: + postgresql: + image: postgres:16.1 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + volumes: + - ./data:/var/lib/postgresql/data + - ./init-scripts:/docker-entrypoint-initdb.d diff --git a/settings.gradle.kts b/settings.gradle.kts index 7898b748..46da5fdd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -76,7 +76,7 @@ fun includeProject(dir: File) { fun calculateProjectName(dir: File): String { val projectName = dir.relativeTo(rootDir).path.replace("/", ":") - println("\uD83D\uDFE2 >>>>> Project name: $projectName") + println("\uD83D\uDFE2 Project name: $projectName") return if (projectName.startsWith(":")) projectName.substring(1) else projectName } diff --git a/shared/common/build.gradle.kts b/shared/common/build.gradle.kts index dd2816ac..1cfb6f0b 100644 --- a/shared/common/build.gradle.kts +++ b/shared/common/build.gradle.kts @@ -3,4 +3,19 @@ plugins { } dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.junit) + testImplementation(libs.faker) + testImplementation(libs.mockk) +} + +testing { + suites { + // Configure the built-in test suite + @Suppress("UnusedPrivateProperty") + val test by getting(JvmTestSuite::class) { + // Use JUnit Jupiter test framework + useJUnitJupiter(libs.versions.junit) + } + } } diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/AggregateRoot.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/AggregateRoot.kt new file mode 100644 index 00000000..5811b97e --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/AggregateRoot.kt @@ -0,0 +1,12 @@ +package com.lyra.common.domain + +/** + * An abstract class representing an aggregate root entity. + * + * @param ID the data type of the aggregate root's identifier + * + * @see BaseEntity + * + */ +@Suppress("unused") +abstract class AggregateRoot : BaseEntity() diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseEntity.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseEntity.kt new file mode 100644 index 00000000..36da1d89 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseEntity.kt @@ -0,0 +1,40 @@ +package com.lyra.common.domain + +import com.lyra.common.domain.bus.event.DomainEvent + +/** + * A base class for entities with a generic identifier. + * + * @param ID the type of the identifier + * @property id The unique identifier of the entity. + */ +abstract class BaseEntity { + abstract val id: ID + private val domainEvents: MutableList = mutableListOf() + + fun record(event: DomainEvent) = domainEvents.add(event) + fun pullDomainEvents(): List { + val events = domainEvents.toList() + clearDomainEvents() + return events + } + + private fun clearDomainEvents() = domainEvents.clear() + @Generated + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BaseEntity<*>) return false + + if (id != other.id) return false + if (domainEvents != other.domainEvents) return false + + return true + } + + @Generated + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + domainEvents.hashCode() + return result + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseId.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseId.kt new file mode 100644 index 00000000..52a413bf --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseId.kt @@ -0,0 +1,28 @@ +package com.lyra.common.domain + +/** + * Represents a base class for creating ID objects. + * + * @param value The value of the ID. + * @param T The type of the ID value. + * + */ +@Suppress("unused") +abstract class BaseId protected constructor(val value: T) { + init { + require(value != null) { "The id cannot be null" } + } + + @Generated + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BaseId<*>) return false + + return value == other.value + } + + @Generated + override fun hashCode(): Int = value?.hashCode() ?: 0 + @Generated + override fun toString(): String = value.toString() +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValidateValueObject.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValidateValueObject.kt new file mode 100644 index 00000000..8880766f --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValidateValueObject.kt @@ -0,0 +1,19 @@ +package com.lyra.common.domain + +/** + * Base class for value objects that are validated when created + * + * @created 2/7/23 + */ +abstract class BaseValidateValueObject protected constructor(value: T) : + BaseValueObject(value) { + init { + this.validate(value) + } + + /** + * Validates the value of the value object + * @param value the value to validate + */ + abstract fun validate(value: T) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValueObject.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValueObject.kt new file mode 100644 index 00000000..32668560 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValueObject.kt @@ -0,0 +1,42 @@ +package com.lyra.common.domain + +import java.io.Serializable + +/** + * Base class for value objects that are validated when created + * + * @created 2/7/23 + */ +@Suppress("SerialVersionUIDInSerializableClass") +abstract class BaseValueObject protected constructor(val value: T) : Serializable { + + /** + * Compares two value objects by their value + */ + @Generated + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BaseValueObject<*>) return false + + if (value != other.value) return false + + return true + } + + /** + * Returns the hash code of the value object + */ + @Generated + override fun hashCode(): Int = value?.hashCode() ?: 0 + + /** + * Returns the string representation of the value object + */ + @Generated + override fun toString(): String = value.toString() + + @Suppress("serialVersionUID") + companion object { + private const val serialVersionUID = -68L + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Container.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Container.kt new file mode 100644 index 00000000..01b2215c --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Container.kt @@ -0,0 +1,55 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import com.lyra.common.domain.bus.command.CommandProvider +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.command.CommandWithResultHandler +import com.lyra.common.domain.bus.command.CommandWithResultProvider +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import com.lyra.common.domain.bus.notification.NotificationProvider +import com.lyra.common.domain.bus.pipeline.PipelineBehavior +import com.lyra.common.domain.bus.pipeline.PipelineProvider +import com.lyra.common.domain.bus.query.Query +import com.lyra.common.domain.bus.query.QueryHandler +import com.lyra.common.domain.bus.query.QueryProvider + +@Suppress("UNCHECKED_CAST") +internal class Container(dependencyProvider: DependencyProvider) : Registrar() { + val commandMap = HashMap, CommandProvider>>() + val queryMap = HashMap, QueryProvider>>() + val notificationMap = + HashMap, MutableList>>>() + val pipelineSet = HashSet>() + val commandWithResultMap = HashMap, CommandWithResultProvider<*>>() + + init { + + registerFor, *>, Query<*>>(dependencyProvider) { key, value -> + queryMap[key] = QueryProvider(dependencyProvider, value as Class>) + } + + registerFor, Command>(dependencyProvider) { key, value -> + commandMap[key] = CommandProvider(dependencyProvider, value) + } + + registerFor, *>, CommandWithResult<*>>( + dependencyProvider, + ) { key, value -> + commandWithResultMap[key] = CommandWithResultProvider( + dependencyProvider, + value as Class>, + ) + } + + registerFor, Notification>(dependencyProvider) { key, value -> + notificationMap.getOrPut(key) { mutableListOf() } + .add(NotificationProvider(dependencyProvider, value as Class>)) + } + + registerFor(dependencyProvider) { handler -> + pipelineSet.add(PipelineProvider(dependencyProvider, handler)) + } + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/DependencyProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/DependencyProvider.kt new file mode 100644 index 00000000..b9e4ff2b --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/DependencyProvider.kt @@ -0,0 +1,7 @@ +package com.lyra.common.domain.bus + +interface DependencyProvider { + fun getSingleInstanceOf(clazz: Class): T + + fun getSubTypesOf(clazz: Class): Collection> +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/HandlerNotFoundException.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/HandlerNotFoundException.kt new file mode 100644 index 00000000..c2d7b3a0 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/HandlerNotFoundException.kt @@ -0,0 +1,3 @@ +package com.lyra.common.domain.bus + +class HandlerNotFoundException(message: String) : Exception(message) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Mediator.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Mediator.kt new file mode 100644 index 00000000..5c01e445 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Mediator.kt @@ -0,0 +1,25 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.query.Query + +interface Mediator { + + suspend fun , TResponse> send(query: TQuery): TResponse + + suspend fun send(command: TCommand) + + suspend fun , TResult> send(command: TCommand): TResult + + /** + * Publishes the given notification to appropriate notification handlers + * + * @since 1.0.9 + * @param T any [Notification] subclass to publish + */ + suspend fun publish(notification: T) + + suspend fun publish(notification: T, publishStrategy: PublishStrategy) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorBuilder.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorBuilder.kt new file mode 100644 index 00000000..8d0f668c --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorBuilder.kt @@ -0,0 +1,27 @@ +package com.lyra.common.domain.bus + +class MediatorBuilder( + private val dependencyProvider: DependencyProvider, +) { + internal var defaultPublishStrategy: PublishStrategy = StopOnExceptionPublishStrategy() + private set + + /** + * Overrides default notification publishing strategy. + * Default strategy is [StopOnExceptionPublishStrategy] + * + * @since 1.0.9 + * @see [PublishStrategy] + * @see [ContinueOnExceptionPublishStrategy] + * @see [StopOnExceptionPublishStrategy] + * @see [ParallelNoWaitPublishStrategy] + * @see [ParallelWhenAllPublishStrategy] + */ + fun withPublishStrategy(publishStrategy: PublishStrategy): MediatorBuilder { + this.defaultPublishStrategy = publishStrategy + return this + } + + fun build(registry: Registry = RegistryImpl(dependencyProvider)): Mediator = + MediatorImpl(registry, defaultPublishStrategy) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorImpl.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorImpl.kt new file mode 100644 index 00000000..410a0d67 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorImpl.kt @@ -0,0 +1,64 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.pipeline.PipelineBehavior +import com.lyra.common.domain.bus.query.Query + +class MediatorImpl( + private val registry: Registry, + private val defaultPublishStrategy: PublishStrategy = StopOnExceptionPublishStrategy(), +) : Mediator { + + override suspend fun , TResponse> send(query: TQuery): TResponse = + processPipeline( + registry.getPipelineBehaviors(), + query, + ) { + registry.resolveQueryHandler(query.javaClass).handle(query) + } + + override suspend fun send(command: TCommand) = processPipeline( + registry.getPipelineBehaviors(), + command, + ) { + registry.resolveCommandHandler(command.javaClass).handle(command) + } + + override suspend fun , TResult> send(command: TCommand): TResult = + processPipeline( + registry.getPipelineBehaviors(), + command, + ) { + registry.resolveCommandWithResultHandler(command.javaClass).handle(command) + } + + override suspend fun publish(notification: T) = + publish(notification, defaultPublishStrategy) + + override suspend fun publish( + notification: T, + publishStrategy: PublishStrategy, + ) = processPipeline( + registry.getPipelineBehaviors(), + notification, + ) { + publishStrategy.publish( + notification, + registry.resolveNotificationHandlers(notification.javaClass), + ) + } + + private suspend fun processPipeline( + pipelineBehaviors: Collection, + request: TRequest, + handler: RequestHandlerDelegate, + ): TResponse = pipelineBehaviors + .reversed() + .fold(handler) { next, pipeline -> + { + pipeline.handle(request) { next(it) } + } + }(request) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategies.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategies.kt new file mode 100644 index 00000000..42f7e69e --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategies.kt @@ -0,0 +1,83 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import com.lyra.common.domain.error.AggregateException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ContinueOnExceptionPublishStrategy : PublishStrategy { + + override suspend fun publish( + notification: T, + notificationHandlers: Collection>, + dispatcher: CoroutineDispatcher, + ) { + coroutineScope { + withContext(dispatcher) { + val exceptions = mutableListOf() + @Suppress("TooGenericExceptionCaught") + notificationHandlers.forEach { + try { + it.handle(notification) + } catch (e: Exception) { + exceptions.add(e) + } + } + if (exceptions.isNotEmpty()) { + throw AggregateException(exceptions) + } + } + } + } +} + +class StopOnExceptionPublishStrategy : PublishStrategy { + + override suspend fun publish( + notification: T, + notificationHandlers: Collection>, + dispatcher: CoroutineDispatcher, + ) { + coroutineScope { + withContext(dispatcher) { + notificationHandlers.forEach { it.handle(notification) } + } + } + } +} + +class ParallelNoWaitPublishStrategy : PublishStrategy { + + override suspend fun publish( + notification: T, + notificationHandlers: Collection>, + dispatcher: CoroutineDispatcher, + ) { + coroutineScope { + withContext(dispatcher) { + notificationHandlers.forEach { launch { it.handle(notification) } } + } + } + } +} + +class ParallelWhenAllPublishStrategy : PublishStrategy { + + override suspend fun publish( + notification: T, + notificationHandlers: Collection>, + dispatcher: CoroutineDispatcher, + ) { + coroutineScope { + withContext(dispatcher) { + val deferredResults = notificationHandlers.map { async { it.handle(notification) } } + deferredResults.awaitAll() + } + } + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategy.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategy.kt new file mode 100644 index 00000000..f5785d0a --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategy.kt @@ -0,0 +1,15 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +interface PublishStrategy { + + suspend fun publish( + notification: T, + notificationHandlers: Collection>, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registrar.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registrar.kt new file mode 100644 index 00000000..6dea44ad --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registrar.kt @@ -0,0 +1,76 @@ +package com.lyra.common.domain.bus + +import java.lang.reflect.ParameterizedType +import java.lang.reflect.TypeVariable + +@Suppress("UNCHECKED_CAST") +abstract class Registrar { + + protected inline fun registerFor( + dependencyProvider: DependencyProvider, + registrar: (key: Class, value: Class) -> Unit, + ) = dependencyProvider.getSubTypesOf(THandler::class.java).forEach { + registerFor(it) { key, value -> + registrar(key as Class, value as Class) + } + } + + protected inline fun registerFor( + handler: Class<*>, + registrar: (key: Class<*>, value: Class<*>) -> Unit, + ) { + val interfaceOrBaseClass = THandler::class.java + if (!interfaceOrBaseClass.isAssignableFrom(handler)) return + + handler.genericInterfaces + .filterIsInstance() + .map { extractParameter(it) } + .forEach { registrar(it, handler) } + + when (handler.genericSuperclass) { + is ParameterizedType -> { + val inheritedHandler = (handler.genericSuperclass as ParameterizedType).rawType as Class<*> + inheritedHandler.genericInterfaces + .filterIsInstance() + .map { extractParameter(handler.genericSuperclass as ParameterizedType) } + .forEach { registrar(it, handler) } + } + + is Class<*> -> { + val inheritedHandler = handler.genericSuperclass as Class<*> + if (interfaceOrBaseClass.isAssignableFrom(inheritedHandler)) { + inheritedHandler.genericInterfaces + .filterIsInstance() + .map { extractParameter(it) } + .forEach { registrar(it, handler) } + } + } + } + } + + protected inline fun registerFor( + dependencyProvider: DependencyProvider, + registrar: (value: Class) -> Unit, + ) = dependencyProvider.getSubTypesOf(T::class.java).forEach { handler -> + registerFor(handler) { value -> registrar(value as Class) } + } + + protected inline fun registerFor( + handler: Class<*>, + registrar: (value: Class<*>) -> Unit, + ) { + val interfaceOrBaseClass = T::class.java + if (!interfaceOrBaseClass.isAssignableFrom(handler)) return + registrar(handler) + } + + protected fun extractParameter(genericInterface: ParameterizedType): Class<*> = + when (val typeArgument = genericInterface.actualTypeArguments[0]) { + is ParameterizedType -> typeArgument.rawType as Class<*> + is TypeVariable<*> -> extractParameter( + (genericInterface.rawType as Class<*>) + .genericInterfaces[0] as ParameterizedType, + ) + else -> typeArgument as Class<*> + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registry.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registry.kt new file mode 100644 index 00000000..ad01cdcf --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registry.kt @@ -0,0 +1,30 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.command.CommandWithResultHandler +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import com.lyra.common.domain.bus.pipeline.PipelineBehavior +import com.lyra.common.domain.bus.query.Query +import com.lyra.common.domain.bus.query.QueryHandler + +interface Registry { + + fun resolveCommandHandler(classOfCommand: Class): CommandHandler + + fun , TResult> resolveCommandWithResultHandler( + classOfCommand: Class, + ): CommandWithResultHandler + + fun , TResult> resolveQueryHandler( + classOfQuery: Class, + ): QueryHandler + + fun resolveNotificationHandlers( + classOfNotification: Class, + ): Collection> + + fun getPipelineBehaviors(): Collection +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RegistryImpl.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RegistryImpl.kt new file mode 100644 index 00000000..0944fa8b --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RegistryImpl.kt @@ -0,0 +1,53 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.command.CommandWithResultHandler +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import com.lyra.common.domain.bus.pipeline.PipelineBehavior +import com.lyra.common.domain.bus.query.Query +import com.lyra.common.domain.bus.query.QueryHandler + +class RegistryImpl( + dependencyProvider: DependencyProvider, +) : Registry { + + private val registry = Container(dependencyProvider) + + override fun resolveCommandHandler( + classOfCommand: Class, + ): CommandHandler { + val handler = registry.commandMap[classOfCommand]?.get() + ?: throw HandlerNotFoundException("handler could not be found for ${classOfCommand.name}") + return handler as CommandHandler + } + + override fun , TResult> resolveCommandWithResultHandler( + classOfCommand: Class, + ): CommandWithResultHandler { + val handler = registry.commandWithResultMap[classOfCommand]?.get() + ?: throw HandlerNotFoundException("handler could not be found for ${classOfCommand.name}") + return handler as CommandWithResultHandler + } + + override fun resolveNotificationHandlers( + classOfNotification: Class, + ): Collection> = + registry.notificationMap.filter { (k, _) -> k.isAssignableFrom(classOfNotification) } + .flatMap { (_, v) -> v.map { it.get() as NotificationHandler } } + + override fun , TResult> resolveQueryHandler( + classOfQuery: Class, + ): QueryHandler { + val handler = registry.queryMap[classOfQuery]?.get() + ?: throw HandlerNotFoundException("handler could not be found for ${classOfQuery.name}") + return handler as QueryHandler + } + + override fun getPipelineBehaviors(): Collection = + registry.pipelineSet.map { it.get() } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RequestHandlerDelegate.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RequestHandlerDelegate.kt new file mode 100644 index 00000000..883d08b2 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RequestHandlerDelegate.kt @@ -0,0 +1,6 @@ +package com.lyra.common.domain.bus + +/** + * RequestHandlerDelegate represents to Command, Query or Notification handler + */ +typealias RequestHandlerDelegate = suspend (TRequest) -> TResponse diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/Command.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/Command.kt new file mode 100644 index 00000000..7c88c9ab --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/Command.kt @@ -0,0 +1,7 @@ +package com.lyra.common.domain.bus.command + +/** + * + * @created 7/1/24 + */ +interface Command diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandler.kt new file mode 100644 index 00000000..ce6b604b --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandler.kt @@ -0,0 +1,5 @@ +package com.lyra.common.domain.bus.command + +interface CommandHandler { + suspend fun handle(command: T) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandlerExecutionError.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandlerExecutionError.kt new file mode 100644 index 00000000..20951ecf --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandlerExecutionError.kt @@ -0,0 +1,3 @@ +package com.lyra.common.domain.bus.command + +class CommandHandlerExecutionError(cause: Throwable) : RuntimeException(cause) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandProvider.kt new file mode 100644 index 00000000..51aed473 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandProvider.kt @@ -0,0 +1,30 @@ +package com.lyra.common.domain.bus.command + +import com.lyra.common.domain.bus.DependencyProvider + +/** + * CommandProvider creates a command handler with enabled spring injection. + * + * @param type of handler + */ +internal class CommandProvider>( + private val dependencyProvider: DependencyProvider, + private val type: Class, +) { + + fun get(): H = dependencyProvider.getSingleInstanceOf(type) +} + +/** + * CommandProvider creates a command handler with enabled spring injection. + * + * @since 1.0.16 + * @param type of handler + */ +internal class CommandWithResultProvider>( + private val dependencyProvider: DependencyProvider, + private val type: Class, +) { + + fun get(): H = dependencyProvider.getSingleInstanceOf(type) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResult.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResult.kt new file mode 100644 index 00000000..8a44da1f --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResult.kt @@ -0,0 +1,9 @@ +package com.lyra.common.domain.bus.command + +/** + * Marker interface for a command with result + * + * @since 1.0.16 + * @see CommandHandler + */ +interface CommandWithResult diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResultHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResultHandler.kt new file mode 100644 index 00000000..6d4b9c70 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResultHandler.kt @@ -0,0 +1,17 @@ +package com.lyra.common.domain.bus.command + +/** + * Interface to be implemented for a non-blocking command with result handler + * + * @since 1.0.16 + * @param TCommand any [Command] subclass to handle + * @see Command + */ +interface CommandWithResultHandler, TResult> { + /** + * Handles a command + * + * @param command the command to handle + */ + suspend fun handle(command: TCommand): TResult +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/BaseDomainEvent.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/BaseDomainEvent.kt new file mode 100644 index 00000000..c8dfc780 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/BaseDomainEvent.kt @@ -0,0 +1,33 @@ +package com.lyra.common.domain.bus.event + +import com.lyra.common.domain.Generated +import java.time.LocalDateTime + +/** + * Represents a base domain event. + * + * All domain events in the system should extend this class. + * + * @property occuredOn The date and time when the event occured. Defaults to the current date and time if not specified. + * + * + */ +open class BaseDomainEvent(private val occuredOn: LocalDateTime = LocalDateTime.now()) : DomainEvent { + private var eventVersion = 1 + + /** + * Returns the version of the event. + * + * @return The version of the event. + */ + @Generated + override fun eventVersion(): Int = eventVersion + + /** + * Returns the LocalDateTime when this event occurred. + * + * @return the LocalDateTime when this event occurred + */ + @Generated + override fun occurredOn(): LocalDateTime = occuredOn +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/DomainEvent.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/DomainEvent.kt new file mode 100644 index 00000000..796c0853 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/DomainEvent.kt @@ -0,0 +1,23 @@ +package com.lyra.common.domain.bus.event + +import java.time.LocalDateTime + +/** + * Represents a domain event. + * + */ +interface DomainEvent { + /** + * Returns the version of the event. + * + * @return The version of the event. + */ + fun eventVersion(): Int + + /** + * Returns the LocalDateTime when this event occurred. + * + * @return the LocalDateTime when this event occurred + */ + fun occurredOn(): LocalDateTime? +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventBroadcaster.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventBroadcaster.kt new file mode 100644 index 00000000..6004b51a --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventBroadcaster.kt @@ -0,0 +1,31 @@ +package com.lyra.common.domain.bus.event + +/** + * Class representing an EventBroadcaster. + * + * @param E the type of event being broadcasted + * + */ +class EventBroadcaster : EventPublisher { + private val eventPublishers = mutableListOf>() + + /** + * Adds an EventPublisher to the list of event publishers. + * + * @param eventPublisher The EventPublisher to be added. + */ + fun use(eventPublisher: EventPublisher) { + eventPublishers.add(eventPublisher) + } + + /** + * Publishes the given event to all event publishers. + * + * @param event the event to be published + */ + override suspend fun publish(event: E) { + eventPublishers.forEach { + it.publish(event) + } + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventConsumer.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventConsumer.kt new file mode 100644 index 00000000..fe7d80d5 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventConsumer.kt @@ -0,0 +1,16 @@ +package com.lyra.common.domain.bus.event + +/** + * Functional interface for consuming events of type [E]. + * + * @param E the type of event to be consumed. + * + */ +fun interface EventConsumer { + /** + * Consume method for handling events. + * + * @param event The event to be consumed. + */ + suspend fun consume(event: E) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventFilter.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventFilter.kt new file mode 100644 index 00000000..a2999276 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventFilter.kt @@ -0,0 +1,21 @@ +package com.lyra.common.domain.bus.event + +/** + * Represents an event filter. + * + * An event filter is a functional interface that defines a filter for events of a specific type. + * The EventFilter interface provides a single method `filter(event: E): Boolean` that accepts an event of type E + * and returns a boolean value indicating whether the event passes the filter. + * + * @param E the type of events that can be filtered + * + */ +fun interface EventFilter { + /** + * Filters the given event based on certain conditions. + * + * @param event The event to be filtered. + * @return True if the event passes the filtering conditions, false otherwise. + */ + suspend fun filter(event: E): Boolean +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventMultiplexer.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventMultiplexer.kt new file mode 100644 index 00000000..683e764b --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventMultiplexer.kt @@ -0,0 +1,36 @@ +package com.lyra.common.domain.bus.event + +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter + +/** + * EventMultiplexer class is responsible for multiplexing events to multiple consumers based on provided filters. + * It implements the EventConsumer interface and consumes events of a specific type. + * + * @param E The type of events this multiplexer consumes. + * + */ +class EventMultiplexer : EventConsumer { + private val consumers = mutableListOf, EventConsumer>>() + + /** + * Adds a consumer to the list of consumers. + * + * @param filter The filter to be used for this consumer. + * @param consumer The consumer to be added. + */ + fun on(filter: EventFilter, consumer: EventConsumer) { + consumers.add(filter to consumer) + } + + /** + * Consumes the given event by passing it to all consumers that pass the filter. + * + * @param event The event to be consumed. + */ + override suspend fun consume(event: E) { + consumers.asFlow() + .filter { (filter, _) -> filter.filter(event) } + .collect { (_, consumer) -> consumer.consume(event) } + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventPublisher.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventPublisher.kt new file mode 100644 index 00000000..1504fd18 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventPublisher.kt @@ -0,0 +1,23 @@ +package com.lyra.common.domain.bus.event + +/** + * Represents an event publisher that is capable of publishing events. + * + * @param E the type of event to be published + * + */ +interface EventPublisher { + /** + * Publishes the given event. + * + * @param event The event to be published. + */ + suspend fun publish(event: E) + + /** + * Publishes multiple events. + * + * @param events The events to be published. + */ + suspend fun publish(events: List) = events.forEach { publish(it) } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/Subscribe.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/Subscribe.kt new file mode 100644 index 00000000..9675c37f --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/Subscribe.kt @@ -0,0 +1,18 @@ +package com.lyra.common.domain.bus.event + +import kotlin.reflect.KClass + +/** + * Annotation used to mark a method as a subscriber for events. + * + * This annotation should be applied to methods that should be invoked when events of a certain type are + * published. The annotated method should have exactly one parameter to receive the event object. + * + * @param filterBy Specifies the type of events that the annotated method should subscribe to. + * Only events of the specified type or its subclasses will be delivered to the method. + * If no type is specified, the method will be subscribed to all events. + * + */ +annotation class Subscribe( + val filterBy: KClass<*> +) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/TypeMatchEventFilter.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/TypeMatchEventFilter.kt new file mode 100644 index 00000000..03ab650f --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/TypeMatchEventFilter.kt @@ -0,0 +1,22 @@ +package com.lyra.common.domain.bus.event + +import kotlin.reflect.KClass + +/** + * A type match event filter that determines if an event is an instance of a specific class. + * + * @param T the type of events to filter + * @property clazz the class to match against events + * @constructor Creates a new instance of [TypeMatchEventFilter] with the given class. + * @see EventFilter + * @see EventPublisher + * @see EventConsumer + * @see EventMultiplexer + * + * + */ +class TypeMatchEventFilter( + private val clazz: KClass +) : EventFilter { + override suspend fun filter(event: T): Boolean = clazz.isInstance(event) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/Notification.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/Notification.kt new file mode 100644 index 00000000..11fa8836 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/Notification.kt @@ -0,0 +1,8 @@ +package com.lyra.common.domain.bus.notification +/** + * Marker interface for a notification + * + * @since 1.0.9 + * @see NotificationHandler + */ +interface Notification diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationHandler.kt new file mode 100644 index 00000000..5e870ba7 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationHandler.kt @@ -0,0 +1,17 @@ +package com.lyra.common.domain.bus.notification +/** + * Interface to be implemented for a non-blocking notification handler + * + * @since 1.0.9 + * @param T any [Notification] subclass to handle + * @see Notification + */ +interface NotificationHandler where T : Notification { + + /** + * Handles a notification + * + * @param notification the notification to handle + */ + suspend fun handle(notification: T) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationProvider.kt new file mode 100644 index 00000000..c05c70a4 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationProvider.kt @@ -0,0 +1,11 @@ +package com.lyra.common.domain.bus.notification + +import com.lyra.common.domain.bus.DependencyProvider + +internal class NotificationProvider>( + private val dependencyProvider: DependencyProvider, + private val type: Class, +) { + + fun get(): H = dependencyProvider.getSingleInstanceOf(type) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineBehavior.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineBehavior.kt new file mode 100644 index 00000000..323c8151 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineBehavior.kt @@ -0,0 +1,21 @@ +package com.lyra.common.domain.bus.pipeline + +import com.lyra.common.domain.bus.RequestHandlerDelegate + +/** + * Interface to be implemented for a non-blocking pipeline behavior + * + * @since 1.0.12 + */ +interface PipelineBehavior { + /** + * Process to invoke before handling any query, command or notification + * + * @param request the request to handle + * @param next the represents the CommandHandler handle function + */ + suspend fun handle( + request: TRequest, + next: RequestHandlerDelegate + ): TResponse +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineProvider.kt new file mode 100644 index 00000000..1b13f0c1 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineProvider.kt @@ -0,0 +1,16 @@ +package com.lyra.common.domain.bus.pipeline + +import com.lyra.common.domain.bus.DependencyProvider + +/** + * PipelineProvider creates a pipeline behavior with enabled spring injection. + * + * @param type of pipeline behavior + */ +internal class PipelineProvider( + private val dependencyProvider: DependencyProvider, + private val type: Class, +) { + + fun get(): H = dependencyProvider.getSingleInstanceOf(type) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Query.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Query.kt new file mode 100644 index 00000000..5f871852 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Query.kt @@ -0,0 +1,7 @@ +package com.lyra.common.domain.bus.query + +/** + * + * @created 7/1/24 + */ +interface Query diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandler.kt new file mode 100644 index 00000000..94d74967 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandler.kt @@ -0,0 +1,17 @@ +package com.lyra.common.domain.bus.query + +/** + * Interface to be implemented for a non-blocking query handler + * + * @since 1.0.0 + * @param TQuery any [Query] subclass to handle + * @see Query + */ +interface QueryHandler, TResponse> { + /** + * Handles a query + * + * @param query the query to handle + */ + suspend fun handle(query: TQuery): TResponse +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandlerExecutionError.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandlerExecutionError.kt new file mode 100644 index 00000000..35eff9d6 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandlerExecutionError.kt @@ -0,0 +1,3 @@ +package com.lyra.common.domain.bus.query + +class QueryHandlerExecutionError(cause: Throwable) : RuntimeException(cause) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryProvider.kt new file mode 100644 index 00000000..d4c0f007 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryProvider.kt @@ -0,0 +1,16 @@ +package com.lyra.common.domain.bus.query + +import com.lyra.common.domain.bus.DependencyProvider + +/** + * QueryProvider creates a query handler with enabled spring injection. + * + * @param type of handler + */ +internal class QueryProvider>( + private val dependencyProvider: DependencyProvider, + private val type: Class, +) { + + fun get(): H = dependencyProvider.getSingleInstanceOf(type) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Response.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Response.kt new file mode 100644 index 00000000..d1300c5e --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Response.kt @@ -0,0 +1,7 @@ +package com.lyra.common.domain.bus.query + +/** + * + * @created 7/1/24 + */ +interface Response diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/Criteria.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/Criteria.kt new file mode 100644 index 00000000..f8db2f27 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/Criteria.kt @@ -0,0 +1,90 @@ +package com.lyra.common.domain.criteria + +import java.util.regex.Pattern + +sealed class Criteria { + object Empty : Criteria() { + override fun toString(): String = "()" + } + + data class And(val value: List) : Criteria() { + override fun toString(): String = "(${value.joinToString(" AND ") { it.toString() }})" + } + + data class Or(val value: List) : Criteria() { + override fun toString(): String = "(${value.joinToString(" OR ") { it.toString() }})" + } + + data class Equals(val key: String, val value: Any) : Criteria() { + override fun toString(): String = "$key = $value" + } + + data class NotEquals(val key: String, val value: Any) : Criteria() { + override fun toString(): String = "$key != $value" + } + + data class Between(val key: String, val value: ClosedRange<*>) : Criteria() { + override fun toString(): String = "($key >= ${value.start} && $key <= ${value.endInclusive})" + } + + data class NotBetween(val key: String, val value: ClosedRange<*>) : Criteria() { + override fun toString(): String = "($key < ${value.start} || $key > ${value.endInclusive})" + } + + data class LessThan(val key: String, val value: Any) : Criteria() { + override fun toString(): String = "$key < $value" + } + + data class LessThanEquals(val key: String, val value: Any) : Criteria() { + override fun toString(): String = "$key <= $value" + } + + data class GreaterThan(val key: String, val value: Any) : Criteria() { + override fun toString(): String = "$key > $value" + } + + data class GreaterThanEquals(val key: String, val value: Any) : Criteria() { + override fun toString(): String = "$key >= $value" + } + + data class IsNull(val key: String) : Criteria() { + override fun toString(): String = "$key = null" + } + + data class IsNotNull(val key: String) : Criteria() { + override fun toString(): String = "$key != null" + } + + data class Like(val key: String, val value: String) : Criteria() { + override fun toString(): String = "$key LIKE $value" + } + + data class NotLike(val key: String, val value: String) : Criteria() { + override fun toString(): String = "$key NOT LIKE $value" + } + + data class Regexp(val key: String, val value: Pattern) : Criteria() { + override fun toString(): String = "$key REGEXP $value" + } + + data class NotRegexp(val key: String, val value: Pattern) : Criteria() { + override fun toString(): String = "$key NOT REGEXP $value" + } + + data class In(val key: String, val value: List) : Criteria() { + override fun toString(): String = + "$key IN [${value.joinToString { it?.toString() ?: "null" }}]" + } + + data class NotIn(val key: String, val value: List) : Criteria() { + override fun toString(): String = + "$key NOT IN [${value.joinToString { it?.toString() ?: "null" }}]" + } + data class IsTrue(val key: String) : Criteria() { + override fun toString(): String = "$key IS TRUE" + } + + data class IsFalse(val key: String) : Criteria() { + override fun toString(): String = "$key IS FALSE" + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaParser.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaParser.kt new file mode 100644 index 00000000..190d1da5 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaParser.kt @@ -0,0 +1,9 @@ +package com.lyra.common.domain.criteria + +/** + * + * @created 10/1/24 + */ +fun interface CriteriaParser { + fun parse(criteria: Criteria): Out +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaStep.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaStep.kt new file mode 100644 index 00000000..d4ba941f --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaStep.kt @@ -0,0 +1,56 @@ +package com.lyra.common.domain.criteria + +/** + * + * @created 10/1/24 + */ + +import java.util.regex.Pattern +import kotlin.reflect.KProperty1 + +@Suppress("TooManyFunctions") +data class CriteriaStep( + val key: String +) { + @Suppress("FunctionNaming") + fun `is`(value: Any) = Criteria.Equals(key, value) + fun not(value: Any) = Criteria.NotEquals(key, value) + + fun between(value: ClosedRange<*>) = Criteria.Between(key, value) + fun notBetween(value: ClosedRange<*>) = Criteria.NotBetween(key, value) + + fun lessThan(value: Any) = Criteria.LessThan(key, value) + fun lessThanOrEquals(value: Any) = Criteria.LessThanEquals(key, value) + + fun greaterThan(value: Any) = Criteria.GreaterThan(key, value) + fun greaterThanOrEquals(value: Any) = Criteria.GreaterThanEquals(key, value) + + fun isNull() = Criteria.IsNull(key) + fun isNotNull() = Criteria.IsNotNull(key) + + fun like(value: String) = Criteria.Like(key, value) + fun notLike(value: String) = Criteria.NotLike(key, value) + + fun regexp(value: Pattern) = Criteria.Regexp(key, value) + fun notRegexp(value: Pattern) = Criteria.NotRegexp(key, value) + @Suppress("FunctionNaming") + fun `in`(vararg value: Any) = Criteria.In(key, value.toList()) + fun notIn(vararg value: Any) = Criteria.NotIn(key, value.toList()) + @Suppress("FunctionNaming") + fun `in`(value: List) = Criteria.In(key, value) + fun notIn(value: List) = Criteria.NotIn(key, value) + + fun isTrue() = Criteria.IsTrue(key) + fun isFalse() = Criteria.IsFalse(key) +} + +fun where(property: KProperty1) = CriteriaStep(property.name) +fun where(key: String) = CriteriaStep(key) + +fun Criteria.and(value: Criteria) = Criteria.And(mutableListOf(this).apply { add(value) }) +fun Criteria.and(value: Collection) = + Criteria.And(mutableListOf(this).apply { addAll(value) }) + +fun Criteria.or(value: Criteria) = Criteria.Or(mutableListOf(this).apply { add(value) }) +fun Criteria.or(value: Collection) = + Criteria.Or(mutableListOf(this).apply { addAll(value) }) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParser.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParser.kt new file mode 100644 index 00000000..0cacf73e --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParser.kt @@ -0,0 +1,270 @@ +package com.lyra.common.domain.criteria + +import com.lyra.common.domain.regexp.SqlLikeTranspiler +import java.util.regex.Pattern +import kotlin.reflect.KClass +import kotlin.reflect.full.memberProperties + +/** + * + * @created 10/1/24 + */ +@Suppress("MethodOverloading") +class RuntimeCriteriaParser( + clazz: KClass +) : CriteriaParser<((T) -> Boolean)?> { + private val properties = clazz.memberProperties.associateBy { it.name } + + @Suppress("CyclomaticComplexMethod", "CognitiveComplexMethod") + override fun parse(criteria: Criteria): ((T) -> Boolean)? { + return when (criteria) { + is Criteria.Empty -> null + is Criteria.And -> parse(criteria) + is Criteria.Or -> parse(criteria) + is Criteria.Equals -> parse(criteria) + is Criteria.NotEquals -> parse(criteria) + is Criteria.Between -> parse(criteria) + is Criteria.NotBetween -> parse(criteria) + is Criteria.LessThan -> parse(criteria) + is Criteria.LessThanEquals -> parse(criteria) + is Criteria.GreaterThan -> parse(criteria) + is Criteria.GreaterThanEquals -> parse(criteria) + is Criteria.IsNull -> parse(criteria) + is Criteria.IsNotNull -> parse(criteria) + is Criteria.Like -> parse(criteria) + is Criteria.NotLike -> parse(criteria) + is Criteria.Regexp -> parse(criteria) + is Criteria.NotRegexp -> parse(criteria) + is Criteria.In -> parse(criteria) + is Criteria.NotIn -> parse(criteria) + is Criteria.IsTrue -> parse(criteria) + is Criteria.IsFalse -> parse(criteria) + } + } + + private fun parse(criteria: Criteria.And): ((T) -> Boolean)? { + if (criteria.value.isEmpty()) { + return null + } + return criteria.value.mapNotNull { parse(it) } + .reduce { acc, cur -> { acc(it) && cur(it) } } + } + + private fun parse(criteria: Criteria.Or): ((T) -> Boolean)? { + if (criteria.value.isEmpty()) { + return null + } + return criteria.value.mapNotNull { parse(it) } + .reduce { acc, cur -> { acc(it) || cur(it) } } + } + + private fun parse(criteria: Criteria.Equals): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it) == criteria.value + } ?: false + } + } + + private fun parse(criteria: Criteria.NotEquals): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it) != criteria.value + } ?: false + } + } + + private fun parse(criteria: Criteria.Between): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it)?.let { value -> + if (value is Comparable<*>) { + @Suppress("UNCHECKED_CAST") + value as Comparable + value >= criteria.value.start && value <= criteria.value.endInclusive + } else { + false + } + } ?: false + } ?: false + } + } + + private fun parse(criteria: Criteria.NotBetween): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it)?.let { value -> + if (value is Comparable<*>) { + @Suppress("UNCHECKED_CAST") + value as Comparable + value < criteria.value.start || value > criteria.value.endInclusive + } else { + false + } + } ?: true + } ?: false + } + } + + private fun parse(criteria: Criteria.LessThan): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it)?.let { value -> + if (value is Comparable<*>) { + @Suppress("UNCHECKED_CAST") + value as Comparable + value < criteria.value + } else { + false + } + } ?: false + } ?: false + } + } + + private fun parse(criteria: Criteria.LessThanEquals): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it)?.let { value -> + if (value is Comparable<*>) { + @Suppress("UNCHECKED_CAST") + value as Comparable + value <= criteria.value + } else { + false + } + } ?: false + } ?: false + } + } + + private fun parse(criteria: Criteria.GreaterThan): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it)?.let { value -> + if (value is Comparable<*>) { + @Suppress("UNCHECKED_CAST") + value as Comparable + value > criteria.value + } else { + false + } + } ?: false + } ?: false + } + } + + private fun parse(criteria: Criteria.GreaterThanEquals): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it)?.let { value -> + if (value is Comparable<*>) { + @Suppress("UNCHECKED_CAST") + value as Comparable + value >= criteria.value + } else { + false + } + } ?: false + } ?: false + } + } + + private fun parse(criteria: Criteria.IsNull): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it) == null + } ?: false + } + } + + private fun parse(criteria: Criteria.IsNotNull): (T) -> Boolean { + return { + properties[criteria.key]?.let { property -> + property.get(it) != null + } ?: false + } + } + + private fun parse(criteria: Criteria.Like): (T) -> Boolean { + val pattern = Pattern.compile(SqlLikeTranspiler.toRegEx(criteria.value)) + return { + properties[criteria.key]?.get(it)?.let { value -> + if (value is CharSequence) { + pattern.matcher(value).find() + } else { + false + } + } ?: false + } + } + + private fun parse(criteria: Criteria.NotLike): (T) -> Boolean { + val pattern = Pattern.compile(SqlLikeTranspiler.toRegEx(criteria.value)) + return { + properties[criteria.key]?.get(it)?.let { value -> + if (value is CharSequence) { + !pattern.matcher(value).find() + } else { + false + } + } ?: false + } + } + + private fun parse(criteria: Criteria.Regexp): (T) -> Boolean { + return { + properties[criteria.key]?.get(it)?.let { value -> + if (value is CharSequence) { + criteria.value.matcher(value).find() + } else { + false + } + } ?: false + } + } + + private fun parse(criteria: Criteria.NotRegexp): (T) -> Boolean { + return { + properties[criteria.key]?.get(it)?.let { value -> + if (value is CharSequence) { + !criteria.value.matcher(value).find() + } else { + false + } + } ?: false + } + } + + private fun parse(criteria: Criteria.In): (T) -> Boolean { + return { + properties[criteria.key]?.get(it)?.let { value -> + criteria.value.contains(value) + } ?: false + } + } + + private fun parse(criteria: Criteria.NotIn): (T) -> Boolean { + return { + properties[criteria.key]?.get(it)?.let { value -> + !criteria.value.contains(value) + } ?: false + } + } + + private fun parse(criteria: Criteria.IsTrue): (T) -> Boolean { + return { + properties[criteria.key]?.get(it)?.let { value -> + value == true + } ?: false + } + } + + private fun parse(criteria: Criteria.IsFalse): (T) -> Boolean { + return { + properties[criteria.key]?.get(it)?.let { value -> + value == false + } ?: false + } + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/email/Email.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/email/Email.kt new file mode 100644 index 00000000..046c21bc --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/email/Email.kt @@ -0,0 +1,33 @@ +package com.lyra.common.domain.email + +import com.lyra.common.domain.BaseValidateValueObject +import com.lyra.common.domain.error.EmailNotValidException + +private const val EMAIL_LEN = 255 + +/** + * Email value object + * @param email value + * @throws [EmailNotValidException] if email is not valid + * @see [BaseValidateValueObject] validate value object + * @see [BaseValueObject] base value object + * @see [EmailNotValidException] email not valid exception + */ +data class Email(val email: String) : BaseValidateValueObject(email) { + + /** + * Validate email value object with regex + * @param value email value + * @throws EmailNotValidException if email is not valid + */ + override fun validate(value: String) { + if (value.length > EMAIL_LEN || !value.matches(REGEX.toRegex())) { + throw EmailNotValidException(value) + } + } + companion object { + @Suppress("MaxLineLength") + private const val REGEX = + "^[a-zA-Z0-9.!#\$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\$" + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/error/AggregateException.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/error/AggregateException.kt new file mode 100644 index 00000000..e1c1a131 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/error/AggregateException.kt @@ -0,0 +1,10 @@ +package com.lyra.common.domain.error + +/** + * A container for one or more exceptions that occurred during multiple task execution. + * + * @since 1.0.9 + */ +class AggregateException(val exceptions: Collection) : RuntimeException() { + constructor(exceptions: Array) : this(exceptions.toList()) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/error/BusinessRuleValidationException.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/error/BusinessRuleValidationException.kt new file mode 100644 index 00000000..d14aef66 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/error/BusinessRuleValidationException.kt @@ -0,0 +1,17 @@ +package com.lyra.common.domain.error + +import java.lang.Exception + +/** + * An abstract class representing a business rule validation exception. + * + * This class is used to represent exceptions that occur during the validation of business rules. + * It extends the Throwable class and provides a message and an optional cause for the exception. + * + * @property message The detail message of the exception. + * @property cause The cause of the exception, if any. + */ +abstract class BusinessRuleValidationException( + override val message: String, + override val cause: Throwable? = null +) : Exception(message, cause) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/error/EmailExceptions.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/error/EmailExceptions.kt new file mode 100644 index 00000000..e09e80ed --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/error/EmailExceptions.kt @@ -0,0 +1,19 @@ +package com.lyra.common.domain.error + +/** + * Invalid argument email exception is thrown when an email is not valid. + * + * @created 2/7/23 + */ +sealed class InvalidArgumentEmailException( + override val message: String, + override val cause: Throwable? = null +) : BusinessRuleValidationException(message, cause) + +/** + * Email not valid exception is thrown when an email is not valid. + * @param id The email id. + * @param cause The cause of the exception. + */ +data class EmailNotValidException(val id: String, override val cause: Throwable? = null) : + InvalidArgumentEmailException("The email <$id> is not valid", cause) diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/SqlLikeTranspiler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/SqlLikeTranspiler.kt new file mode 100644 index 00000000..21ab56e2 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/SqlLikeTranspiler.kt @@ -0,0 +1,22 @@ +package com.lyra.common.domain.regexp + +/** + * + * @created 10/1/24 + */ +object SqlLikeTranspiler { + private val TOKENIZER = Tokenizer() + .add("^\\[([^]]*)]") { EscapeToken(it) } + .add("^(%)") { WildcardToken(it) } + .add("^(_)") { WildcharToken(it) } + .add("^([^\\[\\]%_]+)") { StringToken(it) } + + fun toRegEx(pattern: String): String { + val sb = StringBuilder().append("^") + val tokens = TOKENIZER.tokenize(pattern) + for (token in tokens) { + sb.append(token.convert()) + } + return sb.append("$").toString() + } +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Token.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Token.kt new file mode 100644 index 00000000..9450653c --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Token.kt @@ -0,0 +1,23 @@ +package com.lyra.common.domain.regexp + +import java.util.regex.Pattern + +abstract class Token(val value: String) { + abstract fun convert(): String? +} + +class EscapeToken(value: String) : Token(value) { + override fun convert(): String = Pattern.quote(value) +} + +class WildcardToken(value: String) : Token(value) { + override fun convert(): String = ".*" +} + +class WildcharToken(value: String) : Token(value) { + override fun convert(): String = "." +} + +class StringToken(value: String) : Token(value) { + override fun convert(): String = Pattern.quote(value) +} diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Tokenizer.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Tokenizer.kt new file mode 100644 index 00000000..5720c607 --- /dev/null +++ b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Tokenizer.kt @@ -0,0 +1,41 @@ +package com.lyra.common.domain.regexp + +import java.text.ParseException +import java.util.regex.Pattern + +/** + * + * @created 10/1/24 + */ +class Tokenizer { + private val patterns = mutableListOf Token>>() + + fun add(regex: String, creator: (String) -> Token): Tokenizer { + patterns.add(Pattern.compile(regex) to creator) + return this + } + + fun tokenize(clause: String): Collection { + val tokens = mutableListOf() + var copy = String(clause.toCharArray()) + var position = 0 + while (copy != "") { + var found = false + for ((pattern, fn) in patterns) { + val m = pattern.matcher(copy) + if (m.find()) { + found = true + val token = m.group(1) + tokens.add(fn(token)) + copy = m.replaceFirst("") + position += token.length + break + } + } + if (!found) { + throw ParseException("Unexpected sequence found in input string.", ++position) + } + } + return tokens + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandHandlerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandHandlerTest.kt new file mode 100644 index 00000000..38cee132 --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandHandlerTest.kt @@ -0,0 +1,151 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class CommandHandlerTest { + + @Test + fun async_commandHandler_should_be_fired() = runBlocking { + class MyAsyncCommand : Command + + class MyCommandHandler : CommandHandler { + var invocationCount = 0 + override suspend fun handle(command: MyAsyncCommand) { + invocationCount++ + } + } + + val handler = MyCommandHandler() + val handlers: HashMap, Any> = hashMapOf(Pair(MyCommandHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.send(MyAsyncCommand()) + + assertEquals(1, handler.invocationCount) + } + + @Test + fun `should throw exception if given async command has not been registered before`() { + class NonExistCommand : Command + + val handlers: HashMap, Any> = hashMapOf() + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + val exception = assertFailsWith(HandlerNotFoundException::class) { + runBlocking { + bus.send(NonExistCommand()) + } + } + + assertNotNull(exception) + assertEquals("handler could not be found for ${NonExistCommand::class.java.typeName}", exception.message) + } + + @Test + fun inheritance_should_work() = runBlocking { + class MyCommandForInheritance : Command + abstract class MyCommandHandlerFor : CommandHandler + + class MyInheritedCommandHandler : MyCommandHandlerFor() { + var invocationCount = 0 + override suspend fun handle(command: MyCommandForInheritance) { + invocationCount++ + } + } + + val handler = MyInheritedCommandHandler() + val handlers: HashMap, Any> = hashMapOf(Pair(MyInheritedCommandHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.send(MyCommandForInheritance()) + + assertEquals(1, handler.invocationCount) + } + + @Test + fun inheritance_but_not_parameterized_should_work() = runBlocking { + class MyCommandForInheritance : Command + + abstract class MyCommandHandlerBaseForSpecificCommand : CommandHandler + + class MyInheritedCommandHandlerForSpecificCommand : MyCommandHandlerBaseForSpecificCommand() { + var invocationCount = 0 + override suspend fun handle(command: MyCommandForInheritance) { + invocationCount++ + } + } + + val handler = MyInheritedCommandHandlerForSpecificCommand() + val handlers: HashMap, Any> = + hashMapOf(Pair(MyInheritedCommandHandlerForSpecificCommand::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.send(MyCommandForInheritance()) + + assertEquals(1, handler.invocationCount) + } + + @Nested + inner class ParameterizedTests { + + @Test + fun async_command_should_be_fired() = runBlocking { + class ParameterizedCommand(val param: T) : Command + + class ParameterizedCommandHandler : CommandHandler> { + var invocationCount = 0 + override suspend fun handle(command: ParameterizedCommand) { + invocationCount++ + } + } + + // given + val handler = ParameterizedCommandHandler>() + val handlers: HashMap, Any> = + hashMapOf(Pair(ParameterizedCommandHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + // when + bus.send(ParameterizedCommand("MyParam")) + + // then + assertEquals(1, handler.invocationCount) + } + + @Test + fun async_commandHandler_with_inheritance_should_be_fired() = runBlocking { + class ParameterizedCommand(val param: T) : Command + + abstract class ParameterizedCommandHandlerBase : CommandHandler> + + class ParameterizedCommandHandler : ParameterizedCommandHandlerBase() { + var invocationCount = 0 + override suspend fun handle(command: ParameterizedCommand) { + invocationCount++ + } + } + + // given + val handler = ParameterizedCommandHandler>() + val handlers: HashMap, Any> = + hashMapOf(Pair(ParameterizedCommandHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + // when + bus.send(ParameterizedCommand("MyParam")) + + // then + assertEquals(1, handler.invocationCount) + } + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandWithResultHandlerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandWithResultHandlerTest.kt new file mode 100644 index 00000000..ca981d41 --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandWithResultHandlerTest.kt @@ -0,0 +1,159 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.command.CommandWithResultHandler +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +private var counter = 0 +private var asyncTestCounter = 0 + +class CommandWithResultHandlerTest { + + init { + counter = 0 + asyncTestCounter = 0 + } + + @Test + fun `async commandHandler should be fired`() = runBlocking { + val handler = AsyncMyCommandRHandler() + val handlers: HashMap, Any> = + hashMapOf(Pair(AsyncMyCommandRHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.send(MyAsyncCommandR()) + + assertTrue { + asyncTestCounter == 1 + } + } + + @Test + fun `should throw exception if given async command has not been registered before`() { + val provider = ManualDependencyProvider(hashMapOf()) + val bus: Mediator = MediatorBuilder(provider).build() + val exception = assertFailsWith(HandlerNotFoundException::class) { + runBlocking { + bus.send(NonExistCommandR()) + } + } + + assertNotNull(exception) + assertEquals( + "handler could not be found for com.lyra.common.domain.bus.NonExistCommandR", + exception.message, + ) + } + + @Test + fun inheritance_should_work() = runBlocking { + var invocationCount = 0 + + class MyAsyncCommand : CommandWithResult + + class AsyncMyCommandHandler : CommandWithResultHandler { + override suspend fun handle(command: MyAsyncCommand): Result { + invocationCount++ + return Result() + } + } + + val handler = AsyncMyCommandHandler() + val handlers: HashMap, Any> = + hashMapOf(Pair(AsyncMyCommandHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.send(MyAsyncCommand()) + + assertEquals(1, invocationCount) + } + + @Nested + inner class ParamaterizedTests { + init { + counter = 0 + asyncTestCounter = 0 + } + + inner class ParameterizedCommandWithResult(val param: TParam) : + CommandWithResult + + inner class ParatemerizedAsyncCommandWithResultHandler : + CommandWithResultHandler, String> { + override suspend fun handle(command: ParameterizedCommandWithResult): String { + counter++ + return command.param.toString() + } + } + + @Test + fun `async commandWithResult should be fired and return result`() = runBlocking { + // given + val handler = + ParatemerizedAsyncCommandWithResultHandler>() + val handlers: HashMap, Any> = + hashMapOf(Pair(ParatemerizedAsyncCommandWithResultHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + // when + val result = bus.send(ParameterizedCommandWithResult(61L)) + + // then + assertTrue { counter == 1 } + assertEquals(result, "61") + } + + @Test + fun inheritance_should_work() = runBlocking { + var invocationCount = 0 + + class ParameterizedCommandWithResult(val param: TParam) : CommandWithResult + + abstract class ParameterizedCommandWithResultHandlerBase> : + CommandWithResultHandler + + class Handler : + ParameterizedCommandWithResultHandlerBase>() { + override suspend fun handle(command: ParameterizedCommandWithResult): String { + invocationCount++ + return command.param.toString() + } + } + + val handler = Handler>() + val handlers: HashMap, Any> = hashMapOf(Pair(Handler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + // when + val result = bus.send(ParameterizedCommandWithResult("invoked")) + + // then + assertEquals(1, invocationCount) + assertEquals(result, "invoked") + } + } +} + +private class Result +private class NonExistCommandR : Command + +private class MyAsyncCommandR : CommandWithResult + +private class AsyncMyCommandRHandler : CommandWithResultHandler { + override suspend fun handle(command: MyAsyncCommandR): Result { + delay(500) + asyncTestCounter++ + + return Result() + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/ManualDependencyProvider.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/ManualDependencyProvider.kt new file mode 100644 index 00000000..ec5db90e --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/ManualDependencyProvider.kt @@ -0,0 +1,48 @@ +@file:Suppress("UNCHECKED_CAST") +package com.lyra.common.domain.bus + +import java.lang.reflect.ParameterizedType + +class ManualDependencyProvider( + private val handlerMap: HashMap, Any>, +) : DependencyProvider { + override fun getSingleInstanceOf(clazz: Class): T = handlerMap[clazz] as T + + override fun getSubTypesOf(clazz: Class): Collection> { + return handlerMap + .filter { filterInternal(it.key, clazz) } + .map { + it.key as Class + } + } + + private fun filterInternal( + handler: Class<*>, + interfaceOrBaseClass: Class, + ): Boolean { + if (interfaceOrBaseClass.isAssignableFrom(handler)) return true + + if (handler.genericInterfaces + .filterIsInstance() + .any { it.rawType == interfaceOrBaseClass } + ) { + return true + } + + return when (handler.genericSuperclass) { + is ParameterizedType -> { + val inheritedHandler = (handler.genericSuperclass as ParameterizedType).rawType as Class<*> + inheritedHandler.genericInterfaces + .filterIsInstance() + .any { it.rawType == interfaceOrBaseClass } + } + + is Class<*> -> { + val inheritedHandler = handler.genericSuperclass as Class<*> + interfaceOrBaseClass.isAssignableFrom(inheritedHandler) + } + + else -> false + } + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/MediatorBuilderTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/MediatorBuilderTest.kt new file mode 100644 index 00000000..202b69df --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/MediatorBuilderTest.kt @@ -0,0 +1,30 @@ +package com.lyra.common.domain.bus + +import kotlin.test.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class MediatorBuilderTest { + + @ParameterizedTest + @MethodSource("strategies") + fun `When a publish strategy is defined it should be set`(expectedStrategy: PublishStrategy) { + // Arrange/Act + val builder = MediatorBuilder(ManualDependencyProvider(hashMapOf())) + .withPublishStrategy(expectedStrategy) + + // Assert + assertEquals(expectedStrategy, builder.defaultPublishStrategy) + } + + companion object { + @JvmStatic + fun strategies() = listOf( + Arguments.of(ContinueOnExceptionPublishStrategy()), + Arguments.of(ParallelNoWaitPublishStrategy()), + Arguments.of(ParallelWhenAllPublishStrategy()), + Arguments.of(StopOnExceptionPublishStrategy()), + ) + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/NotificationHandlerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/NotificationHandlerTest.kt new file mode 100644 index 00000000..15c1a4aa --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/NotificationHandlerTest.kt @@ -0,0 +1,160 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import java.util.concurrent.CountDownLatch +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +private var asyncCountDownLatch = CountDownLatch(1) + +private open class Ping : Notification +private class ExtendedPing : Ping() + +private class AnPingHandler : NotificationHandler { + override suspend fun handle(notification: ExtendedPing) { + asyncCountDownLatch.countDown() + } +} + +private class AnotherPingHandler : NotificationHandler { + override suspend fun handle(notification: Ping) { + asyncCountDownLatch.countDown() + } +} + +class NotificationHandlerTest { + init { + asyncCountDownLatch = CountDownLatch(1) + } + + @Test + fun `async notification handler should be called`() = runBlocking { + val pingHandler = AnPingHandler() + val anotherPingHandler = AnotherPingHandler() + val handlers: HashMap, Any> = hashMapOf( + Pair(AnPingHandler::class.java, pingHandler), + Pair(AnotherPingHandler::class.java, anotherPingHandler), + ) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.publish(ExtendedPing()) + + assertTrue { + asyncCountDownLatch.count == 0L + } + } + + @Test + fun multiple_handlers_for_a_notification_should_be_dispatched() = runBlocking { + var invocationCount = 0 + + class MyNotification : Notification + + class Handler1 : NotificationHandler { + override suspend fun handle(notification: MyNotification) { + invocationCount++ + } + } + + class Handler2 : NotificationHandler { + override suspend fun handle(notification: MyNotification) { + invocationCount++ + } + } + + val handlers: HashMap, Any> = hashMapOf( + Pair(Handler1::class.java, Handler1()), + Pair(Handler2::class.java, Handler2()), + ) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.publish(MyNotification()) + assertEquals(2, invocationCount) + } + + @Test + fun inherited_notification_handler_should_be_called() = runBlocking { + class PingForInherited : Notification + + abstract class NotificationHandlerBase : + NotificationHandler + + class InheritedNotificationHandler : NotificationHandlerBase() { + override suspend fun handle(notification: PingForInherited) { + asyncCountDownLatch.countDown() + } + } + + val nHandler = InheritedNotificationHandler() + val handlers: HashMap, Any> = + hashMapOf(Pair(InheritedNotificationHandler::class.java, nHandler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.publish(PingForInherited()) + + assertEquals(0, asyncCountDownLatch.count) + } + + @Nested + inner class ParamaterizedTests { + inner class ParameterizedNotification(val param: T) : Notification + + inner class ParameterizedNotificationHandler : + NotificationHandler> { + override suspend fun handle(notification: ParameterizedNotification) { + asyncCountDownLatch.countDown() + } + } + + @Test + fun `async notification should be fired`() = runBlocking { + // given + val handler = ParameterizedNotificationHandler>() + val handlers: HashMap, Any> = + hashMapOf(Pair(ParameterizedNotificationHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + // when + bus.publish(ParameterizedNotification("MyParam")) + + // then + assertTrue { + asyncCountDownLatch.count == 0L + } + } + + @Test + fun inherited_notification_handler_should_be_called() = runBlocking { + var invocationCount = 0 + var parameter = "" + + class ParameterizedNotification(val param: T) : Notification + + abstract class NotificationHandlerBase : + NotificationHandler + + class ParameterizedNotificationHandler : + NotificationHandlerBase>() { + override suspend fun handle(notification: ParameterizedNotification) { + parameter = notification.param.toString() + invocationCount++ + } + } + + val nHandler = ParameterizedNotificationHandler>() + val handlers: HashMap, Any> = + hashMapOf(Pair(ParameterizedNotificationHandler::class.java, nHandler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.publish(ParameterizedNotification("invoked")) + + assertEquals(1, invocationCount) + assertEquals("invoked", parameter) + } + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/PipelineBehaviorTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/PipelineBehaviorTest.kt new file mode 100644 index 00000000..a276b2c6 --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/PipelineBehaviorTest.kt @@ -0,0 +1,172 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import com.lyra.common.domain.bus.pipeline.PipelineBehavior +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +var exceptionPipelineBehaviorHandleCounter = 0 +var exceptionPipelineBehaviorHandleCatchCounter = 0 +var loggingPipelineBehaviorHandleBeforeNextCounter = 0 +var loggingPipelineBehaviorHandleAfterNextCounter = 0 +var inheritedPipelineBehaviourHandleCounter = 0 +var commandTestCounter = 0 + +class PipelineBehaviorTest { + + init { + exceptionPipelineBehaviorHandleCounter = 0 + exceptionPipelineBehaviorHandleCatchCounter = 0 + loggingPipelineBehaviorHandleBeforeNextCounter = 0 + loggingPipelineBehaviorHandleAfterNextCounter = 0 + inheritedPipelineBehaviourHandleCounter = 0 + commandTestCounter = 0 + } + + private class MyCommand : Command + + private class MyCommandHandler : CommandHandler { + override suspend fun handle(command: MyCommand) { + commandTestCounter++ + delay(500) + } + } + + @Test + fun `should process command without async pipeline`() { + val handler = MyCommandHandler() + val handlers: HashMap, Any> = hashMapOf( + Pair(MyCommandHandler::class.java, handler), + ) + + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + runBlocking { + bus.send(MyCommand()) + } + + assertTrue { commandTestCounter == 1 } + assertTrue { exceptionPipelineBehaviorHandleCatchCounter == 0 } + assertTrue { exceptionPipelineBehaviorHandleCounter == 0 } + assertTrue { loggingPipelineBehaviorHandleBeforeNextCounter == 0 } + assertTrue { loggingPipelineBehaviorHandleAfterNextCounter == 0 } + } + + @Test + fun `should process command with async pipeline`() { + val handler = MyCommandHandler() + val exceptionPipeline = ExceptionPipelineBehavior() + val loggingPipeline = LoggingPipelineBehavior() + val handlers: HashMap, Any> = hashMapOf( + Pair(MyCommandHandler::class.java, handler), + Pair(ExceptionPipelineBehavior::class.java, exceptionPipeline), + Pair(LoggingPipelineBehavior::class.java, loggingPipeline), + ) + + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + runBlocking { + bus.send(MyCommand()) + } + + assertTrue { commandTestCounter == 1 } + assertTrue { exceptionPipelineBehaviorHandleCatchCounter == 0 } + assertTrue { exceptionPipelineBehaviorHandleCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleBeforeNextCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleAfterNextCounter == 1 } + } + + @Test + fun `should process exception in async handler`() { + val handler = MyBrokenHandler() + val exceptionPipeline = ExceptionPipelineBehavior() + val loggingPipeline = LoggingPipelineBehavior() + val handlers: HashMap, Any> = hashMapOf( + Pair(MyBrokenHandler::class.java, handler), + Pair(ExceptionPipelineBehavior::class.java, exceptionPipeline), + Pair(LoggingPipelineBehavior::class.java, loggingPipeline), + ) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + val act = suspend { bus.send(MyBrokenCommand()) } + + assertThrows { runBlocking { act() } } + assertTrue { commandTestCounter == 0 } + assertTrue { exceptionPipelineBehaviorHandleCatchCounter == 1 } + assertTrue { exceptionPipelineBehaviorHandleCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleBeforeNextCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleAfterNextCounter == 0 } + } + + @Test + fun `should process command with inherited pipeline`() = runBlocking { + val handler = MyCommandHandler() + val pipeline = InheritedPipelineBehaviour() + val handlers: HashMap, Any> = + hashMapOf( + Pair(MyCommandHandler::class.java, handler), + Pair(InheritedPipelineBehaviour::class.java, pipeline), + ) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + bus.send(MyCommand()) + + assertEquals(1, inheritedPipelineBehaviourHandleCounter) + } +} + +private abstract class MyBasePipelineBehaviour : PipelineBehavior + +private class InheritedPipelineBehaviour : MyBasePipelineBehaviour() { + override suspend fun handle( + request: TRequest, + next: RequestHandlerDelegate, + ): TResponse { + inheritedPipelineBehaviourHandleCounter++ + return next(request) + } +} + +private class MyCommand : Command +private class MyBrokenCommand : Command + +private class MyBrokenHandler : CommandHandler { + override suspend fun handle(command: MyBrokenCommand) { + @Suppress("TooGenericExceptionThrown") + throw Exception() + } +} + +private class ExceptionPipelineBehavior : PipelineBehavior { + override suspend fun handle( + request: TRequest, + next: RequestHandlerDelegate, + ): TResponse { + try { + exceptionPipelineBehaviorHandleCounter++ + return next(request) + } catch (ex: Exception) { + exceptionPipelineBehaviorHandleCatchCounter++ + throw ex + } + } +} + +private class LoggingPipelineBehavior : PipelineBehavior { + override suspend fun handle( + request: TRequest, + next: RequestHandlerDelegate, + ): TResponse { + loggingPipelineBehaviorHandleBeforeNextCounter++ + val result = next(request) + loggingPipelineBehaviorHandleAfterNextCounter++ + return result + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/QueryHandlerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/QueryHandlerTest.kt new file mode 100644 index 00000000..dff67694 --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/QueryHandlerTest.kt @@ -0,0 +1,81 @@ +package com.lyra.common.domain.bus + +import com.lyra.common.domain.bus.query.Query +import com.lyra.common.domain.bus.query.QueryHandler +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class QueryHandlerTest { + + @Test + fun async_queryHandler_should_retrieve_result() = runBlocking { + class TestQuery(val id: Int) : Query + + class TestQueryHandler : QueryHandler { + override suspend fun handle(query: TestQuery): String = "hello " + query.id + } + + val handler = TestQueryHandler() + val handlers: HashMap, Any> = hashMapOf(Pair(TestQueryHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + val result = bus.send(TestQuery(1)) + + assertTrue { + result == "hello 1" + } + } + + @Test + fun should_throw_exception_if_given_async_query_has_not_been_registered_before() { + class NonExistQuery : Query + + val handlers: HashMap, Any> = hashMapOf() + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + val exception = assertFailsWith(HandlerNotFoundException::class) { + runBlocking { + bus.send(NonExistQuery()) + } + } + + assertNotNull(exception) + assertEquals( + "handler could not be found for ${NonExistQuery::class.java.typeName}", + exception.message, + ) + } + + @Nested + inner class ParamaterizedTests { + inner class ParameterizedQuery(val param: TParam) : Query + + inner class ParameterizedQueryHandler : + QueryHandler, String> { + override suspend fun handle(query: ParameterizedQuery): String = + query.param.toString() + } + + @Test + fun async_query_should_be_fired_and_return_result() = runBlocking { + // given + val handler = ParameterizedQueryHandler>() + val handlers: HashMap, Any> = + hashMapOf(Pair(ParameterizedQueryHandler::class.java, handler)) + val provider = ManualDependencyProvider(handlers) + val bus: Mediator = MediatorBuilder(provider).build() + + // when + val result = bus.send(ParameterizedQuery(61L)) + + // then + assertEquals("61", result) + } + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/event/EventMultiplexerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/event/EventMultiplexerTest.kt new file mode 100644 index 00000000..4e4bbc1e --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/event/EventMultiplexerTest.kt @@ -0,0 +1,46 @@ +package com.lyra.common.domain.bus.event + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +class EventMultiplexerTest { + + @Test + fun `should consume event when filter passes`() = runBlocking { + // Arrange + val eventMultiplexer = EventMultiplexer() + val mockEventConsumer: EventConsumer = mockk(relaxed = true) + val mockEventFilter: EventFilter = mockk { + coEvery { filter(any()) } returns true + } + val mockEvent: DomainEvent = mockk() + + // Act + eventMultiplexer.on(mockEventFilter, mockEventConsumer) + eventMultiplexer.consume(mockEvent) + + // Assert + coVerify { mockEventConsumer.consume(mockEvent) } + } + + @Test + fun `should not consume event when filter fails`() = runBlocking { + // Arrange + val eventMultiplexer = EventMultiplexer() + val mockEventConsumer: EventConsumer = mockk(relaxed = true) + val mockEventFilter: EventFilter = mockk { + coEvery { filter(any()) } returns false + } + val mockEvent: DomainEvent = mockk() + + // Act + eventMultiplexer.on(mockEventFilter, mockEventConsumer) + eventMultiplexer.consume(mockEvent) + + // Assert + coVerify(exactly = 0) { mockEventConsumer.consume(mockEvent) } + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/criteria/CriteriaTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/criteria/CriteriaTest.kt new file mode 100644 index 00000000..a0840623 --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/criteria/CriteriaTest.kt @@ -0,0 +1,205 @@ +package com.lyra.common.domain.criteria + +import java.util.regex.Pattern +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CriteriaTest { + @Test + fun equals() { + val a = Criteria.And( + listOf( + Criteria.Equals("a", "a"), + Criteria.Equals("b", "b"), + Criteria.Empty, + ), + ) + val b = Criteria.And( + listOf( + Criteria.Equals("a", "a"), + Criteria.Equals("b", "b"), + Criteria.Empty, + ), + ) + + assertEquals(a, b) + } + + @Test + fun notEquals() { + val a = Criteria.And( + listOf( + Criteria.Equals("a", "a"), + Criteria.Equals("b", "b"), + Criteria.Empty, + ), + ) + val b = Criteria.And( + listOf( + Criteria.Equals("a", "a"), + Criteria.Equals("b", "b"), + Criteria.Empty, + ), + ) + + assertEquals(a, b) + } + + @Test + fun testEmptyCriteria() { + val criteria = Criteria.Empty + assertEquals("()", Criteria.Empty.toString()) + } + + @Test + fun testAndCriteria() { + val criteria = Criteria.And( + listOf( + Criteria.Equals("name", "John"), + Criteria.GreaterThan("age", 25), + ), + ) + assertEquals("(name = John AND age > 25)", criteria.toString()) + } + + @Test + fun testIsTrueCriteria() { + val criteria = Criteria.IsTrue("is_active") + assertEquals("is_active IS TRUE", criteria.toString()) + } + + @Test + fun testIsFalseCriteria() { + val criteria = Criteria.IsFalse("is_deleted") + assertEquals("is_deleted IS FALSE", criteria.toString()) + } + + @Test + fun testOrCriteria() { + val criteria = Criteria.Or( + listOf( + Criteria.Equals("name", "John"), + Criteria.GreaterThan("age", 25), + ), + ) + assertEquals("(name = John OR age > 25)", criteria.toString()) + } + + @Test + fun testEqualsCriteria() { + val criteria = Criteria.Equals("name", "John") + assertEquals("name = John", criteria.toString()) + } + + @Test + fun testNotEqualsCriteria() { + val criteria = Criteria.NotEquals("name", "John") + assertEquals("name != John", criteria.toString()) + } + + @Test + fun testBetweenCriteria() { + val criteria = Criteria.Between("age", 25..30) + assertEquals("(age >= 25 && age <= 30)", criteria.toString()) + } + + @Test + fun testNotBetweenCriteria() { + val criteria = Criteria.NotBetween("age", 25..30) + assertEquals("(age < 25 || age > 30)", criteria.toString()) + } + + @Test + fun testGreaterThanCriteria() { + val criteria = Criteria.GreaterThan("age", 25) + assertEquals("age > 25", criteria.toString()) + } + + @Test + fun testGreaterThanOrEqualsCriteria() { + val criteria = Criteria.GreaterThanEquals("age", 25) + assertEquals("age >= 25", criteria.toString()) + } + + @Test + fun testLessThanCriteria() { + val criteria = Criteria.LessThan("age", 25) + assertEquals("age < 25", criteria.toString()) + } + + @Test + fun testLessThanOrEqualsCriteria() { + val criteria = Criteria.LessThanEquals("age", 25) + assertEquals("age <= 25", criteria.toString()) + } + + @Test + fun testIsNullCriteria() { + val criteria = Criteria.IsNull("name") + assertEquals("name = null", criteria.toString()) + } + + @Test + fun testIsNotNullCriteria() { + val criteria = Criteria.IsNotNull("name") + assertEquals("name != null", criteria.toString()) + } + + @Test + fun testLikeCriteria() { + val criteria = Criteria.Like("name", "%John%") + assertEquals("name LIKE %John%", criteria.toString()) + } + + @Test + fun testNotLikeCriteria() { + val criteria = Criteria.NotLike("name", "%John%") + assertEquals("name NOT LIKE %John%", criteria.toString()) + } + + @Test + fun testRegexpCriteria() { + val value: Pattern = Pattern.compile("John") + val criteria = Criteria.Regexp("name", value) + assertEquals("name REGEXP John", criteria.toString()) + } + + @Test + fun testNotRegexpCriteria() { + val value: Pattern = Pattern.compile("John") + val criteria = Criteria.NotRegexp("name", value) + assertEquals("name NOT REGEXP John", criteria.toString()) + } + + @Test + fun testInCriteria() { + val criteria = Criteria.In("name", listOf("John", "Jane")) + assertEquals("name IN [John, Jane]", criteria.toString()) + } + + @Test + fun testNotInCriteria() { + val criteria = Criteria.NotIn("name", listOf("John", "Jane")) + assertEquals("name NOT IN [John, Jane]", criteria.toString()) + } + + @Test + fun testComplexCriteriaWithMultipleNestedActions() { + val criteria = Criteria.And( + listOf( + Criteria.Equals("name", "John"), + Criteria.Or( + listOf( + Criteria.Equals("age", 25), + Criteria.Equals("age", 30), + ), + ), + Criteria.Equals("is_active", true), + ), + ) + assertEquals( + "(name = John AND (age = 25 OR age = 30) AND is_active = true)", + criteria.toString(), + ) + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParserTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParserTest.kt new file mode 100644 index 00000000..617e795d --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParserTest.kt @@ -0,0 +1,291 @@ +package com.lyra.common.domain.criteria + +import java.util.regex.Pattern +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RuntimeCriteriaParserTest { + data class TestData( + var name: String? = null, + var age: Int? = null, + var activate: Boolean? = null + ) + + private data class TestCase( + val query: Criteria, + val expectTrue: List = listOf(), + val expectFalse: List = listOf(), + ) + + private val parser = RuntimeCriteriaParser(TestData::class) + + @Test + fun parse() { + val testCases = listOf( + *notTestCaseList().toTypedArray(), + *isTestCaseList().toTypedArray(), + *betweenAndNotBetweenTestCaseList().toTypedArray(), + *lestThanTestCaseList().toTypedArray(), + *greaterThanTestCaseList().toTypedArray(), + *isNullAndNotNullTestCaseList().toTypedArray(), + *likeAndNotLikeTestCaseList().toTypedArray(), + *regexpAndNotRegexpTestCaseList().toTypedArray(), + *inAndNotInTestCaseList().toTypedArray(), + *isTruAndIsFalseTestCaseList().toTypedArray(), + TestCase( + query = Criteria.And(listOf()), + ), + TestCase( + query = Criteria.And(listOf(where(TestData::activate).isTrue())), + expectTrue = listOf(TestData(activate = true)), + expectFalse = listOf(TestData(activate = false), TestData(activate = null)), + ), + TestCase( + query = where(TestData::activate).isTrue().and(where(TestData::name).`is`("test")), + expectTrue = listOf(TestData(activate = true, name = "test")), + expectFalse = listOf( + TestData(activate = false, name = "test"), + TestData(activate = true, name = "!test"), + ), + ), + TestCase( + query = Criteria.Or(listOf()), + ), + TestCase( + query = where(TestData::activate).isTrue().or(where(TestData::name).`is`("test")), + expectTrue = listOf( + TestData(activate = true, name = "test"), + TestData(activate = false, name = "test"), + TestData(activate = true, name = "!test"), + ), + expectFalse = listOf(TestData(activate = false, name = "!test")), + ), + TestCase( + query = Criteria.Or(listOf(where(TestData::activate).isTrue())), + expectTrue = listOf(TestData(activate = true)), + expectFalse = listOf(TestData(activate = false), TestData(activate = null)), + ), + ) + + testCases.forEach { testCase -> + val criteria = parser.parse(testCase.query) + testCase.expectTrue.forEach { + assertTrue(criteria?.invoke(it) ?: false) + } + testCase.expectFalse.forEach { + assertFalse(criteria?.invoke(it) ?: true) + } + if (testCase.expectFalse.isEmpty() && testCase.expectTrue.isEmpty()) { + assertNull(criteria) + } + } + } + private fun notTestCaseList(): List = listOf( + TestCase( + query = where(TestData::name).not("test"), + expectTrue = listOf(TestData(name = "!test"), TestData(name = null)), + expectFalse = listOf(TestData(name = "test")), + ), + TestCase( + query = where("invalid").not("test"), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + + private fun isTestCaseList(): List = listOf( + TestCase( + query = where(TestData::name).`is`("test"), + expectTrue = listOf(TestData(name = "test")), + expectFalse = listOf(TestData(name = "!test"), TestData(name = null)), + ), + TestCase( + query = where("invalid").`is`("test"), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + + private fun betweenAndNotBetweenTestCaseList(): List = listOf( + TestCase( + query = where(TestData::age).between(0..10), + expectTrue = listOf(TestData(age = 0), TestData(age = 5), TestData(age = 10)), + expectFalse = listOf(TestData(age = -1), TestData(age = 11), TestData(age = null)), + ), + TestCase( + query = where("invalid").between(0..10), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::age).notBetween(0..10), + expectTrue = listOf(TestData(age = -1), TestData(age = 11), TestData(age = null)), + expectFalse = listOf(TestData(age = 0), TestData(age = 5), TestData(age = 10)), + ), + TestCase( + query = where("invalid").notBetween(0..10), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun lestThanTestCaseList(): List = listOf( + TestCase( + query = where(TestData::age).lessThan(0), + expectTrue = listOf(TestData(age = -1)), + expectFalse = listOf(TestData(age = 0), TestData(age = null)), + ), + TestCase( + query = where("invalid").lessThan(0), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::age).lessThanOrEquals(0), + expectTrue = listOf(TestData(age = 0)), + expectFalse = listOf(TestData(age = 1), TestData(age = null)), + ), + TestCase( + query = where("invalid").lessThanOrEquals(0), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun greaterThanTestCaseList(): List = listOf( + TestCase( + query = where(TestData::age).greaterThan(0), + expectTrue = listOf(TestData(age = 1)), + expectFalse = listOf(TestData(age = 0), TestData(age = null)), + ), + TestCase( + query = where("invalid").greaterThan(0), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::age).greaterThanOrEquals(0), + expectTrue = listOf(TestData(age = 0)), + expectFalse = listOf(TestData(age = -1), TestData(age = null)), + ), + TestCase( + query = where("invalid").greaterThanOrEquals(0), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun isNullAndNotNullTestCaseList(): List = listOf( + TestCase( + query = where(TestData::age).isNull(), + expectTrue = listOf(TestData(age = null)), + expectFalse = listOf(TestData(age = 0)), + ), + TestCase( + query = where("invalid").isNull(), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::age).isNotNull(), + expectTrue = listOf(TestData(age = 0)), + expectFalse = listOf(TestData(age = null)), + ), + TestCase( + query = where("invalid").isNotNull(), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun likeAndNotLikeTestCaseList(): List = listOf( + TestCase( + query = where(TestData::name).like("%test%"), + expectTrue = listOf(TestData(name = "testtesttest")), + expectFalse = listOf(TestData(name = "any"), TestData(name = null)), + ), + TestCase( + query = where("invalid").like("%test%"), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::name).notLike("%test%"), + expectTrue = listOf(TestData(name = "any")), + expectFalse = listOf(TestData(name = "testtesttest"), TestData(name = null)), + ), + TestCase( + query = where("invalid").notLike("%test%"), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun regexpAndNotRegexpTestCaseList(): List = listOf( + TestCase( + query = where(TestData::name).regexp(Pattern.compile(".*test.*")), + expectTrue = listOf(TestData(name = "testtesttest")), + expectFalse = listOf(TestData(name = "any"), TestData(name = null)), + ), + TestCase( + query = where("invalid").regexp(Pattern.compile(".*test.*")), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::name).notRegexp(Pattern.compile(".*test.*")), + expectTrue = listOf(TestData(name = "any")), + expectFalse = listOf(TestData(name = "testtesttest"), TestData(name = null)), + ), + TestCase( + query = where("invalid").notRegexp(Pattern.compile(".*test.*")), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun inAndNotInTestCaseList(): List = listOf( + TestCase( + query = where(TestData::name).`in`("test1", "test2"), + expectTrue = listOf(TestData(name = "test1"), TestData(name = "test2")), + expectFalse = listOf(TestData(name = "any"), TestData(name = null)), + ), + TestCase( + query = where("invalid").`in`("test1", "test2"), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::name).notIn("test1", "test2"), + expectTrue = listOf(TestData(name = "any")), + expectFalse = listOf( + TestData(name = "test1"), + TestData(name = "test2"), + TestData(name = null), + ), + ), + TestCase( + query = where("invalid").notIn("test1", "test2"), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) + private fun isTruAndIsFalseTestCaseList(): List = listOf( + TestCase( + query = where(TestData::activate).isTrue(), + expectTrue = listOf(TestData(activate = true)), + expectFalse = listOf(TestData(activate = false), TestData(activate = null)), + ), + TestCase( + query = where("invalid").isTrue(), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + TestCase( + query = where(TestData::activate).isFalse(), + expectTrue = listOf(TestData(activate = false)), + expectFalse = listOf(TestData(activate = true), TestData(activate = null)), + ), + TestCase( + query = where("invalid").isFalse(), + expectTrue = listOf(), + expectFalse = listOf(TestData()), + ), + ) +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/email/EmailTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/email/EmailTest.kt new file mode 100644 index 00000000..382fd554 --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/email/EmailTest.kt @@ -0,0 +1,70 @@ +package com.lyra.common.domain.email + +import com.lyra.common.domain.error.EmailNotValidException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +internal class EmailTest { + @Test + fun `should create email`() { + val email = Email("john.snow@gmail.com") + assertEquals("john.snow@gmail.com", email.email) + } + + @Test + fun `should throw exception when email is not valid`() { + val invalidEmails = listOf( + "john.snow@gmail.", "Julia.abc@", + "Julia.abc@.com", "Samantha_21.", ".1Samantha", + "Samantha@10_2A", "JuliaZ007", "_Julia007.com", + "Willie_Zboncak@@yahoo.com", + ) + + invalidEmails.forEach { + println("Email: $it") + assertThrows(EmailNotValidException::class.java) { + Email(it) + } + } + } + + @Test + fun `should throw exception when email is empty`() { + assertThrows(EmailNotValidException::class.java) { + Email("") + } + } + + @Test + fun `should throw exception when email is blank`() { + assertThrows(EmailNotValidException::class.java) { + Email(" ") + } + } + + @Test + fun `should throw exception when email length is greater than 255`() { + val email = "john.snow@${(1..256).joinToString("") { "a" }}.com" + assertThrows(EmailNotValidException::class.java) { + Email(email) + } + } + + @Test + fun `compare email`() { + val email1 = Email("john.snow@gmail.com") + val email2 = Email("john.snow@gmail.com") + assertEquals(email1, email2) + assertEquals(email1.hashCode(), email2.hashCode()) + } + + @Test + fun `compare email with different value`() { + val email1 = Email("john.snow@gmail.com") + val email2 = Email("john-snow@gmail.com") + assertNotEquals(email1, email2) + assertNotEquals(email1.hashCode(), email2.hashCode()) + } +} diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/regexp/TokenTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/regexp/TokenTest.kt new file mode 100644 index 00000000..774c20cf --- /dev/null +++ b/shared/common/src/test/kotlin/com/lyra/common/domain/regexp/TokenTest.kt @@ -0,0 +1,30 @@ +package com.lyra.common.domain.regexp + +import kotlin.test.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class TokenTest { + @Test + fun testEscapeToken() { + val escapeToken = EscapeToken("abc") + assertEquals("\\Qabc\\E", escapeToken.convert()) + } + + @Test + fun testWildcardToken() { + val wildcardToken = WildcardToken("def") + assertEquals(".*", wildcardToken.convert()) + } + + @Test + fun testWildcharToken() { + val wildcharToken = WildcharToken("xyz") + assertEquals(".", wildcharToken.convert()) + } + + @Test + fun testStringToken() { + val stringToken = StringToken("123") + assertEquals("\\Q123\\E", stringToken.convert()) + } +} diff --git a/shared/spring-boot-common/build.gradle.kts b/shared/spring-boot-common/build.gradle.kts new file mode 100644 index 00000000..2b8e9b71 --- /dev/null +++ b/shared/spring-boot-common/build.gradle.kts @@ -0,0 +1,17 @@ +import com.lyra.buildlogic.common.extensions.implementation + +plugins { + id("app.spring.boot.library.convention") +} + +dependencies { + implementation(project(":shared:common")) + implementation(libs.spring.boot.starter.data.r2dbc) + testImplementation(kotlin("test")) + testImplementation(libs.faker) + testImplementation(libs.mockk) +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/ApiController.kt b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/ApiController.kt new file mode 100644 index 00000000..6966e57c --- /dev/null +++ b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/ApiController.kt @@ -0,0 +1,19 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandlerExecutionError +import com.lyra.common.domain.bus.query.Query +import com.lyra.common.domain.bus.query.QueryHandlerExecutionError +import com.lyra.common.domain.bus.query.Response + +abstract class ApiController( + private val mediator: Mediator +) { + + @Throws(CommandHandlerExecutionError::class) + protected suspend fun dispatch(command: Command) = mediator.send(command) + + @Throws(QueryHandlerExecutionError::class) + protected suspend fun ask(query: Query): Response = mediator.send(query) +} diff --git a/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/LyraAutoConfiguration.kt b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/LyraAutoConfiguration.kt new file mode 100644 index 00000000..a8393475 --- /dev/null +++ b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/LyraAutoConfiguration.kt @@ -0,0 +1,18 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.MediatorBuilder +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean + +@AutoConfiguration +open class LyraAutoConfiguration { + @Bean + open fun lyraSpringBeanProvider(applicationContext: ApplicationContext): LyraSpringBeanProvider = + LyraSpringBeanProvider(applicationContext) + + @Bean + open fun mediator(lyraSpringBeanProvider: LyraSpringBeanProvider): Mediator = + MediatorBuilder(lyraSpringBeanProvider).build() +} diff --git a/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/LyraSpringBeanProvider.kt b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/LyraSpringBeanProvider.kt new file mode 100644 index 00000000..02521405 --- /dev/null +++ b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/LyraSpringBeanProvider.kt @@ -0,0 +1,16 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.DependencyProvider +import org.springframework.context.ApplicationContext + +class LyraSpringBeanProvider( + private val applicationContext: ApplicationContext, +) : DependencyProvider { + override fun getSingleInstanceOf(clazz: Class): T = applicationContext.getBean(clazz) + + override fun getSubTypesOf(clazz: Class): Collection> = + applicationContext.getBeanNamesForType(clazz) + .map { applicationContext.getType(it) as Class } +} diff --git a/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/bus/event/EventConfiguration.kt b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/bus/event/EventConfiguration.kt new file mode 100644 index 00000000..0339c89f --- /dev/null +++ b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/bus/event/EventConfiguration.kt @@ -0,0 +1,64 @@ +package com.lyra.spring.boot.bus.event + +import com.lyra.common.domain.bus.event.DomainEvent +import com.lyra.common.domain.bus.event.EventConsumer +import com.lyra.common.domain.bus.event.EventFilter +import com.lyra.common.domain.bus.event.Subscribe +import com.lyra.common.domain.bus.event.TypeMatchEventFilter +import kotlin.reflect.KClass +import org.springframework.beans.BeansException +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Configuration + +/** + * Configuration class for handling event configuration in the application. + * + * @param applicationContext the application context + * @constructor Creates a new configuration class for handling event configuration in the application. + * @property applicationContext the application context + * + */ +@Suppress("UNCHECKED_CAST") +@Configuration +open class EventConfiguration( + private val applicationContext: ApplicationContext +) { + /** + * Configures the event emitter by subscribing all event consumers to the given event emitter. + * + * @param eventEmitter The event emitter to which the event consumers should be subscribed. + */ + @Autowired(required = true) + fun configEventEmitter(eventEmitter: EventEmitter) { + applicationContext.getBeansOfType(EventConsumer::class.java).values.forEach { + it.javaClass.annotations.filterIsInstance() + .forEach { annotation -> + val filter = createFilter(annotation) + eventEmitter.on(filter, it) + } + } + } + + /** + * Creates an EventFilter for a specified DomainEvent type based on the given Subscribe mapping. + * If the filterBy class is a valid bean in the applicationContext, it will be used. + * Otherwise, a TypeMatchEventFilter will be created using the filterBy class. + * + * @param mapping The Subscribe mapping that contains the filterBy class. + * @return An EventFilter for the specified DomainEvent type based on the given mapping. + */ + private inline fun createFilter(mapping: Subscribe): EventFilter { + val filterBeen = try { + applicationContext.getBean(mapping.filterBy.java) + } catch (_: BeansException) { + TypeMatchEventFilter(mapping.filterBy as KClass) + } + + return if (filterBeen is EventFilter<*>) { + filterBeen as EventFilter + } else { + TypeMatchEventFilter(mapping.filterBy as KClass) + } + } +} diff --git a/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/bus/event/EventEmitter.kt b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/bus/event/EventEmitter.kt new file mode 100644 index 00000000..ff9d239f --- /dev/null +++ b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/bus/event/EventEmitter.kt @@ -0,0 +1,46 @@ +package com.lyra.spring.boot.bus.event + +import com.lyra.common.domain.bus.event.DomainEvent +import com.lyra.common.domain.bus.event.EventConsumer +import com.lyra.common.domain.bus.event.EventFilter +import com.lyra.common.domain.bus.event.EventMultiplexer +import com.lyra.common.domain.bus.event.EventPublisher +import org.springframework.stereotype.Component + +/** + * Represents an event emitter that allows subscribing to events and publishing events of a specific type. + * + * @param E the type of events emitted by this event emitter. + * @constructor Creates a new event emitter that allows subscribing to events and publishing events of a specific type. + * @property eventMultiplexer the event multiplexer that handles the event subscriptions. + * @see EventPublisher for publishing events. + * @see EventConsumer for consuming events. + * @see EventFilter for filtering events. + * @see EventMultiplexer for handling event subscriptions. + * @see EventEmitter for emitting events. + * + */ +@Suppress("UNCHECKED_CAST") +@Component +class EventEmitter : EventPublisher { + private val eventMultiplexer = EventMultiplexer() + + /** + * Registers an event consumer with the given event filter. + * + * @param filter The event filter to apply. + * @param consumer The event consumer to register. + */ + fun on(filter: EventFilter, consumer: EventConsumer<*>) { + eventMultiplexer.on(filter, consumer as EventConsumer) + } + + /** + * Publishes an event to the event multiplexer. + * + * @param event the event to be published + */ + override suspend fun publish(event: E) { + eventMultiplexer.consume(event) + } +} diff --git a/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/repository/R2DBCCriteriaParser.kt b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/repository/R2DBCCriteriaParser.kt new file mode 100644 index 00000000..7a24d668 --- /dev/null +++ b/shared/spring-boot-common/src/main/kotlin/com/lyra/spring/boot/repository/R2DBCCriteriaParser.kt @@ -0,0 +1,131 @@ +package com.lyra.spring.boot.repository + +import com.lyra.common.domain.criteria.Criteria +import com.lyra.common.domain.criteria.CriteriaParser +import kotlin.reflect.KClass +import kotlin.reflect.KProperty +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.javaField +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.query.Criteria as R2DBCCriteria +import org.springframework.data.relational.core.query.Criteria.CriteriaStep as R2DBCCriteriaStep + +/** + * + * @created 10/1/24 + */ +@Suppress("MethodOverloading") +class R2DBCCriteriaParser( + clazz: KClass +) : CriteriaParser { + private val columnNames = clazz.memberProperties.associate { it.name to columnName(it) } + + @Suppress("CyclomaticComplexMethod", "CognitiveComplexMethod") + override fun parse(criteria: Criteria): R2DBCCriteria { + return when (criteria) { + is Criteria.Empty -> R2DBCCriteria.empty() + is Criteria.And -> parse(criteria) + is Criteria.Or -> parse(criteria) + is Criteria.Equals -> parse(criteria) + is Criteria.NotEquals -> parse(criteria) + is Criteria.Between -> parse(criteria) + is Criteria.NotBetween -> parse(criteria) + is Criteria.LessThan -> parse(criteria) + is Criteria.LessThanEquals -> parse(criteria) + is Criteria.GreaterThan -> parse(criteria) + is Criteria.GreaterThanEquals -> parse(criteria) + is Criteria.IsNull -> parse(criteria) + is Criteria.IsNotNull -> parse(criteria) + is Criteria.Like -> parse(criteria) + is Criteria.NotLike -> parse(criteria) + is Criteria.In -> parse(criteria) + is Criteria.NotIn -> parse(criteria) + is Criteria.IsTrue -> parse(criteria) + is Criteria.IsFalse -> parse(criteria) + else -> throw IllegalArgumentException("Criteria $criteria is not supported") + } + } + + private fun parse(criteria: Criteria.And): R2DBCCriteria { + if (criteria.value.isEmpty()) { + return R2DBCCriteria.empty() + } + if (criteria.value.size == 1) { + return parse(criteria.value[0]) + } + return criteria.value + .filter { it !is Criteria.Empty } + .map { parse(it) } + .reduce { acc, cur -> acc.and(cur) } + } + + private fun parse(criteria: Criteria.Or): R2DBCCriteria { + if (criteria.value.isEmpty()) { + return R2DBCCriteria.empty() + } + if (criteria.value.size == 1) { + return parse(criteria.value[0]) + } + return criteria.value + .filter { it !is Criteria.Empty } + .map { parse(it) } + .reduce { acc, cur -> acc.or(cur) } + } + + private fun parse(criteria: Criteria.Equals): R2DBCCriteria = where(criteria.key).`is`(criteria.value) + + private fun parse(criteria: Criteria.NotEquals): R2DBCCriteria = where(criteria.key).not(criteria.value) + + private fun parse(criteria: Criteria.Between): R2DBCCriteria = + where(criteria.key).between(criteria.value.start, criteria.value.endInclusive) + + private fun parse(criteria: Criteria.NotBetween): R2DBCCriteria = + where(criteria.key).notBetween(criteria.value.start, criteria.value.endInclusive) + + private fun parse(criteria: Criteria.LessThan): R2DBCCriteria = + where(criteria.key).lessThan(criteria.value) + + private fun parse(criteria: Criteria.LessThanEquals): R2DBCCriteria = + where(criteria.key).lessThanOrEquals(criteria.value) + + private fun parse(criteria: Criteria.GreaterThan): R2DBCCriteria = + where(criteria.key).greaterThan(criteria.value) + + private fun parse(criteria: Criteria.GreaterThanEquals): R2DBCCriteria = + where(criteria.key).greaterThanOrEquals(criteria.value) + + private fun parse(criteria: Criteria.IsNull): R2DBCCriteria = where(criteria.key).isNull + + private fun parse(criteria: Criteria.IsNotNull): R2DBCCriteria = where(criteria.key).isNotNull + + private fun parse(criteria: Criteria.Like): R2DBCCriteria = + where(criteria.key).like(criteria.value) + + private fun parse(criteria: Criteria.NotLike): R2DBCCriteria = + where(criteria.key).notLike(criteria.value) + + private fun parse(criteria: Criteria.In): R2DBCCriteria = where(criteria.key).`in`(criteria.value) + + private fun parse(criteria: Criteria.NotIn): R2DBCCriteria = + where(criteria.key).notIn(criteria.value) + + private fun parse(criteria: Criteria.IsTrue): R2DBCCriteria = where(criteria.key).isTrue + + private fun parse(criteria: Criteria.IsFalse): R2DBCCriteria = where(criteria.key).isFalse + + private fun where(key: String): R2DBCCriteriaStep { + return R2DBCCriteria.where( + columnNames[key] ?: throw IllegalArgumentException("$key is invalid}"), + ) + } +} + +fun columnName(property: KProperty): String { + val column = property + .javaField + ?.annotations + ?.filterIsInstance() + ?.firstOrNull() + + return column?.value ?: property.name +} diff --git a/shared/spring-boot-common/src/main/resources/META-INF/spring.factory b/shared/spring-boot-common/src/main/resources/META-INF/spring.factory new file mode 100644 index 00000000..151c78bd --- /dev/null +++ b/shared/spring-boot-common/src/main/resources/META-INF/spring.factory @@ -0,0 +1,3 @@ +# Register EnableAutoConfiguration +org.springframework.boot.autoconfigure.EnableAutoConfiguration = \ +com.lyra.spring.boot.LyraAutoConfiguration diff --git a/shared/spring-boot-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shared/spring-boot-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..9354b0d1 --- /dev/null +++ b/shared/spring-boot-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lyra.spring.boot.LyraAutoConfiguration diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/CommandHandlerTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/CommandHandlerTest.kt new file mode 100644 index 00000000..007e87c4 --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/CommandHandlerTest.kt @@ -0,0 +1,61 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.HandlerNotFoundException +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +private var springTestCounter = 0 +private var springAsyncTestCounter = 0 + +@SpringBootTest(classes = [LyraAutoConfiguration::class, MyCommandHandler::class]) +class CommandHandlerTest { + + init { + springTestCounter = 0 + springAsyncTestCounter = 0 + } + + @Autowired + lateinit var mediator: Mediator + + @Test + fun `async commandHandler should be fired`() = runBlocking { + mediator.send(MyCommand()) + + assertTrue { + springAsyncTestCounter == 1 + } + } + + @Test + fun `should throw exception if given async command does not have handler bean`() { + val exception = assertFailsWith(HandlerNotFoundException::class) { + runBlocking { + mediator.send(NonExistCommand()) + } + } + + assertNotNull(exception) + assertEquals("handler could not be found for com.lyra.spring.boot.NonExistCommand", exception.message) + } +} + +class NonExistCommand : Command +class MyCommand : Command + +class MyCommandHandler : CommandHandler { + override suspend fun handle(command: MyCommand) { + delay(500) + springAsyncTestCounter++ + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/CommandWithResultHandlerTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/CommandWithResultHandlerTest.kt new file mode 100644 index 00000000..9d3c332c --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/CommandWithResultHandlerTest.kt @@ -0,0 +1,68 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.HandlerNotFoundException +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.command.CommandWithResult +import com.lyra.common.domain.bus.command.CommandWithResultHandler +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +private var springTestCounter = 0 +private var springAsyncTestCounter = 0 + +@SpringBootTest(classes = [LyraAutoConfiguration::class, MyAsyncCommandRHandler::class]) +class CommandWithResultHandlerTest { + + init { + springTestCounter = 0 + springAsyncTestCounter = 0 + } + + @Autowired + lateinit var mediator: Mediator + + @Test + fun `async commandHandler should be fired`() = runBlocking { + mediator.send(MyCommandR()) + + assertTrue { + springAsyncTestCounter == 1 + } + } + + @Test + fun `should throw exception if given async command does not have handler bean`() { + val exception = assertFailsWith(HandlerNotFoundException::class) { + runBlocking { + mediator.send(NonExistCommandR()) + } + } + + assertNotNull(exception) + assertEquals( + exception.message, + "handler could not be found for com.lyra.spring.boot.NonExistCommandR", + ) + } +} + +class Result + +class NonExistCommandR : CommandWithResult +class MyCommandR : CommandWithResult + +class MyAsyncCommandRHandler : CommandWithResultHandler { + override suspend fun handle(command: MyCommandR): Result { + delay(500) + springAsyncTestCounter++ + + return Result() + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/NotificationHandlerTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/NotificationHandlerTest.kt new file mode 100644 index 00000000..49413dba --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/NotificationHandlerTest.kt @@ -0,0 +1,49 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.notification.Notification +import com.lyra.common.domain.bus.notification.NotificationHandler +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +var notificationTestCounter = 0 +var asyncNotificationTestCounter = 0 + +@SpringBootTest( + classes = [ + LyraAutoConfiguration::class, + MyFirstNotificationHandler::class, + ], +) +class NotificationHandlerTest { + + init { + notificationTestCounter = 0 + asyncNotificationTestCounter = 0 + } + + @Autowired + lateinit var mediator: Mediator + + @Test + fun `async notificationHandler should be fired`() = runBlocking { + mediator.publish(MyNotification()) + + assertTrue { + asyncNotificationTestCounter == 1 + } + } +} + +class MyNotification : Notification + +class MyFirstNotificationHandler : NotificationHandler { + override suspend fun handle(notification: MyNotification) { + delay(500) + asyncNotificationTestCounter++ + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/PipelineBehaviorTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/PipelineBehaviorTest.kt new file mode 100644 index 00000000..ffec2b3b --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/PipelineBehaviorTest.kt @@ -0,0 +1,99 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.RequestHandlerDelegate +import com.lyra.common.domain.bus.command.Command +import com.lyra.common.domain.bus.command.CommandHandler +import com.lyra.common.domain.bus.pipeline.PipelineBehavior +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +var exceptionPipelineBehaviorHandleCounter = 0 +var exceptionPipelineBehaviorHandleCatchCounter = 0 +var loggingPipelineBehaviorHandleBeforeNextCounter = 0 +var loggingPipelineBehaviorHandleAfterNextCounter = 0 + +@SpringBootTest( + classes = [ + LyraAutoConfiguration::class, MyCommandHandler::class, + ExceptionPipelineBehavior::class, LoggingPipelineBehavior::class, + ], +) +class PipelineBehaviorTest { + + init { + exceptionPipelineBehaviorHandleCounter = 0 + exceptionPipelineBehaviorHandleCatchCounter = 0 + loggingPipelineBehaviorHandleBeforeNextCounter = 0 + loggingPipelineBehaviorHandleAfterNextCounter = 0 + } + + @Autowired + lateinit var mediator: Mediator + + @Test + fun `should process command with async pipeline`() { + runBlocking { + mediator.send(MyCommand()) + } + + assertTrue { exceptionPipelineBehaviorHandleCatchCounter == 0 } + assertTrue { exceptionPipelineBehaviorHandleCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleBeforeNextCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleAfterNextCounter == 1 } + } + + @Test + fun `should process exception in async handler`() { + val act = suspend { mediator.send(MyBrokenCommand()) } + + assertThrows { runBlocking { act() } } + + assertTrue { exceptionPipelineBehaviorHandleCatchCounter == 1 } + assertTrue { exceptionPipelineBehaviorHandleCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleBeforeNextCounter == 1 } + assertTrue { loggingPipelineBehaviorHandleAfterNextCounter == 0 } + } +} + +class MyBrokenCommand : Command + +class MyBrokenHandler : CommandHandler { + override suspend fun handle(command: MyBrokenCommand) { + delay(500) + @Suppress("TooGenericExceptionThrown") + throw Exception() + } +} + +private class ExceptionPipelineBehavior : PipelineBehavior { + override suspend fun handle( + request: TRequest, + next: RequestHandlerDelegate, + ): TResponse { + try { + exceptionPipelineBehaviorHandleCounter++ + return next(request) + } catch (ex: Exception) { + exceptionPipelineBehaviorHandleCatchCounter++ + throw ex + } + } +} + +private class LoggingPipelineBehavior : PipelineBehavior { + override suspend fun handle( + request: TRequest, + next: RequestHandlerDelegate, + ): TResponse { + loggingPipelineBehaviorHandleBeforeNextCounter++ + val result = next(request) + loggingPipelineBehaviorHandleAfterNextCounter++ + return result + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/QueryHandlerTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/QueryHandlerTest.kt new file mode 100644 index 00000000..69daa82e --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/QueryHandlerTest.kt @@ -0,0 +1,52 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.HandlerNotFoundException +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.query.Query +import com.lyra.common.domain.bus.query.QueryHandler +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest(classes = [LyraAutoConfiguration::class, TestQueryHandler::class]) +class QueryHandlerTest { + + @Autowired + lateinit var mediator: Mediator + + @Test + fun `async queryHandler should retrieve result`() = runBlocking { + val result = mediator.send(TestQuery(1)) + + assertTrue { + result == "hello 1" + } + } + + @Test + fun `should throw exception if given async query does not have handler bean`() { + val exception = assertFailsWith(HandlerNotFoundException::class) { + runBlocking { + mediator.send(NonExistQuery()) + } + } + + assertNotNull(exception) + assertEquals( + "handler could not be found for com.lyra.spring.boot.NonExistQuery", + exception.message, + ) + } +} + +class NonExistQuery : Query +class TestQuery(val id: Int) : Query + +class TestQueryHandler : QueryHandler { + override suspend fun handle(query: TestQuery): String = "hello " + query.id +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/SpringContextTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/SpringContextTest.kt new file mode 100644 index 00000000..749787aa --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/SpringContextTest.kt @@ -0,0 +1,21 @@ +package com.lyra.spring.boot + +import com.lyra.common.domain.bus.Mediator +import com.lyra.common.domain.bus.MediatorImpl +import kotlin.test.assertNotNull +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest(classes = [LyraAutoConfiguration::class]) +class SpringContextTest { + + @Autowired + lateinit var mediator: Mediator + + @Test + fun contextLoads() { + assertNotNull(mediator) + assert(mediator is MediatorImpl) + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/bus/event/EventConfigurationTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/bus/event/EventConfigurationTest.kt new file mode 100644 index 00000000..c73444fd --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/bus/event/EventConfigurationTest.kt @@ -0,0 +1,56 @@ +package com.lyra.spring.boot.bus.event + +import com.lyra.common.domain.bus.event.DomainEvent +import com.lyra.common.domain.bus.event.EventConsumer +import com.lyra.common.domain.bus.event.Subscribe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.ApplicationContext + +@Subscribe(filterBy = TestEventConsumer::class) +class TestEventConsumer : EventConsumer { + override suspend fun consume(event: DomainEvent) { + // Do nothing + println("\uD83D\uDFE2 Event consumed") + } +} +class EventConfigurationTest { + + private lateinit var eventConfiguration: EventConfiguration + private lateinit var applicationContext: ApplicationContext + + @BeforeEach + fun setUp() { + applicationContext = mockk() + eventConfiguration = EventConfiguration(applicationContext) + + // Configurar el mock de ApplicationContext para devolver una instancia de TestEventConsumer + every { applicationContext.getBean(TestEventConsumer::class.java) } returns TestEventConsumer() + } + + @Test + fun `test config event emitter`() { + // Arrange + val mockEventEmitter = mockk>(relaxed = true) + + every { applicationContext.getBeansOfType(EventConsumer::class.java) } returns + dummyBeansImplementingEventConsumer() + + // Act + eventConfiguration.configEventEmitter(mockEventEmitter) + + // Assert + verify { mockEventEmitter.on(any(), any()) } + } + + private fun dummyBeansImplementingEventConsumer(): Map> { + // Crear una instancia de TestEventConsumer + val testEventConsumer = TestEventConsumer() + + // Devolver la instancia de TestEventConsumer + return mapOf("testEventConsumer" to testEventConsumer) + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/bus/event/EventEmitterTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/bus/event/EventEmitterTest.kt new file mode 100644 index 00000000..2b55c5ed --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/bus/event/EventEmitterTest.kt @@ -0,0 +1,50 @@ +package com.lyra.spring.boot.bus.event + +import com.lyra.common.domain.bus.event.DomainEvent +import com.lyra.common.domain.bus.event.EventConsumer +import com.lyra.common.domain.bus.event.EventFilter +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class EventEmitterTest { + + private lateinit var eventEmitter: EventEmitter + private val mockFilter = mockk>() + private val mockConsumer = mockk>(relaxed = true) + private val mockEvent = mockk() + + @BeforeEach + fun setUp() { + eventEmitter = EventEmitter() + } + + @Test + fun `test on method`() { + // Act + eventEmitter.on(mockFilter, mockConsumer) + + // Assert + // No direct way to verify as eventMultiplexer is private + // The verification will be done in the `publish` method test + } + + @Test + fun `test publish method`() = runBlocking { + // Arrange + coEvery { mockConsumer.consume(mockEvent) } returns Unit + coEvery { mockFilter.filter(any()) } returns true + + // Register the EventConsumer with the EventEmitter + eventEmitter.on(mockFilter, mockConsumer) + + // Act + eventEmitter.publish(mockEvent) + + // Assert + coVerify { mockConsumer.consume(mockEvent) } + } +} diff --git a/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/repository/R2DBCCriteriaParserTest.kt b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/repository/R2DBCCriteriaParserTest.kt new file mode 100644 index 00000000..79491648 --- /dev/null +++ b/shared/spring-boot-common/src/test/kotlin/com/lyra/spring/boot/repository/R2DBCCriteriaParserTest.kt @@ -0,0 +1,191 @@ +package com.lyra.spring.boot.repository + +import com.lyra.common.domain.criteria.Criteria +import com.lyra.common.domain.criteria.and +import com.lyra.common.domain.criteria.or +import com.lyra.common.domain.criteria.where +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class R2DBCCriteriaParserTest { + private data class TestData( + var name: String, + var age: Int?, + var activate: Boolean + ) + + private data class TestCase( + val query: Criteria, + val sql: String? = null, + ) + + private val parser = R2DBCCriteriaParser(TestData::class) + + @Test + fun parse() { + val testCases = listOf( + *logicalOperatorsTestCases().toTypedArray(), + *comparisonOperatorsTestCases().toTypedArray(), + *complexTestCases().toTypedArray(), + ) + + testCases.forEach { + if (it.sql != null) { + val criteria = parser.parse(it.query) + assertEquals(it.sql, criteria.toString()) + } + } + } + + private fun complexTestCases(): List = listOf( + TestCase( + query = where(TestData::name).not("test").and(where(TestData::name).like("test")), + sql = "name != 'test' AND (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test") + .and(where(TestData::name).not("test").and(where(TestData::name).like("test"))), + sql = "name != 'test' AND (name != 'test' AND (name LIKE 'test'))", + ), + TestCase( + query = where(TestData::name).not("test") + .and(where(TestData::name).not("test")) + .and(where(TestData::name).like("test")), + sql = "name != 'test' AND (name != 'test') AND (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test") + .and( + listOf( + where(TestData::name).not("test"), + where(TestData::name).like("test"), + ), + ), + sql = "name != 'test' AND (name != 'test') AND (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test").or(where(TestData::name).like("test")), + sql = "name != 'test' OR (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test") + .or(where(TestData::name).not("test").or(where(TestData::name).like("test"))), + sql = "name != 'test' OR (name != 'test' OR (name LIKE 'test'))", + ), + TestCase( + query = where(TestData::name).not("test") + .or(where(TestData::name).not("test")) + .or(where(TestData::name).like("test")), + sql = "name != 'test' OR (name != 'test') OR (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test") + .or( + listOf( + where(TestData::name).not("test"), + where(TestData::name).like("test"), + ), + ), + sql = "name != 'test' OR (name != 'test') OR (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test") + .or(where(TestData::name).not("test").and(where(TestData::name).like("test"))), + sql = "name != 'test' OR (name != 'test' AND (name LIKE 'test'))", + ), + TestCase( + query = where(TestData::name).not("test") + .and(where(TestData::name).not("test").or(where(TestData::name).like("test"))), + sql = "name != 'test' AND (name != 'test' OR (name LIKE 'test'))", + ), + TestCase( + query = where(TestData::name).not("test") + .or(where(TestData::name).not("test")) + .and(where(TestData::name).like("test")), + sql = "name != 'test' OR (name != 'test') AND (name LIKE 'test')", + ), + TestCase( + query = where(TestData::name).not("test") + .and(where(TestData::name).not("test")) + .or(where(TestData::name).like("test")), + sql = "name != 'test' AND (name != 'test') OR (name LIKE 'test')", + ), + ) + + private fun comparisonOperatorsTestCases(): List = listOf( + TestCase( + query = where(TestData::activate).isTrue(), + sql = "activate IS TRUE", + ), + TestCase( + query = where(TestData::activate).isFalse(), + sql = "activate IS FALSE", + ), + TestCase( + query = where(TestData::name).not("test"), + sql = "name != 'test'", + ), + TestCase( + query = where(TestData::name).`is`("test"), + sql = "name = 'test'", + ), + TestCase( + query = where(TestData::age).between(0..10), + sql = "age BETWEEN 0 AND 10", + ), + TestCase( + query = where(TestData::age).notBetween(0..10), + sql = "age NOT BETWEEN 0 AND 10", + ), + TestCase( + query = where(TestData::age).lessThan(0), + sql = "age < 0", + ), + TestCase( + query = where(TestData::age).lessThanOrEquals(0), + sql = "age <= 0", + ), + TestCase( + query = where(TestData::age).greaterThan(0), + sql = "age > 0", + ), + TestCase( + query = where(TestData::age).greaterThanOrEquals(0), + sql = "age >= 0", + ), + ) + private fun logicalOperatorsTestCases(): List = listOf( + TestCase( + query = where(TestData::age).isNull(), + sql = "age IS NULL", + ), + TestCase( + query = where(TestData::age).isNotNull(), + sql = "age IS NOT NULL", + ), + TestCase( + query = where(TestData::name).like("test"), + sql = "name LIKE 'test'", + ), + TestCase( + query = where(TestData::name).notLike("test"), + sql = "name NOT LIKE 'test'", + ), + TestCase( + query = where(TestData::name).`in`("test1", "test2"), + sql = "name IN ('test1', 'test2')", + ), + TestCase( + query = where(TestData::name).notIn("test1", "test2"), + sql = "name NOT IN ('test1', 'test2')", + ), + TestCase( + query = where(TestData::name).`in`(listOf("test1", "test2")), + sql = "name IN ('test1', 'test2')", + ), + TestCase( + query = where(TestData::name).notIn(listOf("test1", "test2")), + sql = "name NOT IN ('test1', 'test2')", + ), + ) +}