diff --git a/.idea/dataSources/data_sources_history.xml b/.idea/dataSources/data_sources_history.xml new file mode 100644 index 0000000..19fba65 --- /dev/null +++ b/.idea/dataSources/data_sources_history.xml @@ -0,0 +1,32 @@ + + + + + " + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/event-demo + master_key + event-demo + + + + + + + + + + + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e78e74a..6c4a236 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,21 +1,19 @@ -@file:Suppress("PropertyName") - import org.jlleitschuh.gradle.ktlint.KtlintExtension -val ktor_version: String by project -val kotlin_version: String by project -val kotlin_serialization_version: String by project -val logback_version: String by project -val koin_version: String by project -val kotlin_logging_version: String by project -val kotest_version: String by project +val ktorVersion: Provider = providers.gradleProperty("ktor_version") +val kotlinVersion: Provider = providers.gradleProperty("kotlin_version") +val kotlinSerializationVersion: Provider = providers.gradleProperty("kotlin_serialization_version") +val logbackVersion: Provider = providers.gradleProperty("logback_version") +val koinVersion: Provider = providers.gradleProperty("koin_version") +val kotlinLoggingVersion: Provider = providers.gradleProperty("kotlin_logging_version") +val kotestVersion: Provider = providers.gradleProperty("kotest_version") plugins { application kotlin("jvm") version "2.1.21" id("io.ktor.plugin") version "3.5.1" id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" - id("org.jlleitschuh.gradle.ktlint") version "12.2.0" + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" id("com.avast.gradle.docker-compose") version "0.17.12" } @@ -29,7 +27,7 @@ application { } configure { - version.set("1.5.0") + version.set("1.8.0") } ktlint { reporters { @@ -124,23 +122,25 @@ dependencies { implementation("io.ktor:ktor-server-data-conversion") implementation("io.ktor:ktor-client-content-negotiation") implementation("io.ktor:ktor-client-auth") - implementation("ch.qos.logback:logback-classic:$logback_version") - implementation("io.insert-koin:koin-ktor:$koin_version") - implementation("io.insert-koin:koin-logger-slf4j:$koin_version") - implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version") + implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}") + implementation("io.insert-koin:koin-ktor:${koinVersion.get()}") + implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}") + implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") implementation("redis.clients:jedis:5.2.0") - implementation("org.postgresql:postgresql:42.7.5") + implementation("org.postgresql:postgresql:42.7.13") implementation("com.zaxxer:HikariCP:6.3.0") implementation("com.rabbitmq:amqp-client:5.25.0") + implementation("com.password4j:password4j:1.8.4") // Force version of sub library (for security) implementation("commons-codec:commons-codec:1.13") - testImplementation("io.kotest:kotest-extensions-koin:$kotest_version") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") - testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version") - testImplementation("io.kotest:kotest-runner-junit5:$kotest_version") + testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}") + testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}") + testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}") testImplementation("io.mockk:mockk:1.13.17") + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") } diff --git a/doc/files-structure.md b/doc/files-structure.md new file mode 100644 index 0000000..5cd8a1d --- /dev/null +++ b/doc/files-structure.md @@ -0,0 +1,67 @@ +# Exemple de structure + +Les couches, du plus interne au plus externe +``` +Domain (le cœur, ne dépend de RIEN d'externe) +↑ +Application (orchestre le Domain, ne connaît pas l'infra concrète) +↑ +Infrastructure (WebSocket, DB, event store — dépend de tout le reste) +``` + +``` +src/ +└── contexts/ + ├── auth/ + └── ... + └── game/ + ├── domain/ ← Le cœur métier, zéro dépendance externe + │ ├── game/ + │ │ ├── Game.ts ← Aggregate Root + │ │ ├── Player.ts ← Entity interne + │ │ ├── Card.ts ← Entity + │ │ ├── Color.ts ← Value Object + │ │ ├── Deck.ts ← VO ou petite structure + │ │ └── errors/ + │ │ ├── InvalidMoveError.ts + │ │ └── ColorChoiceRequiredError.ts + │ └── events/ ← Events de DOMAINE (internes) + │ ├── CardPlayed.ts + │ ├── CardDrawn.ts + │ ├── TurnPassed.ts + │ └── DomainEvent.ts ← interface/type de base + ├── application/ ← Orchestration, cas d'usage + │ ├── commands/ ← Les Commandes (intentions) + │ │ ├── PlayCardCommand.ts + │ │ └── DrawCardCommand.ts + │ ├── handlers/ ← Un handler par commande + │ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save + │ │ └── DrawCardHandler.ts + │ ├── projections/ ← LA LOGIQUE de construction des projections + │ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue + │ │ └── PlayerStatsProjector.kt + │ └── ports/ ← INTERFACES seulement (le "hexagone") + │ ├── GameRepository.ts ← interface, pas d'implémentation + │ ├── EventPublisher.ts ← interface, pas d'implémentation + │ └── ProjectionStore.kt ← interface, où lire/écrire la projection + ├── infrastructure/ ← Tout ce qui est technique/externe + │ ├── persistence/ + │ │ ├── EventStoreGameRepository.ts ← implémente GameRepository + │ │ ├── EventStore.ts + │ │ ├── projections/ + │ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée) + │ │ │ └── models/ + │ │ │ └── GameSummaryView.kt ← structure de la vue elle-même + │ ├── websocket/ + │ │ ├── WebSocketServer.ts + │ │ ├── connectionManager.ts ← Map> + │ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler + │ └── eventPublisher/ + │ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast + └── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer) + ├── clientEvents/ + │ ├── ClientEvent.ts ← types des events envoyés au front + │ └── toClientEvent.ts ← fonction de traduction domain event → client event + └── clientCommands/ + └── parseIncomingCommand.ts ← valide/parse le message brut du client → Command +``` \ No newline at end of file diff --git a/migrations/events/V1__init_event_table.sql b/migrations/events/V1__init_game_event_table.sql similarity index 75% rename from migrations/events/V1__init_event_table.sql rename to migrations/events/V1__init_game_event_table.sql index 6811f49..7c8c93c 100644 --- a/migrations/events/V1__init_event_table.sql +++ b/migrations/events/V1__init_game_event_table.sql @@ -1,4 +1,5 @@ -create table event_stream ( +create schema game; +create table game.game_event_stream ( id uuid not null primary key, aggregate_id uuid not null, version int not null, diff --git a/migrations/events/V2__init_auth_event_table.sql b/migrations/events/V2__init_auth_event_table.sql new file mode 100644 index 0000000..df2d9ab --- /dev/null +++ b/migrations/events/V2__init_auth_event_table.sql @@ -0,0 +1,8 @@ +create schema auth; +create table auth.user_event_stream ( + id uuid not null primary key, + aggregate_id uuid not null, + version int not null, + data jsonb not null, + unique(aggregate_id, version) +); \ No newline at end of file diff --git a/migrations/events/V3__init_auth_projection_table.sql b/migrations/events/V3__init_auth_projection_table.sql new file mode 100644 index 0000000..075f90b --- /dev/null +++ b/migrations/events/V3__init_auth_projection_table.sql @@ -0,0 +1,6 @@ +create table auth.user ( + id uuid not null primary key, + username text not null, + unique(id), + unique(username) +); \ No newline at end of file diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt deleted file mode 100644 index 386e7b3..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.adapter.infrastructure.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventStore -import eventDemo.libs.event.EventStoreInMemory - -/** - * A stream to publish and read the played card event. - */ -class GameEventStoreInMemory : - GameEventStore, - EventStore by EventStoreInMemory() diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt deleted file mode 100644 index 395fe83..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt +++ /dev/null @@ -1,21 +0,0 @@ -package eventDemo.adapter.infrastructure.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventStore -import eventDemo.libs.event.EventStoreInPostgresql -import kotlinx.serialization.json.Json -import javax.sql.DataSource - -/** - * A stream to publish and read the played card event. - */ -class GameEventStoreInPostgresql( - dataSource: DataSource, -) : GameEventStore, - EventStore by EventStoreInPostgresql( - dataSource, - { Json.encodeToString(it) }, - { Json.decodeFromString(it) }, - ) diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt deleted file mode 100644 index 5e2dd5a..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eventDemo.adapter.infrastructure.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInMemory -import io.github.oshai.kotlinlogging.withLoggingContext - -/** - * Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus]. - */ -class GameStateRepositoryInMemory : GameStateRepository { - private val projectionsRepository = - ProjectionRepositoryInMemory( - applyToProjection = GameState::apply, - initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) }, - ) - - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { - // On new event was received, build projection and publish it to the projection bus - eventBus.subscribe { event -> - withLoggingContext("event" to event.toString()) { - projectionsRepository - .applyAndSave(event) - .also { projectionBus.publish(it) } - } - } - } - - /** - * Get the [GameState]. - */ - override fun get(gameId: GameId): GameState = - projectionsRepository.get(gameId) -} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt deleted file mode 100644 index 3963b48..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt +++ /dev/null @@ -1,49 +0,0 @@ -package eventDemo.adapter.infrastructure.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInRedis -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.serialization.json.Json -import redis.clients.jedis.UnifiedJedis - -/** - * Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus]. - */ -class GameStateRepositoryInRedis( - jedis: UnifiedJedis, -) : GameStateRepository { - private val projectionsRepository = - ProjectionRepositoryInRedis( - initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) }, - projectionClass = GameState::class, - projectionToJson = { Json.encodeToString(GameState.serializer(), it) }, - jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) }, - applyToProjection = GameState::apply, - jedis = jedis, - ) - - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { - // On new event was received, build projection and publish it to the projection bus - eventBus.subscribe { event -> - withLoggingContext("event" to event.toString()) { - projectionsRepository - .applyAndSave(event) - .also { projectionBus.publish(it) } - } - } - } - - /** - * Get the [GameState]. - */ - override fun get(gameId: GameId): GameState = - projectionsRepository.get(gameId) -} diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt deleted file mode 100644 index b7d0b1f..0000000 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt +++ /dev/null @@ -1,67 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.notification.Notification -import eventDemo.libs.fromFrameChannel -import eventDemo.libs.toObjectChannel -import io.github.oshai.kotlinlogging.withLoggingContext -import io.ktor.server.auth.authenticate -import io.ktor.server.routing.Route -import io.ktor.server.websocket.DefaultWebSocketServerSession -import io.ktor.server.websocket.webSocket -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.launch -import java.util.UUID - -@DelicateCoroutinesApi -fun Route.gameWebSocket( - playerNotificationListener: PlayerNotificationListener, - commandHandler: GameCommandHandler, -) { - authenticate { - webSocket("/games/new") { - runWebSocket(GameId(), commandHandler, playerNotificationListener) - } - - webSocket("/games/{id}") { - val gameId = GameId(UUID.fromString(call.parameters["id"]!!)) - runWebSocket(gameId, commandHandler, playerNotificationListener) - } - } -} - -@DelicateCoroutinesApi -private fun DefaultWebSocketServerSession.runWebSocket( - gameId: GameId, - commandHandler: GameCommandHandler, - playerNotificationListener: PlayerNotificationListener, -) { - val currentPlayer = call.getPlayerCredentials() - val incomingFrameChannel: ReceiveChannel = toObjectChannel(incoming) - val outgoingFrameChannel: SendChannel = fromFrameChannel(outgoing) - withLoggingContext("currentPlayer" to currentPlayer.toString()) { - val notificationListener = - playerNotificationListener.startListening( - currentPlayer, - gameId, - ) { outgoingFrameChannel.trySendBlocking(it) } - - // TODO change GlobalScope - GlobalScope.launch { - commandHandler.handleIncomingPlayerCommands( - currentPlayer, - gameId, - incomingFrameChannel, - outgoingFrameChannel, - ) - notificationListener.close() - } - } -} diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt deleted file mode 100644 index 766996a..0000000 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.Player -import io.ktor.server.application.ApplicationCall -import io.ktor.server.auth.jwt.JWTPrincipal -import io.ktor.server.auth.principal - -internal fun ApplicationCall.getPlayerCredentials() = - principal()!!.run { - Player( - id = payload.getClaim("playerid").asString(), - name = payload.getClaim("username").asString(), - ) - } diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt deleted file mode 100644 index aa5e161..0000000 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt +++ /dev/null @@ -1,53 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.configuration.serializer.GameIdSerializer -import io.ktor.http.HttpStatusCode -import io.ktor.resources.Resource -import io.ktor.server.auth.authenticate -import io.ktor.server.resources.get -import io.ktor.server.response.respond -import io.ktor.server.routing.Route -import kotlinx.serialization.Serializable - -@Serializable -@Resource("/games/{id}") -class Game( - @Serializable(with = GameIdSerializer::class) - val id: GameId, -) { - @Serializable - @Resource("card/last") - class Card( - val game: Game, - ) - - @Serializable - @Resource("state") - class State( - val game: Game, - ) -} - -/** - * API routes to read the game state. - */ -fun Route.readTheGameState(gameStateRepository: GameStateRepository) { - authenticate { - // Read the last played card on the game. - get { body -> - gameStateRepository - .get(body.game.id) - .cardOnCurrentStack - ?.let { call.respond(it) } - ?: call.response.status(HttpStatusCode.BadRequest) - } - - // Read the last played card on the game. - get { body -> - val state = gameStateRepository.get(body.game.id) - call.respond(state) - } - } -} diff --git a/src/main/kotlin/eventDemo/configuration/Configuration.kt b/src/main/kotlin/eventDemo/configuration/Configuration.kt new file mode 100644 index 0000000..0ee7d73 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/Configuration.kt @@ -0,0 +1,46 @@ +package eventDemo.configuration + +import io.ktor.server.config.ApplicationConfig + +data class Configuration( + val redisUrl: String, + val jwtSecret: String, + val postgresql: Postgresql, + val rabbitmq: RabbitMQ, +) { + data class Postgresql( + val url: String, + val username: String, + val password: String, + ) + + data class RabbitMQ( + val url: String, + val port: Int, + val username: String, + val password: String, + ) +} + +val ApplicationConfig.configuration + get() = + Configuration( + redisUrl = getProperty("redis.url"), + jwtSecret = getProperty("jwt.secret"), + postgresql = + Configuration.Postgresql( + url = getProperty("postgresql.url"), + username = getProperty("postgresql.username"), + password = getProperty("postgresql.password"), + ), + rabbitmq = + Configuration.RabbitMQ( + url = getProperty("rabbitmq.url"), + port = getProperty("rabbitmq.port").toInt(), + username = getProperty("rabbitmq.username"), + password = getProperty("rabbitmq.password"), + ), + ) + +private fun ApplicationConfig.getProperty(path: String): String = + propertyOrNull(path)?.getString() ?: error("You must set the $path") diff --git a/src/main/kotlin/eventDemo/configuration/Configure.kt b/src/main/kotlin/eventDemo/configuration/Configure.kt deleted file mode 100644 index fb45a18..0000000 --- a/src/main/kotlin/eventDemo/configuration/Configure.kt +++ /dev/null @@ -1,29 +0,0 @@ -package eventDemo.configuration - -import eventDemo.configuration.domain.configureGameListener -import eventDemo.configuration.ktor.configureHttpRouting -import eventDemo.configuration.ktor.configureKoin -import eventDemo.configuration.ktor.configureSecurity -import eventDemo.configuration.ktor.configureSerialization -import eventDemo.configuration.ktor.configureWebSockets -import eventDemo.configuration.route.declareHttpGameRoute -import eventDemo.configuration.route.declareWebSocketsGameRoute -import io.ktor.server.application.Application -import org.koin.ktor.ext.get -import org.koin.ktor.ext.getKoin - -fun Application.configure() { - configureKoin() - - configureSecurity() - - configureSerialization() - - configureWebSockets() - declareWebSocketsGameRoute(get(), get()) - - configureHttpRouting() - declareHttpGameRoute() - - getKoin().configureGameListener() -} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt new file mode 100644 index 0000000..fdeb0ba --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt @@ -0,0 +1,14 @@ +package eventDemo.configuration + +import eventDemo.contexts.auth.infrastructure.configure.configureAuthDi +import eventDemo.contexts.game.infrastructure.configuration.injections.application.configureGameDIApplication +import eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure.configureGameDIInfrastructure +import org.koin.dsl.module + +fun appKoinModule(config: Configuration) = + module { + configureDIDataSource(config) + configureAuthDi() + configureGameDIInfrastructure() + configureGameDIApplication() + } diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt new file mode 100644 index 0000000..9a7c972 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt @@ -0,0 +1,55 @@ +package eventDemo.configuration + +import com.rabbitmq.client.ConnectionFactory +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import org.koin.core.module.Module +import org.koin.core.scope.Scope +import org.koin.core.scope.ScopeCallback +import org.koin.dsl.bind +import redis.clients.jedis.JedisPooled +import redis.clients.jedis.UnifiedJedis +import javax.sql.DataSource + +fun Module.configureDIDataSource(config: Configuration) { + // PostgreSQL (for EventStore) + single { + hikariDataSource(config) + .apply { + registerCallback( + object : ScopeCallback { + override fun onScopeClose(scope: Scope) { + close() + } + }, + ) + } + } bind DataSource::class + + // Redis (for Projections) + single { + JedisPooled(config.redisUrl) + } bind UnifiedJedis::class + + // RabbitMQ (for EventBus) + factory { + ConnectionFactory().apply { + host = config.rabbitmq.url + port = config.rabbitmq.port + username = config.rabbitmq.username + password = config.rabbitmq.password + } + } +} + +private fun hikariDataSource(config: Configuration): HikariDataSource = + HikariConfig() + .apply { + jdbcUrl = config.postgresql.url + username = config.postgresql.username + password = config.postgresql.password + maximumPoolSize = 10 + minimumIdle = 10 + }.let { + HikariDataSource(it) + } diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt b/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt new file mode 100644 index 0000000..1fb671d --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt @@ -0,0 +1,16 @@ +package eventDemo.configuration + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import org.koin.ktor.plugin.Koin +import org.koin.logger.slf4jLogger + +fun Application.configureKoin() { + install(Koin) { + slf4jLogger() + + modules( + appKoinModule(environment.config.configuration), + ) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt b/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt new file mode 100644 index 0000000..bd38479 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt @@ -0,0 +1,11 @@ +package eventDemo.configuration + +import eventDemo.contexts.auth.infrastructure.configure.configureAuth +import eventDemo.contexts.game.infrastructure.configuration.ktor.configureUno +import io.ktor.server.application.Application + +fun Application.configure() { + configureKoin() + configureAuth() + configureUno() +} diff --git a/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt deleted file mode 100644 index c49bcfe..0000000 --- a/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt +++ /dev/null @@ -1,21 +0,0 @@ -package eventDemo.configuration.domain - -import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis -import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.event.projection.projectionListener.ReactionListener -import org.koin.core.Koin - -fun Koin.configureGameListener() { - get() - .subscribeToBus(get()) - - get() - .subscribeToBus(get(), get()) - - get() - .subscribeToBus(get(), get()) - - get() - .subscribeToBus(get()) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt deleted file mode 100644 index a62be93..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt +++ /dev/null @@ -1,30 +0,0 @@ -package eventDemo.configuration.injection - -import org.koin.dsl.module - -fun appKoinModule(config: Configuration) = - module { - configureDIBusiness() - configureDIInfrastructure(config) - configureDILibs() - configureDICommandActions() - } - -data class Configuration( - val redisUrl: String, - val postgresql: Postgresql, - val rabbitmq: RabbitMQ, -) { - data class Postgresql( - val url: String, - val username: String, - val password: String, - ) - - data class RabbitMQ( - val url: String, - val port: Int, - val username: String, - val password: String, - ) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt deleted file mode 100644 index 9060c2b..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eventDemo.configuration.injection - -import eventDemo.domain.command.action.ICantPlay -import eventDemo.domain.command.action.IWantToJoinTheGame -import eventDemo.domain.command.action.IWantToPlayCard -import eventDemo.domain.command.action.IamReadyToPlay -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf - -/** - * Configure all actions - */ -fun Module.configureDICommandActions() { - singleOf(::IWantToPlayCard) - singleOf(::IamReadyToPlay) - singleOf(::IWantToJoinTheGame) - singleOf(::ICantPlay) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt deleted file mode 100644 index 8364dd0..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt +++ /dev/null @@ -1,19 +0,0 @@ -package eventDemo.configuration.injection - -import eventDemo.domain.command.GameCommandActionRunner -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.event.projection.projectionListener.ReactionListener -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf - -fun Module.configureDIBusiness() { - single { - GameCommandHandler(get(), get(), get(), get()) - } - singleOf(::GameEventHandler) - singleOf(::GameCommandActionRunner) - singleOf(::PlayerNotificationListener) - singleOf(::ReactionListener) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt deleted file mode 100644 index e99894e..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt +++ /dev/null @@ -1,73 +0,0 @@ -package eventDemo.configuration.injection - -import com.rabbitmq.client.ConnectionFactory -import com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource -import eventDemo.adapter.infrastructure.event.GameEventBusInRabbinMQ -import eventDemo.adapter.infrastructure.event.GameEventStoreInPostgresql -import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis -import eventDemo.adapter.infrastructure.event.projection.GameProjectionBusInRabbitMQ -import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.projection.GameListRepository -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameStateRepository -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf -import org.koin.core.scope.Scope -import org.koin.core.scope.ScopeCallback -import org.koin.dsl.bind -import redis.clients.jedis.JedisPooled -import redis.clients.jedis.UnifiedJedis -import javax.sql.DataSource - -fun Module.configureDIInfrastructure(config: Configuration) { - // Postgresql config - single { - JedisPooled(config.redisUrl) - } bind UnifiedJedis::class - - single { - HikariConfig() - .apply { - jdbcUrl = config.postgresql.url - username = config.postgresql.username - password = config.postgresql.password - maximumPoolSize = 10 - minimumIdle = 10 - }.let { - HikariDataSource(it) - }.also { datasource -> - registerCallback( - object : ScopeCallback { - override fun onScopeClose(scope: Scope) { - datasource.close() - } - }, - ) - } - } bind DataSource::class - - // RabbitMQ config - factory { - ConnectionFactory().apply { - host = config.rabbitmq.url - port = config.rabbitmq.port - username = config.rabbitmq.username - password = config.rabbitmq.password - } - } - - singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class - singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class - singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class - - single { - GameStateRepositoryInRedis(get()) - } bind GameStateRepository::class - - single { - GameListRepositoryInRedis(get()) - } bind GameListRepository::class -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt deleted file mode 100644 index d13a1f8..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eventDemo.configuration.injection - -import eventDemo.libs.event.VersionBuilder -import eventDemo.libs.event.VersionBuilderLocal -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.bind - -fun Module.configureDILibs() { - singleOf(::VersionBuilderLocal) bind VersionBuilder::class -} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt b/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt deleted file mode 100644 index 870077b..0000000 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt +++ /dev/null @@ -1,42 +0,0 @@ -package eventDemo.configuration.ktor - -import eventDemo.configuration.injection.Configuration -import eventDemo.configuration.injection.appKoinModule -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.config.ApplicationConfig -import org.koin.ktor.plugin.Koin -import org.koin.logger.slf4jLogger - -fun Application.configureKoin() { - install(Koin) { - slf4jLogger() - - modules( - appKoinModule( - environment.config.configuration(), - ), - ) - } -} - -fun ApplicationConfig.configuration() = - Configuration( - redisUrl = getProperty("redis.url"), - postgresql = - Configuration.Postgresql( - url = getProperty("postgresql.url"), - username = getProperty("postgresql.username"), - password = getProperty("postgresql.password"), - ), - rabbitmq = - Configuration.RabbitMQ( - url = getProperty("rabbitmq.url"), - port = getProperty("rabbitmq.port").toInt(), - username = getProperty("rabbitmq.username"), - password = getProperty("rabbitmq.password"), - ), - ) - -private fun ApplicationConfig.getProperty(path: String): String = - propertyOrNull(path)?.getString() ?: error("You must set the $path") diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt deleted file mode 100644 index e37e7d3..0000000 --- a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.configuration.route - -import eventDemo.adapter.presenter.query.readGamesList -import eventDemo.adapter.presenter.query.readTheGameState -import io.ktor.server.application.Application -import io.ktor.server.routing.routing -import org.koin.ktor.ext.get - -fun Application.declareHttpGameRoute() { - routing { - readTheGameState(this@declareHttpGameRoute.get()) - readGamesList(this@declareHttpGameRoute.get()) - } -} diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt deleted file mode 100644 index 573f1f3..0000000 --- a/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eventDemo.configuration.route - -import eventDemo.adapter.presenter.query.gameWebSocket -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import io.ktor.server.application.Application -import io.ktor.server.routing.routing -import kotlinx.coroutines.DelicateCoroutinesApi - -@OptIn(DelicateCoroutinesApi::class) -fun Application.declareWebSocketsGameRoute( - playerNotificationListener: PlayerNotificationListener, - commandHandler: GameCommandHandler, -) { - routing { - gameWebSocket(playerNotificationListener, commandHandler) - } -} diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt new file mode 100644 index 0000000..e77e7bf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.auth.application.eventStores + +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.domain.User +import eventDemo.sharedKernel.UserId + +class UserEventStoreRepository( + val eventStore: UserEventStore, +) : UserRepository { + override fun get(id: UserId): User? { + val events = + eventStore + .getStream(id) + .readAll() + if (events.isEmpty()) { + return null + } + return events.let { User.loadFromHistory(it) } + } + + override fun save(user: User) { + eventStore.publish(user.recordedEvents) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt new file mode 100644 index 0000000..4f36eb1 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt @@ -0,0 +1,10 @@ +package eventDemo.contexts.auth.application.eventStores + +import eventDemo.contexts.auth.domain.User +import eventDemo.sharedKernel.UserId + +interface UserRepository { + fun get(id: UserId): User? + + fun save(user: User) +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt new file mode 100644 index 0000000..e576aad --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt @@ -0,0 +1,7 @@ +package eventDemo.contexts.auth.application.ports + +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.sharedKernel.UserId + +interface UserEventStore : EventStore diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt new file mode 100644 index 0000000..9f05c56 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.auth.application.ports + +import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection + +interface UserProjectionRepository { + fun getByUsername(username: String): UserProjection? + + fun save(user: UserProjection) + + fun getUserIfPasswordIsValid( + username: String, + rawPassword: String, + ): UserProjection? +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt new file mode 100644 index 0000000..cf7fe68 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt @@ -0,0 +1,39 @@ +package eventDemo.contexts.auth.domain + +import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: UserId, + val username: String, + val password: String, + val version: Int, +) { + val recordedEvents: Set = emptySet() + + companion object { + fun createNewUser( + username: String, + password: String, + ): User = + apply(NewUserCreatedEvent(username, password, version = 1)) + + fun apply(event: NewUserCreatedEvent): User = + User( + id = event.aggregateId, + username = event.username, + password = event.password, + version = event.version, + ) + + fun loadFromHistory(events: Set): User? = + events.fold(null as User?) { acc, event -> + when (event) { + is NewUserCreatedEvent -> apply(event) + } + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt new file mode 100644 index 0000000..49f5ca8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt @@ -0,0 +1,16 @@ +package eventDemo.contexts.auth.domain.events + +import eventDemo.libs.eventSource.EventId +import eventDemo.sharedKernel.UserId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +class NewUserCreatedEvent( + val username: String, + val password: String, + override val version: Int, + override val createdAt: Instant = Clock.System.now(), + override val aggregateId: UserId = UserId(), +) : UserEvent { + override val eventId: EventId = EventId() +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt new file mode 100644 index 0000000..7cc3199 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt @@ -0,0 +1,6 @@ +package eventDemo.contexts.auth.domain.events + +import eventDemo.libs.eventSource.Event +import eventDemo.sharedKernel.UserId + +sealed interface UserEvent : Event diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt new file mode 100644 index 0000000..726ccd8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt @@ -0,0 +1,13 @@ +package eventDemo.contexts.auth.infrastructure + +import com.password4j.Hash +import com.password4j.Password + +internal fun hashPassword(password: String): Hash = + Password.hash(password).addRandomSalt().withArgon2() + +internal fun checkPassword( + password: String, + hash: Hash, +): Boolean = + Password.check(password, hash) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt new file mode 100644 index 0000000..c52bb3e --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt @@ -0,0 +1,8 @@ +package eventDemo.contexts.auth.infrastructure.configure + +import io.ktor.server.application.Application + +fun Application.configureAuth() { + configureKtorAuth() + configureAuthRoutes() +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt new file mode 100644 index 0000000..82b1d3f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt @@ -0,0 +1,17 @@ +package eventDemo.contexts.auth.infrastructure.configure + +import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInPostgresql +import eventDemo.contexts.auth.infrastructure.persistence.projection.UsedProjectionRepositoryInPostgresql +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind + +fun Module.configureAuthDi() { + singleOf(::UserEventStoreRepository) bind UserRepository::class + singleOf(::UserEventStoreInPostgresql) bind UserEventStore::class + singleOf(::UsedProjectionRepositoryInPostgresql) bind UserProjectionRepository::class +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt new file mode 100644 index 0000000..b2cc987 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt @@ -0,0 +1,20 @@ +package eventDemo.contexts.auth.infrastructure.configure + +import eventDemo.configuration.configuration +import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.rest.createUserRoute +import eventDemo.contexts.auth.infrastructure.rest.loginRoute +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import org.koin.ktor.ext.get + +fun Application.configureAuthRoutes() { + val userRepository = get() + val userProjectionRepository = get() + routing { + createUserRoute(userRepository) + loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt similarity index 56% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt rename to src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt index 88a7fd2..1f8d669 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt @@ -1,8 +1,11 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.auth.infrastructure.configure import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import eventDemo.domain.entity.Player +import eventDemo.configuration.configuration +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection +import eventDemo.sharedKernel.UserId import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.auth.authentication @@ -13,12 +16,10 @@ import io.ktor.server.routing.post import io.ktor.server.routing.routing import kotlinx.serialization.json.Json import java.util.Date +import java.util.UUID -private const val JWT_ISSUER = "PlayCardGame" - -fun Application.configureSecurity() { - val jwtSecret = environment.config.propertyOrNull("jwt.secret")?.getString() ?: error("You must set a jwt secret") - +fun Application.configureKtorAuth() { + val jwtSecret = environment.config.configuration.jwtSecret authentication { jwt { realm = "Play card game" @@ -29,7 +30,11 @@ fun Application.configureSecurity() { .build(), ) validate { credential -> - if (credential.payload.getClaim("username").asString() != "") { + if (credential.payload + .getClaim("username") + .asString() + .isNotEmpty() + ) { JWTPrincipal(credential.payload) } else { null @@ -40,22 +45,25 @@ fun Application.configureSecurity() { } } } - - routing { - post("login/{username}") { - val username = call.parameters["username"]!! - val player = Player(name = username) - - call.respond(hashMapOf("token" to player.makeJwt(jwtSecret))) - } - } } -fun Player.makeJwt(jwtSecret: String): String = +private const val JWT_ISSUER = "PlayCardGame" + +fun UserProjection.makeJwt(jwtSecret: String): String = + makeJwt(jwtSecret, id, username) + +fun User.makeJwt(jwtSecret: String): String = + makeJwt(jwtSecret, id, username) + +fun makeJwt( + jwtSecret: String, + id: UserId, + username: String, +): String = JWT .create() .withIssuer(JWT_ISSUER) - .withClaim("username", name) - .withPayload(Json.encodeToString(this)) + .withClaim("username", username) + .withClaim("userid", id.toString()) .withExpiresAt(Date(System.currentTimeMillis() + 60000)) .sign(Algorithm.HMAC256(jwtSecret)) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt new file mode 100644 index 0000000..f0248c0 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.auth.infrastructure.persistence.eventStore + +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInMemory +import eventDemo.sharedKernel.UserId + +/** + * A stream to publish and read the user events. + */ +class UserEventStoreInMemory : + UserEventStore, + EventStore by EventStoreInMemory() diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt new file mode 100644 index 0000000..7ea4c99 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt @@ -0,0 +1,22 @@ +package eventDemo.contexts.auth.infrastructure.persistence.eventStore + +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.json.Json +import javax.sql.DataSource + +/** + * A stream to publish and read the user events. + */ +class UserEventStoreInPostgresql( + dataSource: DataSource, +) : UserEventStore, + EventStore by EventStoreInPostgresql( + dataSource, + { Json.encodeToString(it) }, + { Json.decodeFromString(it) }, + "auth.user_event_store", + ) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UsedProjectionRepositoryInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UsedProjectionRepositoryInPostgresql.kt new file mode 100644 index 0000000..6c1e208 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UsedProjectionRepositoryInPostgresql.kt @@ -0,0 +1,61 @@ +package eventDemo.contexts.auth.infrastructure.persistence.projection + +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.checkPassword +import eventDemo.contexts.auth.infrastructure.hashPassword +import eventDemo.sharedKernel.UserId +import java.util.UUID +import javax.sql.DataSource + +class UsedProjectionRepositoryInPostgresql( + val dataSource: DataSource, +) : UserProjectionRepository { + override fun getByUsername(username: String): UserProjection? = + dataSource.connection + .prepareStatement( + """ + select id, username + from auth."user" + where id = ?; + """.trimIndent(), + ).use { + it.setObject(1, username) + it.executeQuery() + }.use { resultSet -> + if (resultSet.next()) { + UserProjection( + id = UserId(UUID.fromString(resultSet.getString("id"))), + username = resultSet.getString("username"), + password = resultSet.getString("password"), + ) + } else { + null + } + } + + override fun save(user: UserProjection) { + dataSource.connection.use { connection -> + connection + .prepareStatement( + """ + insert into auth.user (id, username) + values (?, ?) + """.trimIndent(), + ).use { + it.setObject(1, user.id) + it.setString(2, user.username) + it.executeUpdate() + } + } + } + + override fun getUserIfPasswordIsValid( + username: String, + rawPassword: String, + ): UserProjection? { + val user = getByUsername(username) ?: return null + val isValid = checkPassword(rawPassword, hashPassword(user.password)) + if (!isValid) return null + return user + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt new file mode 100644 index 0000000..310d2f6 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.auth.infrastructure.persistence.projection + +import eventDemo.sharedKernel.UserId + +data class UserProjection( + val id: UserId, + val username: String, + val password: String, +) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt new file mode 100644 index 0000000..c994ced --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.auth.infrastructure.rest + +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.configure.makeJwt +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post + +fun Route.loginRoute( + jwtSecret: String, + userProjectionRepository: UserProjectionRepository, +) { + post("login/{username}") { + val username = call.parameters["username"]!! + val rawPassword = call.parameters["password"]!! + + val userProjection = + userProjectionRepository.getUserIfPasswordIsValid(username, rawPassword) + ?: return@post call.respond(HttpStatusCode.BadRequest) + + call.respond(hashMapOf("token" to userProjection.makeJwt(jwtSecret))) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt new file mode 100644 index 0000000..a9f46b0 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt @@ -0,0 +1,43 @@ +package eventDemo.contexts.auth.infrastructure.rest + +import com.password4j.Hash +import com.password4j.Password +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.auth.infrastructure.hashPassword +import io.ktor.resources.Resource +import io.ktor.server.auth.authenticate +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/users") +class Users { + @Serializable + @Resource("/create") + class Create( + val username: String, + val password: String, + ) +} + +/** + * API routes to show all games. + */ +fun Route.createUserRoute(userRepository: UserRepository) { + authenticate { + // Create a new User, and return there ID + post { + val passwordHash = hashPassword(it.password) + val user = User.createNewUser(it.username, passwordHash.result) + userRepository.save(user) + call.respond( + object { + val id = user.id.toString() + }, + ) + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt b/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt new file mode 100644 index 0000000..2c205ff --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt @@ -0,0 +1,37 @@ +package eventDemo.contexts.game.application.channels + +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.notification.CommandSubscriber +import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.sharedKernel.UserId +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel + +class GameChannelsSubscriber( + private val eventToNotificationSubscriber: EventToNotificationSubscriber, + private val commandSubscriber: CommandSubscriber, +) { + @DelicateCoroutinesApi + fun subscribePlayerToGameChannels( + gameId: GameId, + userId: UserId, + incomingCommandChannel: ReceiveChannel, + sendNotificationChannel: SendChannel, + ) { + val sub = + eventToNotificationSubscriber.subscribeToEventsAndSendNotification( + gameId = gameId, + currentUserId = userId, + outgoingFrameChannel = sendNotificationChannel, + ) + + commandSubscriber + .subscribe( + currentUserId = userId, + incomingFrameChannel = incomingCommandChannel, + ).invokeOnCompletion { sub.close() } + } +} diff --git a/src/main/kotlin/eventDemo/domain/command/CommandException.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt similarity index 56% rename from src/main/kotlin/eventDemo/domain/command/CommandException.kt rename to src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt index 187d59f..bd8feea 100644 --- a/src/main/kotlin/eventDemo/domain/command/CommandException.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt @@ -1,4 +1,4 @@ -package eventDemo.domain.command +package eventDemo.contexts.game.application.command.handlers class CommandException( override val message: String, diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt new file mode 100644 index 0000000..2fc3068 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand +import eventDemo.contexts.game.domain.game.GameId +import java.util.Collections + +class GameCommandHandlerDispatcher( + private val playCardHandler: PlayCardHandler, + private val readyToPlayHandler: ReadyToPlayHandler, + private val joinTheGameHandler: JoinTheGameHandler, + private val takeCartFromDrawPileHandler: TakeCartFromDrawPileHandler, +) { + companion object { + val lock: MutableMap = Collections.synchronizedMap(mutableMapOf()) + } + + fun dispatch(command: GameCommand) { + synchronized(lock.getOrPut(command.payload.aggregateId) { command.payload.aggregateId.toString() }) { + when (command) { + is JoinTheGameCommand -> joinTheGameHandler.handle(command) + is ReadyToPlayCommand -> readyToPlayHandler.handle(command) + is PlayCardCommand -> playCardHandler.handle(command) + is TakeCartFromDrawPileCommand -> takeCartFromDrawPileHandler.handle(command) + } + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt new file mode 100644 index 0000000..2de6277 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt @@ -0,0 +1,45 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.libs.command.Command +import kotlin.reflect.KClass + +sealed interface CommandHandler { + fun handle(command: C) +} + +abstract class GameEventManager( + private val gameRepository: GameRepository, + private val gameEventBus: GameEventBus, +) { + fun GameCommand.getGame(): Game = + gameRepository.get(payload.aggregateId) ?: error("Game not found") + + fun GameEvent.getGame(): Game = + gameRepository.get(aggregateId) ?: error("Game not found") + + protected fun Game.saveEvents(): Game { + gameRepository.save(this) + return this + } + + protected fun Game.publishEvents(): Game { + gameEventBus.publish(recordedEvents) + return this + } + + protected fun Game.isStatusOrFail( + kClass: KClass, + message: String, + ): G { + if (kClass.isInstance(this)) { + return this as G + } else { + throw CommandException(message) + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt new file mode 100644 index 0000000..4312c13 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameCreated + +/** + * A command to perform an action to play a new card + */ +class JoinTheGameHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: JoinTheGameCommand) { + command + .getGame() + .isStatusOrFail(GameCreated::class, "The game is started") + .userJoinTheGame( + userId = command.userId, + name = "Name${CharArray(6) { ('A'..'Z').random() }.concatToString()}", + ).saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt new file mode 100644 index 0000000..c047722 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameStarted + +/** + * A command to perform an action to play a new card + */ +class PlayCardHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: PlayCardCommand) { + command + .getGame() + .isStatusOrFail(GameStarted::class, "The game is not started") + .playTheCard( + card = command.payload.card, + playerId = command.payload.playerId, + chosenColor = command.payload.chosenColor, + ).saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt new file mode 100644 index 0000000..28c21e8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameCreated + +/** + * A command to set as ready to play + */ +class ReadyToPlayHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: ReadyToPlayCommand) { + command + .getGame() + .isStatusOrFail(GameCreated::class, "The game is started") + .setReadyPlayer(command.payload.playerId) + .saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt new file mode 100644 index 0000000..2b7f92f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameStarted + +/** + * A command to draw card on draw pile. + * + * Is can be triggered when you cannot play any card in your hand. + */ +class TakeCartFromDrawPileHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: TakeCartFromDrawPileCommand) { + command + .getGame() + .isStatusOrFail(GameStarted::class, "The game is not started") + .playerTakeCartFromDrawPile(command.payload.playerId, 1) + .saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt new file mode 100644 index 0000000..3b7ed9b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt @@ -0,0 +1,19 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.libs.command.Command +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GameCommand : Command { + val userId: UserId + val payload: Payload + + @Serializable + sealed interface Payload { + @Serializable(with = GameIdSerializer::class) + val aggregateId: GameId + } +} diff --git a/src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt similarity index 50% rename from src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt rename to src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt index b70d8a6..fe94917 100644 --- a/src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt @@ -1,22 +1,24 @@ -package eventDemo.domain.command.command +package eventDemo.contexts.game.application.command.models -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId import kotlinx.serialization.Serializable /** * A command to perform an action to play a new card */ @Serializable -data class ICantPlayCommand( +data class JoinTheGameCommand( + override val userId: UserId, override val payload: Payload, ) : GameCommand { override val id: CommandId = CommandId() @Serializable data class Payload( + @Serializable(with = GameIdSerializer::class) override val aggregateId: GameId, - override val player: Player, ) : GameCommand.Payload } diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt new file mode 100644 index 0000000..4fc874b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +/** + * A command to perform an action to play a new card + */ +@Serializable +data class PlayCardCommand( + override val userId: UserId, + override val payload: Payload, +) : GameCommand { + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val playerId: Player.PlayerId, + val card: Card, + val chosenColor: Card.Color?, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt new file mode 100644 index 0000000..143bda9 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt @@ -0,0 +1,28 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +/** + * A command to set as ready to play + */ +@Serializable +data class ReadyToPlayCommand( + override val userId: UserId, + override val payload: Payload, +) : GameCommand { + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val playerId: Player.PlayerId, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt new file mode 100644 index 0000000..ed5b3a5 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt @@ -0,0 +1,28 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +/** + * A command to perform an action to play a new card + */ +@Serializable +data class TakeCartFromDrawPileCommand( + override val userId: UserId, + override val payload: Payload, +) : GameCommand { + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val playerId: Player.PlayerId, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt new file mode 100644 index 0000000..61d8fb0 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.game.application.eventStores + +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.gameState.Game + +class GameEventStoreRepository( + val eventStore: GameEventStore, +) : GameRepository { + override fun get(id: GameId): Game? { + val events = + eventStore + .getStream(id) + .readAll() + if (events.isEmpty()) { + return null + } + return events.let { Game.loadFromHistory(it) } + } + + override fun save(game: Game) { + eventStore.publish(game.recordedEvents) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt new file mode 100644 index 0000000..f6270aa --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.application.eventStores + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameCreated +import eventDemo.contexts.game.domain.game.gameState.GameInit + +interface GameRepository { + fun get(id: GameId): Game? + + fun save(game: Game) + + fun getOrCreate(gameId: GameId): Game = + get(gameId) ?: create(gameId) + + fun create(gameId: GameId = GameId()): GameCreated = + GameInit.createNewGame(gameId).also { save(it) } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt new file mode 100644 index 0000000..656ea52 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt @@ -0,0 +1,29 @@ +package eventDemo.contexts.game.application.logging + +import io.github.oshai.kotlinlogging.withLoggingContext + +inline fun withLoggingContext( + vararg pair: Pair, + body: () -> T, +): T = + withLoggingContext( + *pair + .map { + it.first.name to it.second.toString() + }.toTypedArray(), + restorePrevious = true, + body = body, + ) + +// inline fun withLoggingContext( +// vararg pair: Pair, +// body: () -> Unit, +// ) = +// withLoggingContext( +// *pair +// .map { +// it.first.name to it.second.toString() +// }.toTypedArray(), +// restorePrevious = true, +// body = body, +// ) diff --git a/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt new file mode 100644 index 0000000..5a76672 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.application.logging + +enum class LoggingContextKeys { + CurrentUserId, + Notification, + Game, + Event, + Command, +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt new file mode 100644 index 0000000..09a3b9c --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt @@ -0,0 +1,122 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.notification.models.PilesShuffledNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification +import eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification +import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification +import eventDemo.contexts.game.application.notification.models.PlayerWinNotification +import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification +import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification +import eventDemo.contexts.game.application.notification.models.YourNewCardNotification +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerActionEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import eventDemo.sharedKernel.UserId + +fun GameEvent.toNotification( + game: Game, + currentUserId: UserId, +): Iterable = + Iterable { + iterator { + val currentPlayerId = game.players.get(currentUserId).id + + context(iterator: SequenceScope) + suspend fun Notification.send() { + iterator.yield(this) + } + + fun PlayerActionEvent.isFromCurrentUser(): Boolean = + currentPlayerId != playerId + + when (this@toNotification) { + is GameCreatedEvent -> { + // Nothing to send + } + + is DrawFilledWithDiscardEvent -> { + PilesShuffledNotification().send() + } + + is NewPlayerEvent -> { + if (this@toNotification.isFromCurrentUser()) { + PlayerAsJoinTheGameNotification( + player = this@toNotification.player, + ).send() + } else { + WelcomeToTheGameNotification( + players = game.players, + ).send() + } + } + + is CardIsPlayedEvent -> { + PlayerAsPlayACardNotification( + playerId = this@toNotification.playerId, + card = this@toNotification.card, + ).send() + + if (game is GameStarted) { + ItsTheTurnOfNotification( + player = game.nextPlayer, + ).send() + } + } + + is GameStartedEvent -> { + TheGameWasStartedNotification( + hand = + game.players + .get(currentUserId) + .hand.cards, + ).send() + + if (game is GameStarted) { + ItsTheTurnOfNotification(player = game.nextPlayer) + } + } + + is PlayerHaveDrawCardEvent -> { + if (this@toNotification.isFromCurrentUser()) { + YourNewCardNotification( + cards = this@toNotification.takenCards, + ).send() + } else { + PlayerHavePassNotification( + playerId = this@toNotification.playerId, + ).send() + } + + if (game is GameStarted) { + ItsTheTurnOfNotification(player = game.nextPlayer) + } + } + + is PlayerReadyEvent -> { + if (this@toNotification.isFromCurrentUser()) { + PlayerWasReadyNotification( + playerId = this@toNotification.playerId, + ) + } + } + + is PlayerWinEvent -> { + PlayerWinNotification( + playerId = this@toNotification.playerId, + ) + } + } + } + } diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt new file mode 100644 index 0000000..524a455 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt @@ -0,0 +1,72 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Command +import eventDemo.contexts.game.application.logging.LoggingContextKeys.CurrentUserId +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Event +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Game +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Notification +import eventDemo.contexts.game.application.logging.withLoggingContext +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.bus.Bus +import eventDemo.libs.command.CommandUnicityChecker +import eventDemo.sharedKernel.UserId +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.launch + +class EventToNotificationSubscriber( + private val gameEventBus: GameEventBus, + private val gameRepository: GameRepository, +) { + fun subscribeToEventsAndSendNotification( + gameId: GameId, + currentUserId: UserId, + outgoingFrameChannel: SendChannel, + ): Bus.Subscription = + withLoggingContext(CurrentUserId to currentUserId) { + gameEventBus.subscribe { event -> + val game = gameRepository.get(gameId) ?: error("Game not found") + withLoggingContext(Event to event, Game to game) { + event + .toNotification( + game = game, + currentUserId = currentUserId, + ).forEach { notification -> + withLoggingContext(Notification to notification) { + outgoingFrameChannel.trySendBlocking(notification) + } + } + } + } + } +} + +class CommandSubscriber( + private val gameCommandHandlerDispatcher: GameCommandHandlerDispatcher, +) { + private val controller = CommandUnicityChecker() + + @DelicateCoroutinesApi + fun subscribe( + currentUserId: UserId, + incomingFrameChannel: ReceiveChannel, + ): Job = + GlobalScope.launch { + for (command in incomingFrameChannel) { + withLoggingContext(CurrentUserId to currentUserId, Command to command) { + controller.runOnlyOnce(command) { + gameCommandHandlerDispatcher.dispatch(command) + } + } + } + } +} diff --git a/src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt similarity index 60% rename from src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt index 97fb00d..76a9e78 100644 --- a/src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/domain/notification/Notification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt similarity index 60% rename from src/main/kotlin/eventDemo/domain/notification/Notification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt index 258fb8d..b4a4fa0 100644 --- a/src/main/kotlin/eventDemo/domain/notification/Notification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt @@ -1,6 +1,6 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt new file mode 100644 index 0000000..90b5e83 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt @@ -0,0 +1,11 @@ +package eventDemo.contexts.game.application.notification.models + +import eventDemo.libs.serializer.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PilesShuffledNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), +) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt similarity index 61% rename from src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt index c12291b..ade9911 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt similarity index 50% rename from src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt index 20b61e1..0d6782e 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt @@ -1,8 +1,8 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -10,6 +10,6 @@ import java.util.UUID data class PlayerAsPlayACardNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, val card: Card, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt similarity index 53% rename from src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt index 87328e2..ac25fa1 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class PlayerHavePassNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt similarity index 53% rename from src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt index d25b02d..9eb03e4 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class PlayerWasReadyNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt similarity index 53% rename from src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt index bb6f86b..4b04a49 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class PlayerWinNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt similarity index 55% rename from src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt index bf1508f..34e8a9e 100644 --- a/src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Card -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Card +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class TheGameWasStartedNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val hand: List, + val hand: Set, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt similarity index 61% rename from src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt index e9b1c30..84ad0eb 100644 --- a/src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt similarity index 54% rename from src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt index 328a4b0..64ad41c 100644 --- a/src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Card -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Card +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class YourNewCardNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val card: Card, + val cards: Set, ) : Notification diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt new file mode 100644 index 0000000..5e2c7ff --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt @@ -0,0 +1,6 @@ +package eventDemo.contexts.game.application.ports + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.libs.bus.Bus + +interface GameEventBus : Bus diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt new file mode 100644 index 0000000..4caf8ea --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt @@ -0,0 +1,7 @@ +package eventDemo.contexts.game.application.ports + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.eventStore.EventStore + +interface GameEventStore : EventStore diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt similarity index 55% rename from src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt rename to src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt index 89aef32..4873a60 100644 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt @@ -1,5 +1,7 @@ package eventDemo.domain.event.projection +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList + interface GameListRepository { fun getList(): List } diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt new file mode 100644 index 0000000..a5d20bf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt @@ -0,0 +1,6 @@ +package eventDemo.contexts.game.application.ports + +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection +import eventDemo.libs.bus.Bus + +interface GameProjectionBus : Bus diff --git a/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt b/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt new file mode 100644 index 0000000..335c443 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt @@ -0,0 +1,57 @@ +package eventDemo.contexts.game.application.projections + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList + +fun GameList.apply(event: GameEvent): GameList = + when (event) { + is GameCreatedEvent -> { + this + } + + is NewPlayerEvent -> { + copy( + players = players + event.player, + status = GameList.Status.OPENING, + ) + } + + is GameStartedEvent -> { + copy( + status = GameList.Status.IS_STARTED, + ) + } + + is PlayerWinEvent -> { + copy( + winners = winners + players.filter { it.id == event.playerId }, + status = GameList.Status.FINISH, + ) + } + + is CardIsPlayedEvent -> { + this + } + + is PlayerHaveDrawCardEvent -> { + this + } + + is PlayerReadyEvent -> { + this + } + + is DrawFilledWithDiscardEvent -> { + this + } + }.copy( + lastEventVersion = event.version, + ) diff --git a/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt b/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt new file mode 100644 index 0000000..9a3bc2d --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt @@ -0,0 +1,65 @@ +package eventDemo.contexts.game.application.reaction + +import eventDemo.contexts.game.application.command.handlers.GameEventManager +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.logging.LoggingContextKeys +import eventDemo.contexts.game.application.logging.withLoggingContext +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameCreated +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.concurrent.ConcurrentSkipListSet + +class ReactionListener( + gameRepository: GameRepository, + private val gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus) { + private companion object Config { + val registeredListeners = ConcurrentSkipListSet() + } + + private val logger = KotlinLogging.logger { } + + fun subscribeToBus() { + if (registeredListeners.add(gameEventBus)) { + gameEventBus.subscribe { event -> + val game = event.getGame() + withLoggingContext(LoggingContextKeys.Game to game) { + sendStartGameEvent(game) + sendWinnerEvent(game) + } + } + } else { + "${this::class.simpleName} is already init for this bus".let { + logger.error { it } + error(it) + } + } + } + + private fun sendStartGameEvent(game: Game) { + if (game is GameCreated && game.allPlayerIsReady) { + game + .startGame() + .saveEvents() + .publishEvents() + } + } + + private fun sendWinnerEvent(game: Game) { + if (game is GameStarted) { + val lastPlayerWin = + game + .players + .get(game.lastPlayerId) + .hand.size == 0 + if (lastPlayerWin) { + game + .playerWin(game.lastPlayerId) + .saveEvents() + .publishEvents() + } + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt new file mode 100644 index 0000000..323b528 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Card.Color +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * An [GameEvent] to represent a played card. + */ +@Serializable +data class CardIsPlayedEvent( + override val aggregateId: GameId, + val card: Card, + override val playerId: Player.PlayerId, + val chosenColor: Color? = null, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() + + val theColorCard get() = if (card is Card.CardWithColor) card.color else chosenColor +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt new file mode 100644 index 0000000..e4638a4 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * When the Pile are shuffled after the draw pille was empty + */ +@Serializable +class DrawFilledWithDiscardEvent( + override val aggregateId: GameId, + val newDrawPile: DrawPile, + val newDiscardPile: DiscardPile, + override val version: Int, +) : GameEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt new file mode 100644 index 0000000..f3f26bf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt @@ -0,0 +1,22 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when all players are ready. + */ +@Serializable +data class GameCreatedEvent( + override val aggregateId: GameId, + override val version: Int, +) : GameEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt new file mode 100644 index 0000000..d97881a --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.libs.eventSource.Event +import eventDemo.libs.eventSource.EventId +import kotlinx.serialization.Serializable + +/** + * An [Event] of a Game. + */ +@Serializable +sealed interface GameEvent : Event { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId + + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId + override val version: Int +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt new file mode 100644 index 0000000..141c665 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt @@ -0,0 +1,36 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when all players are ready. + */ +@Serializable +data class GameStartedEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val firstPlayer: Player.PlayerId, + val drawPile: DrawPile, + val discardPile: DiscardPile, + override val version: Int, +) : GameEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} + +private var isDisabled = false + +internal fun disableShuffleDeck() { + isDisabled = true +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt new file mode 100644 index 0000000..ad39c41 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * An [GameEvent] to represent a new player joining the game. + */ +@Serializable +data class NewPlayerEvent( + override val aggregateId: GameId, + val player: Player, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + override val playerId: Player.PlayerId get() = player.id + + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt new file mode 100644 index 0000000..9068b3f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.Player +import kotlinx.serialization.Serializable + +@Serializable +sealed interface PlayerActionEvent : GameEvent { + val playerId: Player.PlayerId +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt new file mode 100644 index 0000000..df769ab --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt @@ -0,0 +1,29 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when a player can play. + */ +@Serializable +data class PlayerHaveDrawCardEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + override val playerId: Player.PlayerId, + val takenCards: Set, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt new file mode 100644 index 0000000..45f9915 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when a player is ready. + */ +@Serializable +data class PlayerReadyEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + override val playerId: Player.PlayerId, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt new file mode 100644 index 0000000..4d9f3fd --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when a player is ready. + */ +@Serializable +data class PlayerWinEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + override val playerId: Player.PlayerId, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/domain/entity/Card.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt similarity index 73% rename from src/main/kotlin/eventDemo/domain/entity/Card.kt rename to src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt index b6048b6..4cd3bf1 100644 --- a/src/main/kotlin/eventDemo/domain/entity/Card.kt +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt @@ -1,6 +1,6 @@ -package eventDemo.domain.entity +package eventDemo.contexts.game.domain.game -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.UUID @@ -23,7 +23,7 @@ sealed interface Card { Green, } - sealed interface ColorCard : Card { + sealed interface CardWithColor : Card { val color: Color } @@ -38,11 +38,14 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Card, - ColorCard { + CardWithColor { init { if (number > 9) error("Card number cannot be greater of 9") if (number < 0) error("Card number cannot be lower of 0") } + + override fun toString(): String = + "Numeric Card $number $color" } sealed interface Special : Card @@ -57,7 +60,10 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - ColorCard + CardWithColor { + override fun toString(): String = + "Revert Card $color" + } sealed interface PassTurnCard : Card @@ -71,8 +77,11 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - ColorCard, - PassTurnCard + CardWithColor, + PassTurnCard { + override fun toString(): String = + "Pass Card $color" + } /** * A play card to force the next player to take 2 card and pass the turn. @@ -84,10 +93,13 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - ColorCard, - PassTurnCard + CardWithColor, + PassTurnCard { + override fun toString(): String = + "Plus2 Card $color" + } - sealed interface AllColorCard : Card + sealed interface CardWith4Color : Card /** * A play card to force the next player to take 4 card and pass the turn. @@ -98,8 +110,11 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - AllColorCard, - PassTurnCard + CardWith4Color, + PassTurnCard { + override fun toString(): String = + "Plus4 Card" + } /** * A play card to change the color. @@ -110,5 +125,8 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - AllColorCard + CardWith4Color { + override fun toString(): String = + "Change color Card" + } } diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt new file mode 100644 index 0000000..82db786 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.domain.game + +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class DiscardPile( + val cards: Set = emptySet(), +) { + fun withNewCard(card: Card): DiscardPile = + DiscardPile(cards + card) + + val topCard: Card? get() = cards.lastOrNull() + + val topCardColor: Card.Color? get() = topCard?.let { if (it is Card.CardWithColor) it.color else null } + + val size: Int get() = cards.size +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt new file mode 100644 index 0000000..9eadf36 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.domain.game + +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class DrawPile( + val cards: Set = emptySet(), +) { + fun take(number: Int): Pair> = + cards.drop(number).toDrawPile() to cards.take(number).toSet() + + val remainingCards + get() = cards.size + + fun shuffled(): DrawPile = + cards.shuffled().toDrawPile() + + val firstCard get() = cards.first() + + fun generateValidDrawPile(): DrawPile = + if (cards.first() is Card.CardWith4Color) { + DrawPile(setOf(cards.first()) + cards.drop(1)) + .generateValidDrawPile() + } else { + this + } +} + +private fun List.toDrawPile(): DrawPile = + DrawPile(this.toSet()) diff --git a/src/main/kotlin/eventDemo/domain/entity/GameId.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt similarity index 56% rename from src/main/kotlin/eventDemo/domain/entity/GameId.kt rename to src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt index 4b364dc..63401b9 100644 --- a/src/main/kotlin/eventDemo/domain/entity/GameId.kt +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.entity +package eventDemo.contexts.game.domain.game -import eventDemo.configuration.serializer.GameIdSerializer -import eventDemo.libs.event.AggregateId +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,8 +9,9 @@ import java.util.UUID * An [AggregateId] for a game. */ @JvmInline -@Serializable(with = GameIdSerializer::class) +@Serializable value class GameId( + @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : AggregateId { override fun toString(): String = diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt new file mode 100644 index 0000000..c071206 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt @@ -0,0 +1,61 @@ +package eventDemo.contexts.game.domain.game + +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.helpers.withReplacedValue +import eventDemo.libs.serializer.UUIDSerializer +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class Player( + val name: String, + val userId: UserId, + val hand: PlayerHand = PlayerHand(), + val id: PlayerId = PlayerId(UUID.randomUUID()), +) { + @JvmInline + @Serializable + value class PlayerId( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + ) : AggregateId { + override fun toString(): String = + id.toString() + } +} + +class PlayerList( + val players: Set = emptySet(), +) : Set by players { + fun get(id: Player.PlayerId): Player = + find { it.id == id } ?: error("no player with id $id") + + fun get(id: UserId): Player = + find { it.userId == id } ?: error("no player with userId $id") + + fun withNewCardOnPlayerHand( + playerId: Player.PlayerId, + cards: Set, + ): PlayerList = + players + .withReplacedValue(get(playerId)) { + it.copy( + hand = it.hand.withNewCards(cards), + ) + }.let { PlayerList(it) } + + fun withDropCardOnPlayerHand( + playerId: Player.PlayerId, + card: Card, + ): PlayerList = + players + .withReplacedValue(get(playerId)) { + it.copy( + hand = it.hand.withoutTheCards(setOf(card)), + ) + }.let { PlayerList(it) } + + operator fun plus(player: Player): PlayerList = + PlayerList(players + player) +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt new file mode 100644 index 0000000..7ddf68b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.domain.game + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class PlayerHand( + val cards: Set = emptySet(), +) { + fun withNewCards(newCards: Set): PlayerHand = + PlayerHand(cards + newCards) + + fun withoutTheCards(newCards: Set): PlayerHand = + PlayerHand(cards - newCards) + + val size: Int get() = + cards.size +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt new file mode 100644 index 0000000..d612630 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt @@ -0,0 +1,68 @@ +package eventDemo.contexts.game.domain.game.errors + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.gameState.Deck +import eventDemo.contexts.game.domain.game.gameState.Game + +abstract class GameException( + message: String, +) : Exception(message) + +abstract class IllegalActionException( + message: String, +) : GameException(message) + +class ItsNotTheTurnException( + val playerId: Player.PlayerId, +) : IllegalActionException("It is not the turn of the player") { + constructor(playerId: Player.PlayerId, event: GameEvent) : this(playerId) +} + +class TheCardIsAColorCardException( + val playerId: Player.PlayerId, +) : IllegalActionException("The card is a color card") + +class TheCardHasNoColorException( + val playerId: Player.PlayerId, +) : IllegalActionException("The card has no color, you must chose a color") + +class ThePlayerHasRemainingCardsException( + val player: Player, +) : IllegalActionException("The player has remaining cards") + +class ThePlayerHasAlreadyWinException( + val playerId: Player.PlayerId, +) : IllegalActionException("The player has already win") + +class ThePlayerIsNotInTheGameException( + val playerId: Player.PlayerId, +) : IllegalActionException("The player is not in the game") + +class ThePlayerMustPlayACardException( + val playerId: Player.PlayerId, + val playableCards: Set, +) : IllegalActionException("The player must be play a card") + +class NeedMorePlayersToStartGameException( + val players: PlayerList, +) : IllegalActionException("You cannot start a game with less than 2 players!") + +class AllPlayerNotReadyException( + val players: PlayerList, +) : IllegalActionException("All players not ready!") + +class DeckMissingCardsException( + val players: PlayerList, + deck: Deck, +) : IllegalActionException("The deck missing cards") + +class InconsistentGameException( + val game: Game, +) : GameException("Inconsistent game state") + +class InconsistentEventVersionException( + val game: Set, +) : GameException("Inconsistent event version") diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt new file mode 100644 index 0000000..fadc9dc --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt @@ -0,0 +1,88 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.errors.GameException +import eventDemo.contexts.game.domain.game.errors.InconsistentEventVersionException + +sealed interface Game { + val aggregateId: GameId + val players: PlayerList + + /** + * On each modification, an event is put in their + */ + val recordedEvents: Set + val version: Int + + enum class Direction { + CLOCKWISE, + COUNTER_CLOCKWISE, + ; + + fun revert(): Direction = + if (this === CLOCKWISE) { + COUNTER_CLOCKWISE + } else { + CLOCKWISE + } + } + + companion object { + fun loadFromHistory(events: Set): Game = + events.fold(GameInit(events.first().aggregateId)) { game: Game, event -> + game.run { + when (event) { + is GameCreatedEvent if this is GameInit -> applyEvent(event) + is GameCreatedEvent -> error("Game is already created") + is NewPlayerEvent if this is GameCreated -> applyEvent(event) + is NewPlayerEvent -> error("Game is already stared") + is PlayerReadyEvent if this is GameCreated -> applyEvent(event) + is PlayerReadyEvent -> error("Game is already stared") + is GameStartedEvent if this is GameCreated -> applyEvent(event) + is GameStartedEvent -> error("Game is already started") + is CardIsPlayedEvent if this is GameStarted -> applyEvent(event) + is CardIsPlayedEvent -> error("Game is end") + is PlayerHaveDrawCardEvent if this is GameStarted -> applyEvent(event) + is PlayerHaveDrawCardEvent -> error("Game is end") + is PlayerWinEvent if this is GameStarted -> applyEvent(event) + is PlayerWinEvent -> error("Game is end") + is DrawFilledWithDiscardEvent if this is GameStarted -> applyEvent(event) + is DrawFilledWithDiscardEvent -> error("Game is end") + } + } + } + } +} + +internal fun T.checkState( + block: (T) -> Boolean, + exception: (T) -> GameException, +): T { + if (!block(this)) throw exception(this) + return this +} + +/** + * recordedEvents versions must be ordered and incremental. + */ +internal fun Game.checkRecorderEventsConsistency() { + recordedEvents + .also { if (it.size != (it.lastOrNull()?.version ?: 0)) throw InconsistentEventVersionException(recordedEvents) } + .mapIndexed { index, event -> + (index + 1) == event.version + }.run { + if (any { !it }) { + throw InconsistentEventVersionException(recordedEvents) + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt new file mode 100644 index 0000000..95c8b94 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt @@ -0,0 +1,140 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.errors.AllPlayerNotReadyException +import eventDemo.contexts.game.domain.game.errors.DeckMissingCardsException +import eventDemo.contexts.game.domain.game.errors.NeedMorePlayersToStartGameException +import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException +import eventDemo.sharedKernel.UserId +import kotlin.collections.plus + +data class GameCreated( + override val aggregateId: GameId, + override val players: PlayerList = PlayerList(), + val playersStatus: Map = emptyMap(), + override val recordedEvents: Set, + override val version: Int, +) : Game { + init { + checkRecorderEventsConsistency() + } + + val allPlayerIsReady: Boolean + get() { + return playersStatus.values.all { it == PlayerStatus.Ready } + } + + fun startGame(deck: Deck = newDeck().shuffleDeck()): GameStarted { + val (drawPile, discardPile) = initPiles(deck) + + return GameStartedEvent( + aggregateId = aggregateId, + firstPlayer = players.random().id, + version = version + 1, + drawPile = drawPile, + discardPile = discardPile, + ).checkState( + { players.size > 1 }, + { NeedMorePlayersToStartGameException(players) }, + ).checkState( + { allPlayerIsReady }, + { AllPlayerNotReadyException(players) }, + ).checkState( + { deck.size == 108 }, + { DeckMissingCardsException(players, deck) }, + ).run(::applyEvent) + } + + private fun initPiles(deck: Set): Pair = + DrawPile(deck) + .generateValidDrawPile() + .take(1) + .let { (draw, cards) -> + draw to DiscardPile(cards) + } + + fun userJoinTheGame( + userId: UserId, + name: String, + ): GameCreated { + if (players.map { it.userId }.contains(userId)) { + throw IllegalStateException("User $userId already in party") + } + + val player = Player(name, userId) + + return applyEvent( + NewPlayerEvent( + aggregateId = aggregateId, + player = player, + version = version + 1, + ), + ) + } + + fun setReadyPlayer(playerId: Player.PlayerId): GameCreated { + if (!players.map { it.id }.contains(playerId)) { + throw ThePlayerIsNotInTheGameException(playerId) + } + + return PlayerReadyEvent(aggregateId, playerId, version + 1) + .run(::applyEvent) + } + + internal fun applyEvent(event: NewPlayerEvent): GameCreated = + copy( + players = players + (event.player), + playersStatus = playersStatus + (event.player.id to PlayerStatus.Waiting), + recordedEvents = recordedEvents + event, + version = version + 1, + ) + + internal fun applyEvent(event: PlayerReadyEvent): GameCreated = + copy( + playersStatus = playersStatus + (event.playerId to PlayerStatus.Ready), + recordedEvents = recordedEvents + event, + version = version + 1, + ) + + internal fun applyEvent(event: GameStartedEvent): GameStarted = + GameStarted( + aggregateId = event.aggregateId, + players = players, + lastPlayerId = event.firstPlayer, + drawPile = event.drawPile, + discardPile = event.discardPile, + version = event.version + 1, + recordedEvents = recordedEvents + event, + currentColor = event.discardPile.topCardColor ?: error("The discard pile was not initialized!"), + ) + + enum class PlayerStatus { + Ready, + Waiting, + } +} + +typealias Deck = Set + +fun newDeck(): Deck = + listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green) + .flatMap { color -> + ((0..9) + (1..9)).map { Card.NumericCard(it, color) } + + (1..2).map { Card.Plus2Card(color) } + + (1..2).map { Card.ReverseCard(color) } + + (1..2).map { Card.PassCard(color) } + }.let { + it + (1..4).map { Card.Plus4Card() } + }.toSet() + +fun Set.shuffleDeck() = + shuffled().toSet() diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt new file mode 100644 index 0000000..a73fa23 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList + +data class GameEnded( + override val aggregateId: GameId, + override val players: PlayerList, + val playerWins: Set = emptySet(), + override val version: Int, + override val recordedEvents: Set, +) : Game { + init { + if (!players.map { it.id }.containsAll(playerWins)) { + throw IllegalArgumentException("Player ${players.map { it.id }} were not in players") + } + checkRecorderEventsConsistency() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt new file mode 100644 index 0000000..939d008 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt @@ -0,0 +1,28 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.PlayerList + +data class GameInit( + override val aggregateId: GameId, +) : Game { + override val players: PlayerList = PlayerList() + override var recordedEvents: Set = emptySet() + + // 0 = no events; not persisted; not really exist. + override var version: Int = 0 + + companion object { + fun createNewGame(gameId: GameId = GameId()): GameCreated { + val event = GameCreatedEvent(gameId, 1) + return GameInit(event.aggregateId).run { + event.run(::applyEvent) + } + } + } + + internal fun applyEvent(event: GameCreatedEvent): GameCreated = + GameCreated(aggregateId, recordedEvents = setOf(event), version = event.version) +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt new file mode 100644 index 0000000..8e6fd24 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt @@ -0,0 +1,256 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.PlayerActionEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Card.Color +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.errors.InconsistentGameException +import eventDemo.contexts.game.domain.game.errors.ItsNotTheTurnException +import eventDemo.contexts.game.domain.game.errors.TheCardHasNoColorException +import eventDemo.contexts.game.domain.game.errors.TheCardIsAColorCardException +import eventDemo.contexts.game.domain.game.errors.ThePlayerHasAlreadyWinException +import eventDemo.contexts.game.domain.game.errors.ThePlayerHasRemainingCardsException +import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException +import eventDemo.contexts.game.domain.game.errors.ThePlayerMustPlayACardException +import eventDemo.contexts.game.domain.game.gameState.Game.Direction + +data class GameStarted( + override val aggregateId: GameId, + override val players: PlayerList, + val drawPile: DrawPile, + val discardPile: DiscardPile, + val lastPlayerId: Player.PlayerId, + val currentColor: Color, + val playedTurnHistory: List = emptyList(), + val direction: Direction = Direction.CLOCKWISE, + val playerWins: Set = emptySet(), + override val version: Int, + override val recordedEvents: Set, +) : Game { + val playersInGame by lazy { players.filter { it.hand.cards.isNotEmpty() } } + + val lastPlayedCard: Card? by lazy { discardPile.topCard } + + val lastPlayed: Player by lazy { players.get(lastPlayerId) } + + init { + checkRecorderEventsConsistency() + } + + data class History( + val playerId: Player.PlayerId, + val event: GameEvent, + val direction: Direction, + ) + + val lastPlayer by lazy { players.get(lastPlayerId) } + + val nextPlayer: Player by lazy { + val playersLastTurn = players.filter { it.hand.cards.isNotEmpty() || it == lastPlayer } + + playersLastTurn + .indexOf(lastPlayer) + .let { lastPlayerIndex -> + if (direction == Direction.CLOCKWISE) { + if (lastPlayerIndex == playersLastTurn.size - 1) { + 0 + } else { + lastPlayerIndex + 1 + } + } else { + if (lastPlayerIndex == 0) { + playersLastTurn.size - 1 + } else { + lastPlayerIndex - 1 + } + } + }.let { nextPlayerIndex -> players.elementAt(nextPlayerIndex) } + } + + fun canBePlayThisCard(card: Card): Boolean { + val cardOnBoard = discardPile.topCard ?: return false + return when (cardOnBoard) { + is Card.NumericCard -> { + when (card) { + is Card.CardWith4Color -> true + is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color + is Card.CardWithColor -> card.color == cardOnBoard.color + } + } + + is Card.ReverseCard -> { + when (card) { + is Card.ReverseCard -> true + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == cardOnBoard.color + } + } + + is Card.PassCard -> { + when (card) { + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == cardOnBoard.color + } + } + + is Card.ChangeColorCard -> { + when (card) { + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == currentColor + } + } + + is Card.Plus2Card -> { + when (card) { + is Card.Plus2Card -> true + ::isPlayedLastTurn -> false + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == currentColor + } + } + + is Card.Plus4Card -> { + when (card) { + is Card.Plus4Card -> true + ::isPlayedLastTurn -> false + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == currentColor + } + } + } + } + + fun playableCards(playerId: Player.PlayerId): Set = + players + .get(playerId) + .hand + .cards + .filter(::canBePlayThisCard) + .toSet() + + fun playTheCard( + playerId: Player.PlayerId, + card: Card, + chosenColor: Color? = null, + ): GameStarted = + CardIsPlayedEvent(aggregateId, card, playerId, chosenColor, version + 1) + .checkPlayerTurn() + .checkState({ card is Card.CardWithColor && chosenColor != null }, { TheCardIsAColorCardException(playerId) }) + .checkState({ card is Card.CardWith4Color && chosenColor == null }, { TheCardHasNoColorException(playerId) }) + .run(::applyEvent) + + internal fun applyEvent(event: CardIsPlayedEvent): GameStarted = + run { + val nextDirectionAfterPlay = + when (event.card) { + is Card.ReverseCard -> direction.revert() + else -> direction + } + + val color = + when (event.card) { + is Card.CardWithColor -> event.card.color + is Card.CardWith4Color -> event.chosenColor!! + } + + copy( + players = players.withDropCardOnPlayerHand(event.playerId, event.card), + discardPile = discardPile.withNewCard(card = event.card), + currentColor = color, + lastPlayerId = event.playerId, + playedTurnHistory = playedTurnHistory - History(event.playerId, event, direction), + direction = nextDirectionAfterPlay, + version = event.version, + recordedEvents = recordedEvents + event, + ) + } + + fun playerTakeCartFromDrawPile( + playerId: Player.PlayerId, + number: Int, + ): GameStarted { + val takenCards = drawPile.take(number).second + return PlayerHaveDrawCardEvent(aggregateId, playerId, takenCards, version + 1) + .checkPlayerTurn() + .checkState({ + playableCards(playerId).isEmpty() + }, { + ThePlayerMustPlayACardException(playerId, playableCards(playerId)) + }) + .run(::applyEvent) + .run { + val missingCardsCount = number - takenCards.size + if (missingCardsCount > 0) { + fillDrawWithDiscard() + .playerTakeCartFromDrawPile(playerId, missingCardsCount) + } else { + this + } + } + } + + internal fun applyEvent(event: PlayerHaveDrawCardEvent): GameStarted = + copy( + players = players.withNewCardOnPlayerHand(event.playerId, event.takenCards), + drawPile = drawPile.take(event.takenCards.size).first, + version = event.version, + recordedEvents = recordedEvents + event, + ) + + /** + * Filling the draw pile with the discard pile while excluding the top card + */ + private fun fillDrawWithDiscard(): GameStarted = + run { + val topCard = discardPile.topCard ?: throw InconsistentGameException(this) + DrawPile(discardPile.cards - topCard).shuffled() to DiscardPile(setOf(topCard)) + }.let { (newDrawPile, newDiscardPile) -> + DrawFilledWithDiscardEvent(aggregateId, newDrawPile, newDiscardPile, version + 1) + .run(::applyEvent) + } + + internal fun applyEvent(event: DrawFilledWithDiscardEvent): GameStarted = + copy( + drawPile = event.newDrawPile, + discardPile = event.newDiscardPile, + version = event.version, + recordedEvents = recordedEvents + event, + ) + + fun playerWin(playerId: Player.PlayerId): GameStarted = + PlayerWinEvent(aggregateId, playerId, version + 1) + .checkState({ + players + .get(playerId) + .hand.cards + .isEmpty() + }, { ThePlayerHasRemainingCardsException(players.get(playerId)) }) + .checkState({ playerWins.contains(playerId) }, { ThePlayerHasAlreadyWinException(playerId) }) + .checkState({ !players.map { it.id }.contains(playerId) }, { ThePlayerIsNotInTheGameException(playerId) }) + .run(::applyEvent) + + internal fun applyEvent(event: PlayerWinEvent): GameStarted = + copy( + playerWins = playerWins + event.playerId, + version = event.version, + recordedEvents = recordedEvents + event, + ) + + private fun T.checkPlayerTurn(): T = + checkState( + { nextPlayer.id == playerId }, + { ItsNotTheTurnException(playerId, this) }, + ) + + private fun isPlayedLastTurn(card: Card): Boolean = + (playedTurnHistory.last().event as? CardIsPlayedEvent)?.card == card +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt new file mode 100644 index 0000000..4bb8d0b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt @@ -0,0 +1,17 @@ +package eventDemo.contexts.game.infrastructure.configuration.injections.application + +import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber +import eventDemo.contexts.game.application.reaction.ReactionListener +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind + +fun Module.configureGameDIApplication() { + configureDICommandHandlers() + + singleOf(::ReactionListener) + singleOf(::EventToNotificationSubscriber) + singleOf(::GameEventStoreRepository) bind GameRepository::class +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt new file mode 100644 index 0000000..047b4bc --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.infrastructure.configuration.injections.application + +import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler +import eventDemo.contexts.game.application.command.handlers.PlayCardHandler +import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler +import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf + +/** + * Configure all actions + */ +fun Module.configureDICommandHandlers() { + singleOf(::PlayCardHandler) + singleOf(::ReadyToPlayHandler) + singleOf(::JoinTheGameHandler) + singleOf(::TakeCartFromDrawPileHandler) +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt new file mode 100644 index 0000000..4b6c0a9 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure + +import eventDemo.contexts.game.application.channels.GameChannelsSubscriber +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.notification.CommandSubscriber +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ +import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInPostgresql +import eventDemo.contexts.game.infrastructure.persistence.projections.GameListRepositoryInRedis +import eventDemo.contexts.game.infrastructure.persistence.projections.bus.GameProjectionBusInRabbitMQ +import eventDemo.domain.event.projection.GameListRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind + +fun Module.configureGameDIInfrastructure() { + singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class + singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class + singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class + singleOf(::CommandSubscriber) + singleOf(::GameChannelsSubscriber) + singleOf(::GameCommandHandlerDispatcher) + singleOf(::GameListRepositoryInRedis) bind GameListRepository::class +} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt similarity index 96% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt index 91c3c12..7620756 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.game.infrastructure.configuration.ktor import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt similarity index 55% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt index 363996f..89da12e 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt @@ -1,12 +1,14 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.game.infrastructure.configuration.ktor -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.CommandIdSerializer -import eventDemo.configuration.serializer.GameIdSerializer -import eventDemo.configuration.serializer.PlayerIdSerializer -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer import eventDemo.libs.command.CommandId +import eventDemo.libs.eventSource.EventId +import eventDemo.libs.serializer.UUIDSerializer import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install @@ -29,6 +31,7 @@ fun defaultJsonSerializer(): Json = SerializersModule { contextual(UUID::class) { UUIDSerializer } contextual(GameId::class) { GameIdSerializer } + contextual(EventId::class) { EventIdSerializer } contextual(CommandId::class) { CommandIdSerializer } contextual(Player.PlayerId::class) { PlayerIdSerializer } } diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt new file mode 100644 index 0000000..1c7af65 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.infrastructure.configuration.ktor + +import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener +import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener +import io.ktor.server.application.Application +import org.koin.ktor.plugin.koin + +fun Application.configureUno() { + configureSerialization() + + configureWebSockets() + declareWebSocketsRoute() + + configureHttpRouting() + declareHttpGameRoute() + + koin().run { + configureProjectionListener() + configureReactionListener() + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt similarity index 86% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt index e97f11a..422e759 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.game.infrastructure.configuration.ktor import io.ktor.server.application.Application import io.ktor.server.application.install diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt new file mode 100644 index 0000000..1fdcabf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.game.infrastructure.configuration.ktor + +import eventDemo.contexts.game.infrastructure.rest.gamesListRoute +import eventDemo.contexts.game.infrastructure.rest.getFullNotificationsRoute +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import org.koin.ktor.ext.get as getDi + +fun Application.declareHttpGameRoute() { + routing { + gamesListRoute(getDi()) + getFullNotificationsRoute(getDi(), getDi()) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt new file mode 100644 index 0000000..7932bc4 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt @@ -0,0 +1,16 @@ +package eventDemo.contexts.game.infrastructure.configuration.ktor + +import eventDemo.contexts.game.infrastructure.websocket.gameWebSocket +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import kotlinx.coroutines.DelicateCoroutinesApi +import org.koin.ktor.ext.get as getDi + +@OptIn(DelicateCoroutinesApi::class) +fun Application.declareWebSocketsRoute() { + routing { + gameWebSocket( + getDi(), + ) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt new file mode 100644 index 0000000..b9b73be --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.infrastructure.configuration.listener + +import eventDemo.contexts.game.infrastructure.persistence.projections.GameListRepositoryInRedis +import org.koin.core.Koin + +fun Koin.configureProjectionListener() { + get() + .subscribeToBus() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt new file mode 100644 index 0000000..549214d --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.infrastructure.configuration.listener + +import eventDemo.contexts.game.application.reaction.ReactionListener +import org.koin.core.Koin + +fun Koin.configureReactionListener() { + get() + .subscribeToBus() +} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt similarity index 68% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt index d72487d..c202cc9 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt @@ -1,7 +1,7 @@ -package eventDemo.adapter.infrastructure.event +package eventDemo.contexts.game.infrastructure.persistence.eventBus -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.event.GameEvent +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInMemory import java.util.UUID diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt similarity index 77% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt index 03baba9..7c25a0a 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt @@ -1,8 +1,8 @@ -package eventDemo.adapter.infrastructure.event +package eventDemo.contexts.game.infrastructure.persistence.eventBus import com.rabbitmq.client.ConnectionFactory -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.event.GameEvent +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInRabbitMQ import kotlinx.serialization.json.Json diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt new file mode 100644 index 0000000..ea44440 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.game.infrastructure.persistence.eventStore + +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInMemory + +/** + * A stream to publish and read the played card event. + */ +class GameEventStoreInMemory : + GameEventStore, + EventStore by EventStoreInMemory() diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt new file mode 100644 index 0000000..a25e219 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt @@ -0,0 +1,22 @@ +package eventDemo.contexts.game.infrastructure.persistence.eventStore + +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql +import kotlinx.serialization.json.Json +import javax.sql.DataSource + +/** + * A stream to publish and read the played card event. + */ +class GameEventStoreInPostgresql( + dataSource: DataSource, +) : GameEventStore, + EventStore by EventStoreInPostgresql( + dataSource, + { Json.encodeToString(it) }, + { Json.decodeFromString(it) }, + "game.game_event_stream", + ) diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt similarity index 70% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt index 42e3575..dcb8eec 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt @@ -1,13 +1,12 @@ -package eventDemo.adapter.infrastructure.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameList +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.application.projections.apply +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList import eventDemo.domain.event.projection.GameListRepository -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInMemory +import eventDemo.libs.eventSource.projection.ProjectionRepositoryInMemory import io.github.oshai.kotlinlogging.withLoggingContext /** diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInRedis.kt similarity index 69% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInRedis.kt index 51ed7e1..7e90899 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInRedis.kt @@ -1,13 +1,12 @@ -package eventDemo.adapter.infrastructure.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameList +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.application.projections.apply +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList import eventDemo.domain.event.projection.GameListRepository -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInRedis +import eventDemo.libs.eventSource.projection.ProjectionRepositoryInRedis import io.github.oshai.kotlinlogging.withLoggingContext import kotlinx.serialization.json.Json import redis.clients.jedis.UnifiedJedis @@ -17,6 +16,8 @@ import redis.clients.jedis.UnifiedJedis */ class GameListRepositoryInRedis( jedis: UnifiedJedis, + val projectionBus: GameProjectionBus, + val eventBus: GameEventBus, ) : GameListRepository { private val projectionsRepository = ProjectionRepositoryInRedis( @@ -28,10 +29,7 @@ class GameListRepositoryInRedis( jedis = jedis, ) - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { + fun subscribeToBus() { eventBus.subscribe { event -> withLoggingContext("event" to event.toString()) { projectionsRepository diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt similarity index 64% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt index 02f46d4..6dad40a 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt @@ -1,7 +1,7 @@ -package eventDemo.adapter.infrastructure.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections.bus -import eventDemo.domain.event.projection.GameProjection -import eventDemo.domain.event.projection.GameProjectionBus +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInMemory import java.util.UUID diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt similarity index 74% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt index d067a61..4f20398 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt @@ -1,8 +1,8 @@ -package eventDemo.adapter.infrastructure.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections.bus import com.rabbitmq.client.ConnectionFactory -import eventDemo.domain.event.projection.GameProjection -import eventDemo.domain.event.projection.GameProjectionBus +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInRabbitMQ import kotlinx.serialization.json.Json diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt similarity index 66% rename from src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt index 1b31a2f..72559ec 100644 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt @@ -1,8 +1,8 @@ -package eventDemo.domain.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections.models -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.event.projection.Projection +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.eventSource.projection.Projection import kotlinx.serialization.Serializable /** diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt new file mode 100644 index 0000000..6882566 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt @@ -0,0 +1,8 @@ +package eventDemo.contexts.game.infrastructure.persistence.projections.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.projection.Projection +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GameProjection : Projection diff --git a/src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt similarity index 91% rename from src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt index ebad2dd..cbe82e9 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.serializer +package eventDemo.contexts.game.infrastructure.persistence.serializers import eventDemo.libs.command.CommandId import kotlinx.serialization.KSerializer diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt new file mode 100644 index 0000000..87096c8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.game.infrastructure.persistence.serializers + +import eventDemo.libs.eventSource.EventId +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.UUID + +object EventIdSerializer : KSerializer { + override fun deserialize(decoder: Decoder): EventId = + EventId(UUID.fromString(decoder.decodeString())) + + override fun serialize( + encoder: Encoder, + value: EventId, + ) { + encoder.encodeString(value.id.toString()) + } + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("EventId", PrimitiveKind.STRING) +} diff --git a/src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt similarity index 85% rename from src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt index d425738..9027285 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt @@ -1,6 +1,6 @@ -package eventDemo.configuration.serializer +package eventDemo.contexts.game.infrastructure.persistence.serializers -import eventDemo.domain.entity.GameId +import eventDemo.contexts.game.domain.game.GameId import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor diff --git a/src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt similarity index 86% rename from src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt index 91559d4..977a25e 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt @@ -1,6 +1,6 @@ -package eventDemo.configuration.serializer +package eventDemo.contexts.game.infrastructure.persistence.serializers -import eventDemo.domain.entity.Player +import eventDemo.contexts.game.domain.game.Player import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt similarity index 82% rename from src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt index c8718ff..73cc341 100644 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt @@ -1,4 +1,4 @@ -package eventDemo.adapter.presenter.query +package eventDemo.contexts.game.infrastructure.rest import eventDemo.domain.event.projection.GameListRepository import io.ktor.resources.Resource @@ -15,7 +15,7 @@ class Games /** * API routes to show all games. */ -fun Route.readGamesList(gameListRepository: GameListRepository) { +fun Route.gamesListRoute(gameListRepository: GameListRepository) { authenticate { // Read the last played card on the game. get { diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt new file mode 100644 index 0000000..d1fd887 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt @@ -0,0 +1,45 @@ +package eventDemo.contexts.game.infrastructure.rest + +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.notification.toNotification +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.sharedKernel.currentUserId +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.auth.authenticate +import io.ktor.server.resources.get +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/games/{id}") +class Game( + @Serializable(with = GameIdSerializer::class) + val id: GameId, +) + +/** + * API routes to read the game state. + */ +fun Route.getFullNotificationsRoute( + gameRepository: GameRepository, + gameEventStore: GameEventStore, +) { + authenticate { + get { body -> + val game = + gameRepository.get(body.id) + ?: return@get call.respond(HttpStatusCode.NotFound) + val notifications = + gameEventStore + .getStream(body.id) + .readAll() + .flatMap { it.toNotification(game, call.currentUserId) } + + call.respond(notifications) + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt new file mode 100644 index 0000000..2165181 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.infrastructure.websocket + +import eventDemo.contexts.game.application.channels.GameChannelsSubscriber +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.helpers.fromFrameChannel +import eventDemo.libs.helpers.toObjectChannel +import eventDemo.sharedKernel.currentUserId +import io.ktor.server.auth.authenticate +import io.ktor.server.routing.Route +import io.ktor.server.websocket.webSocket +import kotlinx.coroutines.DelicateCoroutinesApi +import java.util.UUID + +@DelicateCoroutinesApi +fun Route.gameWebSocket(channelSubscriber: GameChannelsSubscriber) { + authenticate { + webSocket("/games/{id}") { + channelSubscriber.subscribePlayerToGameChannels( + gameId = GameId(UUID.nameUUIDFromBytes(call.parameters["id"]?.encodeToByteArray()!!)), + userId = call.currentUserId, + incomingCommandChannel = toObjectChannel(incoming), + sendNotificationChannel = fromFrameChannel(outgoing), + ) + } + } +} diff --git a/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt b/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt deleted file mode 100644 index d5d421d..0000000 --- a/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eventDemo.domain.command - -import eventDemo.domain.command.action.ICantPlay -import eventDemo.domain.command.action.IWantToJoinTheGame -import eventDemo.domain.command.action.IWantToPlayCard -import eventDemo.domain.command.action.IamReadyToPlay -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.command.command.ICantPlayCommand -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.command.command.IWantToPlayCardCommand -import eventDemo.domain.command.command.IamReadyToPlayCommand -import eventDemo.domain.event.event.GameEvent - -class GameCommandActionRunner( - private val iWantToPlayCard: IWantToPlayCard, - private val iamReadyToPlay: IamReadyToPlay, - private val iWantToJoinTheGame: IWantToJoinTheGame, - private val iCantPlay: ICantPlay, -) { - fun run(command: GameCommand): (version: Int) -> GameEvent = - when (command) { - is IWantToPlayCardCommand -> iWantToPlayCard.run(command) - is IamReadyToPlayCommand -> iamReadyToPlay.run(command) - is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command) - is ICantPlayCommand -> iCantPlay.run(command) - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt b/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt deleted file mode 100644 index 534af5c..0000000 --- a/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt +++ /dev/null @@ -1,139 +0,0 @@ -package eventDemo.domain.command - -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.notification.CommandErrorNotification -import eventDemo.domain.notification.CommandSuccessNotification -import eventDemo.domain.notification.Notification -import eventDemo.libs.command.CommandHandler -import eventDemo.libs.command.CommandRunnerController -import eventDemo.libs.event.EventHandlerImpl -import eventDemo.libs.event.VersionBuilder -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel - -/** - * Listen [GameCommand] on [GameEventBus], check the validity and execute an action. - * - * This action can be executing an action and produce a new [GameEvent] after verification. - */ -class GameCommandHandler( - eventBus: GameEventBus, - eventStore: GameEventStore, - versionBuilder: VersionBuilder, - runner: GameCommandActionRunner, -) { - private val logger = KotlinLogging.logger { } - - private val eventHandler = - EventHandlerImpl( - eventBus, - eventStore, - versionBuilder, - ) - private val commandHandler = - CommandHandler( - CommandRunnerController(), - eventHandler, - ) { - runner.run(it) - } - - /** - * Subscribe to the [event bus][GameEventBus] - * to send success [notification][Notification] after save the [event][GameEvent]. - */ - fun subscribeToBus(eventBus: GameEventBus) = - commandHandler.subscribeToBus(eventBus) - - /** - * Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel], - * run the command and publish the generated [event][GameEvent] to the bus. - * - * It restricts to run only once a command. - * - * If the command fail, send an [error notification][CommandErrorNotification], - * if success, send a [success notification][CommandSuccessNotification] - */ - suspend fun handleIncomingPlayerCommands( - player: Player, - gameId: GameId, - incomingCommandChannel: ReceiveChannel, - channelNotification: SendChannel, - ) { - for (command in incomingCommandChannel) { - handle( - player, - gameId, - command, - channelNotification.sendSuccess(command), - channelNotification.sendError(command), - ) - } - } - - /** - * Run the [command] and publish the generated [event][GameEvent] to the bus. - * - * It restricts to run only once a command. - * - * If the command fail, send an [error notification][CommandErrorNotification], - * if success, send a [success notification][CommandSuccessNotification] - */ - fun handle( - player: Player, - gameId: GameId, - command: GameCommand, - sendSuccess: () -> Unit, - sendError: (message: String) -> Unit, - ) { - if (command.payload.aggregateId.id != gameId.id) { - logger.warn { "Handle command Refuse, the gameId of the command is not the same" } - sendError("The gameId in the command does not match with your game") - return - } - if (command.payload.player.id != player.id) { - logger.warn { "Handle command Refuse, the player of the command is not the same" } - sendError("You are not the author of this command") - return - } - - commandHandler.handle(gameId, command) { _, error -> - if (error != null) { - sendError(error.message) // Business - } else { - sendSuccess() - } - } - } -} - -private fun SendChannel.sendSuccess(command: GameCommand): () -> Unit = - { - val logger = KotlinLogging.logger { } - CommandSuccessNotification(commandId = command.id) - .also { notification -> - withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) { - logger.debug { "Notification SUCCESS sent" } - trySend(notification) - } - } - } - -private fun SendChannel.sendError(command: GameCommand): (message: String) -> Unit = - { - val logger = KotlinLogging.logger { } - CommandErrorNotification(message = it, command = command) - .also { notification -> - withLoggingContext("notification" to notification.toString(), "command" to command.toString()) { - logger.warn { "Notification ERROR sent: ${notification.message}" } - trySend(notification) - } - } - } diff --git a/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt b/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt deleted file mode 100644 index 35fee72..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.libs.command.Command -import eventDemo.libs.event.Event - -sealed interface CommandAction> { - fun run(command: C): (version: Int) -> E -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt b/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt deleted file mode 100644 index 0a9b284..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.ICantPlayCommand -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to perform an action to play a new card - */ -data class ICantPlay( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - - if (state.currentPlayerTurn != command.payload.player) { - throw CommandException("Its not your turn!") - } - - val playableCards = state.playableCards(command.payload.player) - if (playableCards.isNotEmpty()) { - throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}") - } - - val takenCard = state.deck.stack.first() - return { version -> - PlayerHavePassEvent( - aggregateId = command.payload.aggregateId, - player = command.payload.player, - takenCard = takenCard, - version = version, - ) - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt b/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt deleted file mode 100644 index b0b99c4..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to perform an action to play a new card - */ -data class IWantToJoinTheGame( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - if (!state.isStarted) { - return { - NewPlayerEvent( - aggregateId = command.payload.aggregateId, - player = command.payload.player, - version = it, - ) - } - } else { - throw CommandException("The game is already started") - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt b/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt deleted file mode 100644 index 25b27f5..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.IWantToPlayCardCommand -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to perform an action to play a new card - */ -data class IWantToPlayCard( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - - if (!state.isStarted) { - throw CommandException("The game is Not started") - } - if (state.currentPlayerTurn != command.payload.player) { - throw CommandException("Its not your turn!") - } - if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) { - throw CommandException("You cannot play this card") - } - - return { version -> - CardIsPlayedEvent( - aggregateId = command.payload.aggregateId, - card = command.payload.card, - player = command.payload.player, - version = version, - ) - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt b/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt deleted file mode 100644 index 5bc4c9d..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.IamReadyToPlayCommand -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to set as ready to play - */ -class IamReadyToPlay( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - @Throws(CommandException::class) - override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - val playerExist: Boolean = state.players.contains(command.payload.player) - val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player) - - if (state.isStarted) { - throw CommandException("The game is already started") - } else if (!playerExist) { - throw CommandException("You are not in the game") - } else if (playerIsAlreadyReady) { - throw CommandException("You are already ready") - } else { - return { version: Int -> - PlayerReadyEvent( - aggregateId = command.payload.aggregateId, - player = command.payload.player, - version = version, - ) - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt deleted file mode 100644 index a919392..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt +++ /dev/null @@ -1,17 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.Command -import kotlinx.serialization.Serializable - -@Serializable -sealed interface GameCommand : Command { - val payload: Payload - - @Serializable - sealed interface Payload { - val aggregateId: GameId - val player: Player - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt deleted file mode 100644 index cbf49d0..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt +++ /dev/null @@ -1,22 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable - -/** - * A command to perform an action to play a new card - */ -@Serializable -data class IWantToJoinTheGameCommand( - override val payload: Payload, -) : GameCommand { - override val id: CommandId = CommandId() - - @Serializable - data class Payload( - override val aggregateId: GameId, - override val player: Player, - ) : GameCommand.Payload -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt deleted file mode 100644 index 5f92f27..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable - -/** - * A command to perform an action to play a new card - */ -@Serializable -data class IWantToPlayCardCommand( - override val payload: Payload, -) : GameCommand { - override val id: CommandId = CommandId() - - @Serializable - data class Payload( - override val aggregateId: GameId, - override val player: Player, - val card: Card, - ) : GameCommand.Payload -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt deleted file mode 100644 index dbc1b75..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt +++ /dev/null @@ -1,22 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable - -/** - * A command to set as ready to play - */ -@Serializable -data class IamReadyToPlayCommand( - override val payload: Payload, -) : GameCommand { - override val id: CommandId = CommandId() - - @Serializable - data class Payload( - override val aggregateId: GameId, - override val player: Player, - ) : GameCommand.Payload -} diff --git a/src/main/kotlin/eventDemo/domain/entity/Deck.kt b/src/main/kotlin/eventDemo/domain/entity/Deck.kt deleted file mode 100644 index b0dfe17..0000000 --- a/src/main/kotlin/eventDemo/domain/entity/Deck.kt +++ /dev/null @@ -1,133 +0,0 @@ -package eventDemo.domain.entity - -import kotlinx.serialization.Serializable - -@Serializable -data class Deck( - val stack: Stack = Stack(), - val discard: Discard = Discard(), - val playersHands: PlayersHands = PlayersHands(), -) { - constructor(players: Set) : - this(playersHands = PlayersHands(players)) - - fun shuffle(): Deck = - copy(stack = stack.shuffle()) - - fun placeFirstCardOnDiscard(): Deck { - val takenCard = stack.first() - return copy( - stack = stack - takenCard, - discard = discard + takenCard, - ) - } - - fun takeOneCardFromStackTo(player: Player): Deck = - takeOne().let { (deck, newPlayerCard) -> - deck.copy( - playersHands = deck.playersHands.addCard(player, newPlayerCard), - ) - } - - fun putOneCardFromHand( - player: Player, - card: Card, - ): Deck = - run { - // Validate parameters - val playerHand = - playersHands.getHand(player) - ?: error("No player on this game") - if (playerHand.none { it == card }) { - error("No card exist on the player hand") - } - }.let { - copy( - discard = discard + card, - playersHands = playersHands.removeCard(player, card), - ) - } - - fun playerHasNoCardLeft(): List = - playersHands - .filter { (playerId, hand) -> hand.isEmpty() } - .map { (playerId, hand) -> playerId } - - private fun take(n: Int): Pair> { - val takenCards = stack.take(n) - val newStack = stack.filterNot { takenCards.contains(it) }.toStack() - return Pair(copy(stack = newStack), takenCards) - } - - private fun takeOne(): Pair = - take(1).let { (deck, cards) -> Pair(deck, cards.first()) } - - companion object { - fun newWithoutPlayers(): Deck = - listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green) - .flatMap { color -> - ((0..9) + (1..9)).map { Card.NumericCard(it, color) } + - (1..2).map { Card.Plus2Card(color) } + - (1..2).map { Card.ReverseCard(color) } + - (1..2).map { Card.PassCard(color) } - }.let { - it + (1..4).map { Card.Plus4Card() } - }.toStack() - .let { Deck(it) } - } -} - -fun Deck.initHands( - players: Set, - handSize: Int = 7, -): Deck { - // Copy cards from stack to the player hands - val deckWithEmptyHands = copy(playersHands = PlayersHands(players)) - return players.fold(deckWithEmptyHands) { acc: Deck, player: Player -> - val hand = acc.stack.take(handSize) - val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack() - copy( - stack = newStack, - playersHands = acc.playersHands.addCards(player, hand), - ) - } -} - -@JvmInline -@Serializable -value class Stack( - private val cards: Set = emptySet(), -) : Set by cards { - operator fun plus(card: Card): Stack = - cards.plus(card).toStack() - - operator fun minus(card: Card): Stack = - cards.minus(card).toStack() - - fun shuffle(): Stack = - shuffled().toStack() -} - -fun List.toStack(): Stack = - Stack(this.toSet()) - -fun Set.toStack(): Stack = - Stack(this) - -@JvmInline -@Serializable -value class Discard( - private val cards: Set = emptySet(), -) : Set by cards { - operator fun plus(card: Card): Discard = - cards.plus(card).toDiscard() - - operator fun minus(card: Card): Discard = - cards.minus(card).toDiscard() -} - -fun List.toDiscard(): Discard = - Discard(this.toSet()) - -fun Set.toDiscard(): Discard = - Discard(this) diff --git a/src/main/kotlin/eventDemo/domain/entity/Player.kt b/src/main/kotlin/eventDemo/domain/entity/Player.kt deleted file mode 100644 index 6f1c3ed..0000000 --- a/src/main/kotlin/eventDemo/domain/entity/Player.kt +++ /dev/null @@ -1,29 +0,0 @@ -package eventDemo.domain.entity - -import eventDemo.configuration.serializer.PlayerIdSerializer -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.event.AggregateId -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class Player( - val name: String, - @Serializable(with = PlayerIdSerializer::class) - val id: PlayerId = PlayerId(UUID.randomUUID()), -) { - constructor(id: String, name: String) : this( - name, - PlayerId(UUID.fromString(id)), - ) - - @Serializable - @JvmInline - value class PlayerId( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - ) : AggregateId { - override fun toString(): String = - id.toString() - } -} diff --git a/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt b/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt deleted file mode 100644 index 9884627..0000000 --- a/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt +++ /dev/null @@ -1,50 +0,0 @@ -package eventDemo.domain.entity - -import kotlinx.serialization.Serializable - -@Serializable -@JvmInline -value class PlayersHands( - private val map: Map> = emptyMap(), -) : Map> by map { - constructor(players: Set) : - this(players.map { it.id }.associateWith { emptyList() }.toPlayersHands()) - - fun getHand(player: Player): List? = - this[player.id] - - fun removeCard( - player: Player, - card: Card, - ): PlayersHands = - mapValues { (playerId, cards) -> - if (playerId == player.id) { - if (!cards.contains(card)) error("The hand no contain the card") - cards - card - } else { - cards - } - }.toPlayersHands() - - fun addCard( - player: Player, - newCard: Card, - ): PlayersHands = - addCards(player, listOf(newCard)) - - fun addCards( - player: Player, - newCards: List, - ): PlayersHands = - mapValues { (p, cards) -> - if (p == player.id) { - if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card") - cards + newCards - } else { - cards - } - }.toPlayersHands() -} - -fun Map>.toPlayersHands(): PlayersHands = - PlayersHands(this) diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt b/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt deleted file mode 100644 index 995ad14..0000000 --- a/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.bus.Bus - -interface GameEventBus : Bus diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt b/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt deleted file mode 100644 index 3f46a67..0000000 --- a/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventHandler -import eventDemo.libs.event.EventHandlerImpl -import eventDemo.libs.event.VersionBuilder - -/** - * Handle the event to dispatch it to store, bus and projections builders - */ -class GameEventHandler( - private val eventBus: GameEventBus, - private val eventStore: GameEventStore, - private val versionBuilder: VersionBuilder, -) : EventHandler by EventHandlerImpl(eventBus, eventStore, versionBuilder) diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt b/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt deleted file mode 100644 index fbef71f..0000000 --- a/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventStore - -interface GameEventStore : EventStore diff --git a/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt deleted file mode 100644 index d50df11..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * An [GameEvent] to represent a played card. - */ -@Serializable -data class CardIsPlayedEvent( - override val aggregateId: GameId, - val card: Card, - override val player: Player, - override val version: Int, -) : GameEvent, - PlayerActionEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt deleted file mode 100644 index 58fe98c..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.libs.event.Event -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * An [Event] of a Game. - */ -@Serializable -sealed interface GameEvent : Event { - override val eventId: UUID - override val aggregateId: GameId - override val version: Int -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt deleted file mode 100644 index 5bdcb04..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt +++ /dev/null @@ -1,52 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Deck -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.entity.initHands -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when all players are ready. - */ -@Serializable -data class GameStartedEvent( - override val aggregateId: GameId, - val firstPlayer: Player, - val deck: Deck, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() - - companion object { - fun new( - id: GameId, - players: Set, - version: Int, - shuffleIsDisabled: Boolean = isDisabled, - ): GameStartedEvent = - GameStartedEvent( - aggregateId = id, - firstPlayer = if (shuffleIsDisabled) players.first() else players.random(), - deck = - Deck - .newWithoutPlayers() - .let { if (shuffleIsDisabled) it else it.shuffle() } - .initHands(players) - .placeFirstCardOnDiscard(), - version = version, - ) - } -} - -private var isDisabled = false - -internal fun disableShuffleDeck() { - isDisabled = true -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt deleted file mode 100644 index 5affe01..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * An [GameEvent] to represent a new player joining the game. - */ -@Serializable -data class NewPlayerEvent( - override val aggregateId: GameId, - val player: Player, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt deleted file mode 100644 index 2f7eda8..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Player -import kotlinx.serialization.Serializable - -@Serializable -sealed interface PlayerActionEvent : GameEvent { - val player: Player -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt deleted file mode 100644 index 4477e2a..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player chose a color. - */ -@Serializable -data class PlayerChoseColorEvent( - override val aggregateId: GameId, - override val player: Player, - val color: Card.Color, - override val version: Int, -) : GameEvent, - PlayerActionEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt deleted file mode 100644 index e646128..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player can play. - */ -@Serializable -data class PlayerHavePassEvent( - override val aggregateId: GameId, - override val player: Player, - val takenCard: Card, - override val version: Int, -) : GameEvent, - PlayerActionEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt deleted file mode 100644 index 8f0afed..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player is ready. - */ -@Serializable -data class PlayerReadyEvent( - override val aggregateId: GameId, - val player: Player, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt deleted file mode 100644 index 96d73f7..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player is ready. - */ -@Serializable -data class PlayerWinEvent( - override val aggregateId: GameId, - val player: Player, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt b/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt deleted file mode 100644 index 636aa54..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.libs.event.projection.Projection -import kotlinx.serialization.Serializable - -@Serializable -sealed interface GameProjection : Projection diff --git a/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt b/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt deleted file mode 100644 index e8bbe80..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt +++ /dev/null @@ -1,5 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.libs.bus.Bus - -interface GameProjectionBus : Bus diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt deleted file mode 100644 index 0ae729b..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt +++ /dev/null @@ -1,51 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerChoseColorEvent -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.PlayerWinEvent - -fun GameList.apply(event: GameEvent): GameList = - when (event) { - is NewPlayerEvent -> { - copy( - players = players + event.player, - status = GameList.Status.OPENING, - ) - } - - is GameStartedEvent -> { - copy( - status = GameList.Status.IS_STARTED, - ) - } - - is PlayerWinEvent -> { - copy( - winners = winners + event.player, - status = GameList.Status.FINISH, - ) - } - - is CardIsPlayedEvent -> { - this - } - - is PlayerChoseColorEvent -> { - this - } - - is PlayerHavePassEvent -> { - this - } - - is PlayerReadyEvent -> { - this - } - }.copy( - lastEventVersion = event.version, - ) diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt deleted file mode 100644 index 914ee43..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt +++ /dev/null @@ -1,182 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Deck -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.projection.Projection -import kotlinx.serialization.Serializable - -/** - * This [projection][Projection] is used for manage a game and theirs [card][Card] - */ -@Serializable -data class GameState( - override val aggregateId: GameId, - override val lastEventVersion: Int = 0, - val players: Set = emptySet(), - val currentPlayerTurn: Player? = null, - val lastCardPlayer: Player? = null, - val colorOnCurrentStack: Card.Color? = null, - val direction: Direction = Direction.CLOCKWISE, - val readyPlayers: Set = emptySet(), - val deck: Deck = Deck(players), - val isStarted: Boolean = false, - val playerWins: Set = emptySet(), - val lastEvent: GameEvent? = null, -) : GameProjection { - enum class Direction { - CLOCKWISE, - COUNTER_CLOCKWISE, - ; - - fun revert(): Direction = - if (this === CLOCKWISE) { - COUNTER_CLOCKWISE - } else { - CLOCKWISE - } - } - - val cardOnCurrentStack: Card? = deck.discard.lastOrNull() - - val isReady: Boolean get() { - return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) } - } - - private val currentPlayerIndex: Int? get() { - val i = players.indexOf(currentPlayerTurn) - return if (i == -1) { - null - } else { - i - } - } - - private fun nextPlayerIndex(direction: Direction): Int { - if (players.isEmpty()) return 0 - - return if (direction == Direction.CLOCKWISE) { - sidePlayerIndexClockwise - } else { - sidePlayerIndexCounterClockwise - } - } - - fun nextPlayer(direction: Direction): Player = - players.elementAt(nextPlayerIndex(direction)) - - private val sidePlayerIndexClockwise: Int by lazy { - if (players.isEmpty()) { - 0 - } else { - ((currentPlayerIndex ?: 0) + 1) % players.size - } - } - private val sidePlayerIndexCounterClockwise: Int by lazy { - if (players.isEmpty()) { - 0 - } else { - ((currentPlayerIndex ?: 0) - 1) % players.size - } - } - - val nextPlayerTurn: Player? by lazy { - if (players.isEmpty()) { - null - } else { - nextPlayer(direction) - } - } - - private val Player.currentIndex: Int get() = players.indexOf(this) - - fun Player.playerDiffIndex(nextPlayer: Player): Int = - if (direction == Direction.CLOCKWISE) { - nextPlayer.currentIndex + this.currentIndex - } else { - nextPlayer.currentIndex - this.currentIndex - }.let { it % players.size } - - val Player.cardOnBoardIsForYou: Boolean get() { - if (lastCardPlayer == null) error("No card") - return this.playerDiffIndex(lastCardPlayer) == 1 - } - - fun playableCards(player: Player): List = - deck - .playersHands - .getHand(player) - ?.filter { canBePlayThisCard(player, it) } - ?: emptyList() - - fun playerHasNoCardLeft(): List = - deck.playerHasNoCardLeft().map { playerId -> - players.find { it.id == playerId } ?: error("inconsistency detected between players") - } - - fun canBePlayThisCard( - player: Player, - card: Card, - ): Boolean { - val cardOnBoard = cardOnCurrentStack ?: return false - return when (cardOnBoard) { - is Card.NumericCard -> { - when (card) { - is Card.AllColorCard -> true - is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color - is Card.ColorCard -> card.color == cardOnBoard.color - } - } - - is Card.ReverseCard -> { - when (card) { - is Card.ReverseCard -> true - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == cardOnBoard.color - } - } - - is Card.PassCard -> { - if (player.cardOnBoardIsForYou) { - false - } else { - when (card) { - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == cardOnBoard.color - } - } - } - - is Card.ChangeColorCard -> { - when (card) { - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == colorOnCurrentStack - } - } - - is Card.Plus2Card -> { - if (player.cardOnBoardIsForYou && card is Card.Plus2Card) { - true - } else { - when (card) { - is Card.Plus2Card -> true - else -> false - } - } - } - - is Card.Plus4Card -> { - if (player.cardOnBoardIsForYou && card is Card.Plus4Card) { - true - } else { - when (card) { - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == colorOnCurrentStack - } - } - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt deleted file mode 100644 index 3b29dda..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt +++ /dev/null @@ -1,116 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerActionEvent -import eventDemo.domain.event.event.PlayerChoseColorEvent -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.PlayerWinEvent -import io.github.oshai.kotlinlogging.KotlinLogging - -fun GameState.apply(event: GameEvent): GameState = - this.let { state -> - val logger = KotlinLogging.logger { } - if (event is PlayerActionEvent) { - if (state.currentPlayerTurn != event.player) { - logger.atError { - message = "Inconsistent player turn" - payload = - mapOf( - "CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"), - "Player" to event.player, - ) - } - } - } - - when (event) { - is CardIsPlayedEvent -> { - val nextDirectionAfterPlay = - when (event.card) { - is Card.ReverseCard -> state.direction.revert() - else -> state.direction - } - - val color = - when (event.card) { - is Card.ColorCard -> event.card.color - is Card.AllColorCard -> null - } - - val currentPlayerAfterThePlay = - if (event.card is Card.AllColorCard) { - state.currentPlayerTurn - } else { - state.nextPlayer(nextDirectionAfterPlay) - } - - state.copy( - currentPlayerTurn = currentPlayerAfterThePlay, - direction = nextDirectionAfterPlay, - colorOnCurrentStack = color, - lastCardPlayer = event.player, - deck = state.deck.putOneCardFromHand(event.player, event.card), - ) - } - - is NewPlayerEvent -> { - if (state.isStarted) { - logger.error { "The game is already started" } - } - - state.copy( - players = state.players + event.player, - ) - } - - is PlayerReadyEvent -> { - if (state.isStarted) { - logger.error { "The game is already started" } - } - state.copy( - readyPlayers = state.readyPlayers + event.player, - ) - } - - is PlayerHavePassEvent -> { - if (event.takenCard != state.deck.stack.first()) { - logger.error { "taken card is not ot top of the stack: ${event.takenCard}" } - } - state.copy( - currentPlayerTurn = state.nextPlayerTurn, - deck = state.deck.takeOneCardFromStackTo(event.player), - ) - } - - is PlayerChoseColorEvent -> { - state.copy( - currentPlayerTurn = state.nextPlayerTurn, - colorOnCurrentStack = event.color, - ) - } - - is GameStartedEvent -> { - state.copy( - colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack, - lastCardPlayer = null, - currentPlayerTurn = event.firstPlayer, - deck = event.deck, - isStarted = true, - ) - } - - is PlayerWinEvent -> { - state.copy( - playerWins = state.playerWins + event.player, - ) - } - }.copy( - lastEventVersion = event.version, - lastEvent = event, - ) - } diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt deleted file mode 100644 index 1d1d8c6..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.GameId - -interface GameStateRepository { - fun get(gameId: GameId): GameState -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt b/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt deleted file mode 100644 index bc13f91..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt +++ /dev/null @@ -1,149 +0,0 @@ -package eventDemo.domain.event.projection.projectionListener - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerChoseColorEvent -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.PlayerWinEvent -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.notification.ItsTheTurnOfNotification -import eventDemo.domain.notification.Notification -import eventDemo.domain.notification.PlayerAsJoinTheGameNotification -import eventDemo.domain.notification.PlayerAsPlayACardNotification -import eventDemo.domain.notification.PlayerHavePassNotification -import eventDemo.domain.notification.PlayerWasChoseTheCardColorNotification -import eventDemo.domain.notification.PlayerWasReadyNotification -import eventDemo.domain.notification.PlayerWinNotification -import eventDemo.domain.notification.TheGameWasStartedNotification -import eventDemo.domain.notification.WelcomeToTheGameNotification -import eventDemo.domain.notification.YourNewCardNotification -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext - -class PlayerNotificationListener( - private val projectionBus: GameProjectionBus, -) { - private val logger = KotlinLogging.logger {} - - /** - * Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification] - */ - fun startListening( - currentPlayer: Player, - gameId: GameId, - outgoingNotification: (Notification) -> Unit, - ): AutoCloseable { - return projectionBus.subscribe { currentState -> - if (currentState !is GameState) return@subscribe - if (currentState.aggregateId != gameId) return@subscribe - withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) { - fun Notification.send() { - withLoggingContext("notification" to this.toString()) { - if (currentState.players.contains(currentPlayer)) { - // Only notify players who have already joined the game. - outgoingNotification(this) - logger.info { "Notification was SEND" } - } else { - // Rare use case, when a connexion is created with the channel, - // but the player was not already join in the game - logger.warn { "Notification was SKIP, no player on the game" } - } - } - } - - fun sendNextTurnNotif() = - ItsTheTurnOfNotification( - player = currentState.currentPlayerTurn ?: error("No player turn defined"), - ).send() - - val event = - currentState.lastEvent - ?: error("No last event in the GameState projection") - - when (event) { - is NewPlayerEvent -> { - if (currentPlayer != event.player) { - PlayerAsJoinTheGameNotification( - player = event.player, - ).send() - } else { - WelcomeToTheGameNotification( - players = currentState.players, - ).send() - } - } - - is CardIsPlayedEvent -> { - if (currentPlayer != event.player) { - PlayerAsPlayACardNotification( - player = event.player, - card = event.card, - ).send() - } - - if (event.card !is Card.AllColorCard) { - ItsTheTurnOfNotification( - player = currentState.currentPlayerTurn ?: error("No player turn defined"), - ).send() - } - } - - is GameStartedEvent -> { - TheGameWasStartedNotification( - hand = - event.deck.playersHands.getHand(currentPlayer) - ?: error("You are not in the game"), - ).send() - - sendNextTurnNotif() - } - - is PlayerChoseColorEvent -> { - if (currentPlayer != event.player) { - PlayerWasChoseTheCardColorNotification( - player = event.player, - color = event.color, - ).send() - } - - sendNextTurnNotif() - } - - is PlayerHavePassEvent -> { - if (currentPlayer == event.player) { - YourNewCardNotification( - card = event.takenCard, - ).send() - } else { - PlayerHavePassNotification( - player = event.player, - ).send() - } - - sendNextTurnNotif() - } - - is PlayerReadyEvent -> { - if (currentPlayer != event.player) { - PlayerWasReadyNotification( - player = event.player, - ).send() - } - } - - is PlayerWinEvent -> { - PlayerWinNotification( - player = event.player, - ).send() - } - } - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt b/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt deleted file mode 100644 index c437132..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt +++ /dev/null @@ -1,75 +0,0 @@ -package eventDemo.domain.event.projection.projectionListener - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.PlayerWinEvent -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.libs.event.projection.Projection -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import java.util.concurrent.ConcurrentSkipListSet - -class ReactionListener( - private val eventHandler: GameEventHandler, -) { - companion object Config { - val registeredListeners = ConcurrentSkipListSet() - } - - private val logger = KotlinLogging.logger { } - - fun subscribeToBus(projectionBus: GameProjectionBus) { - if (registeredListeners.add(projectionBus)) { - projectionBus.subscribe { projection: Projection -> - if (projection !is GameState) return@subscribe - withLoggingContext("projection" to projection.toString()) { - sendStartGameEvent(projection) - sendWinnerEvent(projection) - } - } - } else { - "${this::class.simpleName} is already init for this bus".let { - logger.error { it } - error(it) - } - } - } - - private fun sendStartGameEvent(state: GameState) { - if (state.isReady && !state.isStarted) { - val reactionEvent = - eventHandler.handle(state.aggregateId) { - GameStartedEvent.new( - id = state.aggregateId, - players = state.players, - version = it, - ) - } - logger.atInfo { - message = "Reaction event was Send" - payload = mapOf("reactionEvent" to reactionEvent) - } - } - } - - private fun sendWinnerEvent(state: GameState) { - val winner = state.playerHasNoCardLeft().firstOrNull() - if (winner != null) { - val reactionEvent = - eventHandler.handle(state.aggregateId) { - PlayerWinEvent( - aggregateId = state.aggregateId, - player = winner, - version = it, - ) - } - - logger.atInfo { - message = "Reaction event was Send" - payload = mapOf("reactionEvent" to reactionEvent) - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt deleted file mode 100644 index 3385315..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eventDemo.domain.notification - -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.command.Command -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class CommandErrorNotification( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - val message: String, - val command: Command, -) : Notification, - CommandNotification diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt deleted file mode 100644 index 5d8462f..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt +++ /dev/null @@ -1,3 +0,0 @@ -package eventDemo.domain.notification - -sealed interface CommandNotification : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt deleted file mode 100644 index 252774a..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.domain.notification - -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class CommandSuccessNotification( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - val commandId: CommandId, -) : Notification, - CommandNotification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt b/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt deleted file mode 100644 index ef5d0b0..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eventDemo.domain.notification - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class PlayerWasChoseTheCardColorNotification( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - val player: Player, - val color: Card.Color, -) : Notification diff --git a/src/main/kotlin/eventDemo/libs/bus/Bus.kt b/src/main/kotlin/eventDemo/libs/bus/Bus.kt index 89381eb..7b08a8c 100644 --- a/src/main/kotlin/eventDemo/libs/bus/Bus.kt +++ b/src/main/kotlin/eventDemo/libs/bus/Bus.kt @@ -6,6 +6,10 @@ interface Bus { */ fun publish(item: T) + fun publish(items: Collection) { + items.forEach { publish(it) } + } + /** * Subscribe a [lambda][block] to the bus. * diff --git a/src/main/kotlin/eventDemo/libs/command/Command.kt b/src/main/kotlin/eventDemo/libs/command/Command.kt index 39b966c..9faba9e 100644 --- a/src/main/kotlin/eventDemo/libs/command/Command.kt +++ b/src/main/kotlin/eventDemo/libs/command/Command.kt @@ -1,6 +1,6 @@ package eventDemo.libs.command -import eventDemo.configuration.serializer.CommandIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt b/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt deleted file mode 100644 index d192ea5..0000000 --- a/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt +++ /dev/null @@ -1,108 +0,0 @@ -package eventDemo.libs.command - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.bus.Bus -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import eventDemo.libs.event.EventHandler -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes - -/** - * Listen [GameCommand] on [CommandStreamChannel], check the validity and execute an action. - * - * This action can be executing an action and produce a new [GameEvent] after verification. - */ -class CommandHandler, E : Event, ID : AggregateId, C : Command>( - private val controller: CommandRunnerController, - private val eventHandler: EventHandler, - private val runner: (command: C) -> (version: Int) -> E, -) { - private val logger = KotlinLogging.logger { } - private val eventCommandMap = EventCommandMap() - - /** subscribe to the event bus to run callback after event was saved. */ - fun subscribeToBus(eventBus: B) { - eventBus.subscribe { event: E -> - eventCommandMap[event.eventId]?.invoke() - ?: logger.debug { "No Notification for event: $event" } - } - } - - /** - * Run the [command] and publish generated [event][Event]. - * - * The [callback] is call after execute the [command] - * - * It restricts to run only once the [command]. - */ - fun handle( - aggregateId: ID, - command: C, - callback: CommandCallback, - ) { - controller.runOnlyOnce(command) { - withLoggingContext("command" to command.toString()) { - logger.info { "Handle command" } - try { - val eventBuilder = runner(command) - - eventHandler.handle(aggregateId) { version -> - eventBuilder(version) - .also { eventCommandMap.set(callback, it, command) } - } - } catch (e: CommandException) { - logger.warn(e) { e.message } - callback(command, e) - } - } - } - } -} - -/** - * Map to record the command that triggered the event. - */ -private class EventCommandMap>( - val retention: Duration = 10.minutes, -) { - val map = ConcurrentHashMap>() - - fun set( - callback: CommandCallback, - event: E, - command: C, - ) { - map[event.eventId] = Callback(callback, command, event, Clock.System.now()) - - // Remove older - map - .filterValues { it.date < (Clock.System.now() - retention) } - .keys - .forEach(map::remove) - } - - operator fun get(eventId: UUID): Callback? = - map[eventId] - - data class Callback>( - val callback: CommandCallback, - val command: C, - val event: E, - val date: Instant, - ) { - operator fun invoke(error: CommandException? = null) { - callback(command, error) - } - } -} - -typealias CommandCallback = (command: C, error: CommandException?) -> Unit diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt deleted file mode 100644 index f9d18f7..0000000 --- a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt +++ /dev/null @@ -1,50 +0,0 @@ -package eventDemo.libs.command - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.coroutines.channels.ReceiveChannel - -/** - * Manage [Command]'s with kotlin Channel. - * - * Use [CommandRunnerController] to prevent multiple executions. - * - * Add logs when command success or failed - */ -class CommandStreamChannel( - private val controller: CommandRunnerController, -) { - private val logger = KotlinLogging.logger {} - - suspend fun process( - incoming: ReceiveChannel, - action: CommandBlock, - ) { - for (command in incoming) { - withLoggingContext("command" to command.toString()) { - try { - controller.runOnlyOnce(command) { - // Wrap action to add logs - runAndLogStatus(command, action) - } - } catch (e: CommandRunnerController.Exception) { - logger.warn { e.message } - } - } - } - } - - private fun runAndLogStatus( - command: C, - action: CommandBlock, - ) { - val actionResult = runCatching { action(command) } - if (actionResult.isFailure) { - logger.warn(actionResult.exceptionOrNull()) { "Compute command FAILED" } - } else if (actionResult.isSuccess) { - logger.info { "Compute command SUCCESS" } - } - } -} - -typealias CommandBlock = (C) -> Unit diff --git a/src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt b/src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt similarity index 76% rename from src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt rename to src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt index ab36f1b..7d7e6b1 100644 --- a/src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt +++ b/src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt @@ -9,32 +9,32 @@ import kotlin.time.Duration.Companion.minutes /** * Controls the execution of a command to prevent it from being executed more than once. */ -class CommandRunnerController( +class CommandUnicityChecker( private val maxCacheTime: Duration = 10.minutes, ) { private val executedCommand: ConcurrentHashMap> = ConcurrentHashMap() fun runOnlyOnce( command: C, - action: CommandBlock, + action: (C) -> Unit, ) { if (!isAlreadyExecuted(command)) { action(command) setAsExecuted(command) removeOldCache() } else { - throw Exception("Command already executed", command) + throw UnicityException("Command already executed", command) } } private fun setAsExecuted(command: C) { - executedCommand.computeIfAbsent(command.id) { Pair(false, Clock.System.now()) } + executedCommand.computeIfAbsent(command.id) { Pair(true, Clock.System.now()) } } private fun removeOldCache() { executedCommand .filterValues { (_, date) -> - (date + maxCacheTime) > Clock.System.now() + (date + maxCacheTime) < Clock.System.now() }.keys .forEach { executedCommand.remove(it) @@ -44,8 +44,8 @@ class CommandRunnerController( private fun isAlreadyExecuted(command: C): Boolean = executedCommand[command.id]?.first ?: false - class Exception( + class UnicityException( override val message: String, val command: Command, - ) : kotlin.Exception(message) + ) : Exception(message) } diff --git a/src/main/kotlin/eventDemo/libs/event/EventHandler.kt b/src/main/kotlin/eventDemo/libs/event/EventHandler.kt deleted file mode 100644 index 00eb39b..0000000 --- a/src/main/kotlin/eventDemo/libs/event/EventHandler.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eventDemo.libs.event - -/** - * A stream to publish and read the played card event. - */ -interface EventHandler, ID : AggregateId> { - fun handle( - aggregateId: ID, - buildEvent: (version: Int) -> E, - ): E -} diff --git a/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt b/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt deleted file mode 100644 index d577530..0000000 --- a/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt +++ /dev/null @@ -1,42 +0,0 @@ -package eventDemo.libs.event - -import eventDemo.libs.bus.Bus -import io.github.oshai.kotlinlogging.withLoggingContext -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -/** - * Handle the event to dispatch it to store, bus and projections builders - */ -class EventHandlerImpl, ID : AggregateId>( - private val eventBus: Bus, - private val eventStore: EventStore, - private val versionBuilder: VersionBuilder, -) : EventHandler { - private val locks: ConcurrentHashMap = ConcurrentHashMap() - - /** - * Build Event then send it to the event store and bus. - */ - override fun handle( - aggregateId: ID, - buildEvent: (version: Int) -> E, - ): E = - withLoggingContext("aggregateId" to aggregateId.toString()) { - locks - // Get lock for the aggregate - .computeIfAbsent(aggregateId) { ReentrantLock() } - .withLock { - // Build event with the version - buildEvent(versionBuilder.buildNextVersion(aggregateId)) - // then publish it to the event store - .also { - withLoggingContext("event" to it.toString()) { - eventStore.publish(it) - eventBus.publish(it) - } - } - } - } -} diff --git a/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt b/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt deleted file mode 100644 index 38ab05e..0000000 --- a/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eventDemo.libs.event - -interface VersionBuilder { - fun buildNextVersion(aggregateId: AggregateId): Int - - fun getLastVersion(aggregateId: AggregateId): Int -} diff --git a/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt b/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt deleted file mode 100644 index ab356fc..0000000 --- a/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt +++ /dev/null @@ -1,25 +0,0 @@ -package eventDemo.libs.event - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger - -class VersionBuilderLocal : VersionBuilder { - private val logger = KotlinLogging.logger { } - private val versions: ConcurrentHashMap = ConcurrentHashMap() - - override fun buildNextVersion(aggregateId: AggregateId): Int = - withLoggingContext("aggregateId" to aggregateId.toString()) { - versionOfAggregate(aggregateId) - .addAndGet(1) - .also { logger.debug { "New event version $it" } } - } - - override fun getLastVersion(aggregateId: AggregateId): Int = - versionOfAggregate(aggregateId).toInt() - - private fun versionOfAggregate(aggregateId: AggregateId) = - versions - .computeIfAbsent(aggregateId) { AtomicInteger(0) } -} diff --git a/src/main/kotlin/eventDemo/libs/event/Event.kt b/src/main/kotlin/eventDemo/libs/eventSource/Event.kt similarity index 63% rename from src/main/kotlin/eventDemo/libs/event/Event.kt rename to src/main/kotlin/eventDemo/libs/eventSource/Event.kt index 749dc82..75c2391 100644 --- a/src/main/kotlin/eventDemo/libs/event/Event.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/Event.kt @@ -1,4 +1,4 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource import kotlinx.datetime.Instant import java.util.UUID @@ -13,11 +13,16 @@ interface AggregateId { /** * The basic interface for an Event - * @see EventStream + * @see eventDemo.libs.eventSource.eventStore.EventStream */ interface Event { - val eventId: UUID + val eventId: EventId val aggregateId: ID val createdAt: Instant val version: Int } + +@JvmInline +value class EventId( + val id: UUID = UUID.randomUUID(), +) diff --git a/src/main/kotlin/eventDemo/libs/event/EventStore.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt similarity index 60% rename from src/main/kotlin/eventDemo/libs/event/EventStore.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt index bc7a75b..718c0d1 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStore.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt @@ -1,5 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.withLoggingContext interface EventStore, ID : AggregateId> { @@ -9,4 +11,7 @@ interface EventStore, ID : AggregateId> { withLoggingContext("event" to event.toString()) { getStream(event.aggregateId).publish(event) } + + fun publish(events: Set) = + events.forEach { publish(it) } } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt similarity index 75% rename from src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt index 9021a76..ac85b99 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt @@ -1,5 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap diff --git a/src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt similarity index 65% rename from src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt index b15859e..1dc2882 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt @@ -1,12 +1,15 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import javax.sql.DataSource class EventStoreInPostgresql, ID : AggregateId>( private val dataSource: DataSource, private val objectToString: (E) -> String, private val stringToObject: (String) -> E, + private val tableName: String, ) : EventStore { override fun getStream(aggregateId: ID): EventStream = - EventStreamInPostgresql(aggregateId, dataSource, objectToString, stringToObject) + EventStreamInPostgresql(aggregateId, dataSource, objectToString, stringToObject, tableName) } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStream.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt similarity index 82% rename from src/main/kotlin/eventDemo/libs/event/EventStream.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt index 5cf7a82..d92459c 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStream.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt @@ -1,6 +1,8 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore -import eventDemo.libs.event.projection.Projection +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event +import eventDemo.libs.eventSource.projection.Projection import io.github.oshai.kotlinlogging.withLoggingContext /** @@ -37,4 +39,6 @@ interface EventStream, ID : AggregateId> { fun getByVersion(version: Int): E? = readVersionBetween(version..version).firstOrNull() + + fun exist(): Boolean } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt similarity index 83% rename from src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt index 48607ad..09c0005 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt @@ -1,5 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.KotlinLogging import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue @@ -34,4 +36,7 @@ class EventStreamInMemory, ID : AggregateId>( events .filter { version.contains(it.version) } .toSet() + + override fun exist(): Boolean = + events.isNotEmpty() } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt similarity index 61% rename from src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt index fe81fe5..43df093 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt @@ -1,6 +1,9 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext import org.postgresql.util.PGobject import javax.sql.DataSource @@ -14,31 +17,34 @@ class EventStreamInPostgresql, ID : AggregateId>( private val dataSource: DataSource, private val objectToString: (E) -> String, private val stringToObject: (String) -> E, + private val tableName: String, ) : EventStream { private val logger = KotlinLogging.logger {} override fun publish(event: E) { - if (event.aggregateId != aggregateId) { - throw EventStreamPublishException( - "You cannot publish this event in this stream because it has a different aggregateId!", - ) + withLoggingContext("event" to event.toString()) { + if (event.aggregateId != aggregateId) { + throw EventStreamPublishException( + "You cannot publish this event in this stream because it has a different aggregateId!", + ) + } + dataSource.connection.use { connection -> + connection + .prepareStatement( + """ + insert into $tableName (id, aggregate_id, version, data) + values (?, ?, ?, ?) + """.trimIndent(), + ).use { + it.setObject(1, event.eventId.id) + it.setObject(2, event.aggregateId.id) + it.setInt(3, event.version) + it.setObject(4, PGJsonb(objectToString(event))) + it.executeUpdate() + } + } + logger.info { "Event published" } } - dataSource.connection.use { connection -> - connection - .prepareStatement( - """ - insert into event_stream(id, aggregate_id, version, data) - values (?, ?, ?, ?) - """.trimIndent(), - ).use { - it.setObject(1, event.eventId) - it.setObject(2, event.aggregateId.id) - it.setInt(3, event.version) - it.setObject(4, PGJsonb(objectToString(event))) - it.executeUpdate() - } - } - logger.info { "Event published" } } override fun readAll(): Set = @@ -46,8 +52,8 @@ class EventStreamInPostgresql, ID : AggregateId>( connection .prepareStatement( """ - select data - from event_stream + select data + from $tableName where aggregate_id = ? order by version asc """.trimIndent(), @@ -66,13 +72,32 @@ class EventStreamInPostgresql, ID : AggregateId>( } } + override fun exist(): Boolean = + dataSource.connection.use { connection -> + connection + .prepareStatement( + """ + select 1 + from $tableName + where aggregate_id = ? + limit 1 + order by version asc + """.trimIndent(), + ).use { + it.setObject(1, aggregateId.id) + it.executeQuery().use { resultSet -> + resultSet.next() + } + } + } + override fun readVersionBetween(version: IntRange): Set = dataSource.connection.use { connection -> connection .prepareStatement( """ select data - from event_stream + from $tableName where version between ? and ? and aggregate_id = ? order by version asc diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt similarity index 66% rename from src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt index c2013f7..df58501 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt @@ -1,4 +1,4 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore class EventStreamPublishException( override val message: String, diff --git a/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/Projection.kt similarity index 50% rename from src/main/kotlin/eventDemo/libs/event/projection/Projection.kt rename to src/main/kotlin/eventDemo/libs/eventSource/projection/Projection.kt index 1f969e7..f917acb 100644 --- a/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/Projection.kt @@ -1,6 +1,6 @@ -package eventDemo.libs.event.projection +package eventDemo.libs.eventSource.projection -import eventDemo.libs.event.AggregateId +import eventDemo.libs.eventSource.AggregateId interface Projection { val aggregateId: ID diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepository.kt similarity index 81% rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepository.kt index ed48797..2db5a00 100644 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepository.kt @@ -1,7 +1,7 @@ -package eventDemo.libs.event.projection +package eventDemo.libs.eventSource.projection -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event interface ProjectionRepository, P : Projection, ID : AggregateId> { /** diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryAbs.kt similarity index 93% rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryAbs.kt index b349a82..e619da2 100644 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryAbs.kt @@ -1,7 +1,7 @@ -package eventDemo.libs.event.projection +package eventDemo.libs.eventSource.projection -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.withLoggingContext diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInMemory.kt similarity index 89% rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInMemory.kt index 3098c48..d1f4368 100644 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInMemory.kt @@ -1,7 +1,7 @@ -package eventDemo.libs.event.projection +package eventDemo.libs.eventSource.projection -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import java.util.concurrent.ConcurrentHashMap class ProjectionRepositoryInMemory, P : Projection, ID : AggregateId>( diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInRedis.kt similarity index 94% rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInRedis.kt index eba9554..43f5362 100644 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInRedis.kt @@ -1,7 +1,7 @@ -package eventDemo.libs.event.projection +package eventDemo.libs.eventSource.projection -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.KotlinLogging import redis.clients.jedis.UnifiedJedis import redis.clients.jedis.params.ScanParams diff --git a/src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt b/src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt similarity index 86% rename from src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt rename to src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt index e51fd3d..902dfcd 100644 --- a/src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt +++ b/src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt @@ -1,4 +1,4 @@ -package eventDemo.libs +package eventDemo.libs.helpers import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.websocket.Frame @@ -25,8 +25,9 @@ inline fun CoroutineScope.toObjectChannel( return produce(capacity = bufferSize) { frames.consumeEach { frame -> if (frame is Frame.Text) { - logger.debug { "Conversion of the Frame: ${frame.readText()}" } - send(Json.decodeFromString(frame.readText())) + val frameText = frame.readText() + logger.debug { "Conversion of the Frame: $frameText to ${T::class.simpleName}" } + send(Json.decodeFromString(frameText)) } else { logger.warn { "The frame is not a text frame" } } diff --git a/src/main/kotlin/eventDemo/libs/ListToRange.kt b/src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt similarity index 89% rename from src/main/kotlin/eventDemo/libs/ListToRange.kt rename to src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt index 4db29c3..bf15b76 100644 --- a/src/main/kotlin/eventDemo/libs/ListToRange.kt +++ b/src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt @@ -1,4 +1,4 @@ -package eventDemo.libs +package eventDemo.libs.helpers fun List.toRanges(): List = fold(listOf()) { acc, i -> diff --git a/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt b/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt new file mode 100644 index 0000000..72dd2ed --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt @@ -0,0 +1,25 @@ +package eventDemo.libs.helpers + +inline fun Map.withReplacedValue( + toReplace: K, + transform: (V) -> V, +): Map = + mapValues { + if (it.key == toReplace) { + transform(it.value) + } else { + it.value + } + } + +inline fun Set.withReplacedValue( + toReplace: V, + transform: (V) -> V, +): Set = + map { + if (it == toReplace) { + transform(it) + } else { + it + } + }.toSet() diff --git a/src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt b/src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt similarity index 94% rename from src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt rename to src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt index 6328f10..92b186d 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt +++ b/src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.serializer +package eventDemo.libs.serializer import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt b/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt new file mode 100644 index 0000000..71098b0 --- /dev/null +++ b/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt @@ -0,0 +1,12 @@ +package eventDemo.sharedKernel + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import java.util.UUID + +internal val ApplicationCall.currentUserId: UserId + get() = + principal()!!.run { + UserId(UUID.fromString(payload.getClaim("userid").asString())) + } diff --git a/src/main/kotlin/eventDemo/sharedKernel/UserId.kt b/src/main/kotlin/eventDemo/sharedKernel/UserId.kt new file mode 100644 index 0000000..7c6a20a --- /dev/null +++ b/src/main/kotlin/eventDemo/sharedKernel/UserId.kt @@ -0,0 +1,13 @@ +package eventDemo.sharedKernel + +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.serializer.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +@JvmInline +value class UserId( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), +) : AggregateId diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 69b6a46..66a0f43 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -3,7 +3,7 @@ ktor { port = 8080 } application { - modules = [ eventDemo.configuration.ConfigureKt.configure ] + modules = [ eventDemo.configuration.ConfigureKtorKt.configure ] } } diff --git a/src/test/kotlin/eventDemo/CreateGameWithCommandsHelpers.kt b/src/test/kotlin/eventDemo/CreateGameWithCommandsHelpers.kt new file mode 100644 index 0000000..e0314ea --- /dev/null +++ b/src/test/kotlin/eventDemo/CreateGameWithCommandsHelpers.kt @@ -0,0 +1,60 @@ +package eventDemo + +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.sharedKernel.UserId +import kotlinx.coroutines.channels.Channel +import org.koin.core.Koin +import java.util.UUID + +object GameWithCommands { + context(koin: Koin) + fun createGameWithCommands( + gameName: String = "testGame${UUID.randomUUID()}", + block: context(GameWithCommands, GameCommandHandlerDispatcher, GameId) ((User) -> Player) -> T, + ): T { + val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray())) + val repo = koin.get() + repo.getOrCreate(gameId) + + return koin.get().run { + with(gameId) { + with(GameWithCommands) { + block { user -> repo.get(gameId)!!.players.get(user.id) } + } + } + } + } + + context(dispatcher: GameCommandHandlerDispatcher, gameId: GameId) + fun User.joinTheGame(): JoinTheGameCommand = + JoinTheGameCommand( + id, + JoinTheGameCommand.Payload(gameId), + ).also { dispatcher.dispatch(it) } + + context(dispatcher: GameCommandHandlerDispatcher, gameId: GameId) + fun Player.readyToPlay(): ReadyToPlayCommand = + ReadyToPlayCommand( + userId, + ReadyToPlayCommand.Payload(gameId, id), + ).also { dispatcher.dispatch(it) } + + context(dispatcher: GameCommandHandlerDispatcher, gameId: GameId) + fun Player.playCard( + card: Card, + chosenColor: Card.Color? = null, + ): PlayCardCommand = + PlayCardCommand( + userId, + PlayCardCommand.Payload(gameId, id, card, chosenColor), + ).also { dispatcher.dispatch(it) } +} diff --git a/src/test/kotlin/eventDemo/CreateGameWithCommandsInChannelsHelpers.kt b/src/test/kotlin/eventDemo/CreateGameWithCommandsInChannelsHelpers.kt new file mode 100644 index 0000000..fec264f --- /dev/null +++ b/src/test/kotlin/eventDemo/CreateGameWithCommandsInChannelsHelpers.kt @@ -0,0 +1,60 @@ +package eventDemo + +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.sharedKernel.UserId +import kotlinx.coroutines.channels.Channel +import org.koin.core.Koin +import java.util.UUID + +object GameWithCommandsInChannels { + context(koin: Koin) + suspend fun createGameWithCommandsInChannels( + channelCommand: Channel, + gameName: String = "testGame${UUID.randomUUID()}", + block: suspend context(Channel, GameId) ((User) -> Player) -> T, + ): T { + val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray())) + val repo = koin.get() + repo.getOrCreate(gameId) + + return with(gameId) { + with(channelCommand) { + with(GameWithCommandsInChannels) { + block { user -> repo.get(gameId)!!.players.get(user.id) } + } + } + } + } + + context(channelCommand: Channel, gameId: GameId) + suspend fun User.joinTheGame(): JoinTheGameCommand = + JoinTheGameCommand( + id, + JoinTheGameCommand.Payload(gameId), + ).also { channelCommand.send(it) } + + context(channelCommand: Channel, gameId: GameId) + suspend fun Player.readyToPlay(): ReadyToPlayCommand = + ReadyToPlayCommand( + userId, + ReadyToPlayCommand.Payload(gameId, id), + ).also { channelCommand.send(it) } + + context(channelCommand: Channel, gameId: GameId) + suspend fun Player.playCard( + card: Card, + chosenColor: Card.Color? = null, + ): PlayCardCommand = + PlayCardCommand( + userId, + PlayCardCommand.Payload(gameId, id, card, chosenColor), + ).also { channelCommand.send(it) } +} diff --git a/src/test/kotlin/eventDemo/NewUser.kt b/src/test/kotlin/eventDemo/NewUser.kt new file mode 100644 index 0000000..487f3c9 --- /dev/null +++ b/src/test/kotlin/eventDemo/NewUser.kt @@ -0,0 +1,21 @@ +package eventDemo + +import eventDemo.contexts.auth.domain.User +import eventDemo.sharedKernel.UserId +import kotlin.reflect.KProperty + +class NewUser { + operator fun getValue( + thisRef: Any?, + property: KProperty<*>, + ): User = + newUser(property.name) +} + +fun newUser(username: String): User = + User( + id = UserId(), + username = username, + password = "changeit", + version = 1, + ) diff --git a/src/test/kotlin/eventDemo/TestAllCardCountHelpers.kt b/src/test/kotlin/eventDemo/TestAllCardCountHelpers.kt new file mode 100644 index 0000000..5f1dd07 --- /dev/null +++ b/src/test/kotlin/eventDemo/TestAllCardCountHelpers.kt @@ -0,0 +1,6 @@ +package eventDemo + +import eventDemo.contexts.game.domain.game.gameState.GameStarted + +fun GameStarted.allCardCount(): Int = + drawPile.remainingCards + discardPile.size + players.map { it.hand }.sumOf { it.size } diff --git a/src/test/kotlin/eventDemo/Helpers.kt b/src/test/kotlin/eventDemo/TestApplicationHelpers.kt similarity index 60% rename from src/test/kotlin/eventDemo/Helpers.kt rename to src/test/kotlin/eventDemo/TestApplicationHelpers.kt index e9ba836..bd24ac9 100644 --- a/src/test/kotlin/eventDemo/Helpers.kt +++ b/src/test/kotlin/eventDemo/TestApplicationHelpers.kt @@ -1,11 +1,10 @@ package eventDemo import com.zaxxer.hikari.HikariDataSource -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Deck -import eventDemo.configuration.domain.configureGameListener -import eventDemo.configuration.injection.appKoinModule -import eventDemo.configuration.ktor.configuration +import eventDemo.configuration.appKoinModule +import eventDemo.configuration.configuration +import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener +import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.server.config.ApplicationConfig import io.ktor.server.testing.ApplicationTestBuilder @@ -15,22 +14,15 @@ import org.koin.core.Koin import org.koin.core.module.KoinApplicationDslMarker import org.koin.dsl.koinApplication import org.koin.ktor.ext.getKoin -import redis.clients.jedis.UnifiedJedis -import javax.sql.DataSource - -fun Deck.allCardCount(): Int = - stack.size + discard.size + playersHands.values.flatten().size - -fun Deck.allCards(): Set = - stack + discard + playersHands.values.flatten() @KoinApplicationDslMarker suspend fun testKoinApplicationWithConfig(block: suspend Koin.() -> T): T = - koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration())) } + koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration)) } .koin .run { cleanDataTest() - configureGameListener() + configureProjectionListener() + configureReactionListener() block() .apply { get().close() } } @@ -62,23 +54,3 @@ fun testApplicationWithConfig( logger.info { "B finish" } } } - -fun DataSource.cleanEventSource() { - this.connection.use { - it - .prepareStatement( - """ - truncate event_stream; - """.trimIndent(), - ).execute() - } -} - -fun UnifiedJedis.cleanProjections() { - flushAll() -} - -fun Koin.cleanDataTest() { - get().cleanEventSource() - get().cleanProjections() -} diff --git a/src/test/kotlin/eventDemo/TestDataHelper.kt b/src/test/kotlin/eventDemo/TestDataHelper.kt new file mode 100644 index 0000000..7d55a54 --- /dev/null +++ b/src/test/kotlin/eventDemo/TestDataHelper.kt @@ -0,0 +1,27 @@ +package eventDemo + +import org.koin.core.Koin +import redis.clients.jedis.UnifiedJedis +import javax.sql.DataSource + +fun DataSource.cleanEventSource() { + this.connection.use { + it + .prepareStatement( + """ + truncate game.game_event_stream; + truncate auth.user_event_stream; + truncate auth.user; + """.trimIndent(), + ).execute() + } +} + +fun UnifiedJedis.cleanProjections() { + flushAll() +} + +fun Koin.cleanDataTest() { + get().cleanEventSource() + get().cleanProjections() +} diff --git a/src/test/kotlin/eventDemo/TestHelper.kt b/src/test/kotlin/eventDemo/TestHelper.kt new file mode 100644 index 0000000..a7ceaad --- /dev/null +++ b/src/test/kotlin/eventDemo/TestHelper.kt @@ -0,0 +1,33 @@ +package eventDemo + +import io.kotest.assertions.nondeterministic.eventually +import io.mockk.spyk +import io.mockk.verify +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +inline fun arrange(block: () -> R): R = + run(block) + +inline fun T.and(block: (T) -> R): R = + this.let(block) + +inline fun T.act(block: (T) -> R): R = + this.let(block) + +inline fun T.assert(block: (T) -> R): R = + this.let(block) + +suspend fun spyPing( + duration: Duration = 0.5.seconds, + exactly: Int = -1, + block: (ping: () -> Unit) -> Unit, +) { + val spy = spyk<() -> Unit>() + + block(spy) + + eventually(duration) { + verify(exactly = exactly) { spy() } + } +} diff --git a/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt b/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt deleted file mode 100644 index 32f2246..0000000 --- a/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package eventDemo.adapter.infrastructure.event - -import com.rabbitmq.client.ConnectionFactory -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.testKoinApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.kotest.datatest.withData -import io.kotest.matchers.equals.shouldBeEqual -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import java.util.function.Function -import kotlin.time.Duration.Companion.seconds - -class GameEventBusInRabbitMQTest : - FunSpec({ - context("Pub/sub") { - testKoinApplicationWithConfig { - val busListToTest: Map = - mapOf( - GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(), - GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get()), - ) - - withData(busListToTest) { bus -> - val spy = spyk<() -> Unit>() - val aggregateId = GameId() - val player1 = Player(name = "Tesla") - val player2 = Player(name = "Einstein") - - bus.subscribe { obj -> - spy() - obj.aggregateId shouldBeEqual aggregateId - } - bus.publish(NewPlayerEvent(aggregateId, player1, 1)) - bus.publish(NewPlayerEvent(aggregateId, player2, 2)) - - eventually(1.seconds) { - verify(exactly = 2) { spy() } - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt deleted file mode 100644 index 55d8823..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.Player -import eventDemo.configuration.ktor.makeJwt -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.header - -internal fun HttpRequestBuilder.withAuth(player: Player) { - header("Authorization", "Bearer ${player.makeJwt("secret")}") -} diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt deleted file mode 100644 index 6efdcfe..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt +++ /dev/null @@ -1,196 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.Tag -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.command.command.IWantToPlayCardCommand -import eventDemo.domain.command.command.IamReadyToPlayCommand -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.disableShuffleDeck -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.notification.CommandSuccessNotification -import eventDemo.domain.notification.ItsTheTurnOfNotification -import eventDemo.domain.notification.Notification -import eventDemo.domain.notification.PlayerAsJoinTheGameNotification -import eventDemo.domain.notification.PlayerAsPlayACardNotification -import eventDemo.domain.notification.PlayerWasReadyNotification -import eventDemo.domain.notification.TheGameWasStartedNotification -import eventDemo.domain.notification.WelcomeToTheGameNotification -import eventDemo.testKoinApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.nondeterministic.until -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -@DelicateCoroutinesApi -class GameSimulationTest : - FunSpec({ - tags(Tag.Postgresql) - - test("Simulation of a game") { - withTimeout(10.seconds) { - disableShuffleDeck() - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - val channelCommand1 = Channel(Channel.BUFFERED) - val channelCommand2 = Channel(Channel.BUFFERED) - val channelNotification1 = Channel(Channel.BUFFERED) - val channelNotification2 = Channel(Channel.BUFFERED) - - var playedCard1: Card? = null - var playedCard2: Card? = null - - var player1HasJoin = false - - testKoinApplicationWithConfig { - val commandHandler = get() - val playerNotificationListener = get() - val gameStateRepository = get() - - // Run command handler - // In the normal process, these handlers is invoque players connect to the websocket - run { - GlobalScope.launch(Dispatchers.IO) { - commandHandler.handleIncomingPlayerCommands(player1, gameId, channelCommand1, channelNotification1) - } - GlobalScope.launch(Dispatchers.IO) { - commandHandler.handleIncomingPlayerCommands(player2, gameId, channelCommand2, channelNotification2) - } - } - - // Consume etch notification of players, and put theses in a list. - // Is used later to control when other players can execute the next action - val player1Notifications = mutableListOf() - val player2Notifications = mutableListOf() - run { - GlobalScope.launch { - for (notification in channelNotification1) { - player1Notifications.add(notification) - } - } - - GlobalScope.launch { - for (notification in channelNotification2) { - player2Notifications.add(notification) - } - } - } - - // Player 1 actions - val player1Job = - launch { - playerNotificationListener.startListening(player1, gameId) { - channelNotification1.trySendBlocking(it) - } - IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player1)).also { sendCommand -> - channelCommand1.send(sendCommand) - player1Notifications.waitNotification { commandId == sendCommand.id } - } - - player1HasJoin = true - - player1Notifications.waitNotification { players == setOf(player1) } - player1Notifications.waitNotification { player == player2 } - - IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(gameId, player1)).also { sendCommand -> - channelCommand1.send(sendCommand) - player1Notifications.waitNotification { commandId == sendCommand.id } - } - player1Notifications.waitNotification { player == player2 } - val player1Hand = player1Notifications.waitNotification { hand.size == 7 }.hand - - playedCard1 = player1Hand.first() - player1Notifications.waitNotification { player == player1 } - - IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(gameId, player1, player1Hand.first())).also { sendCommand -> - channelCommand1.send(sendCommand) - player1Notifications.waitNotification { commandId == sendCommand.id } - } - - player1Notifications.waitNotification { player == player2 } - - player1Notifications.waitNotification { - player == player2 && card == playedCard2 - } - } - - // Player 2 actions - val player2Job = - launch { - // wait player 1 has joined the game - until(3.seconds) { player1HasJoin } - - playerNotificationListener.startListening(player2, gameId) { - channelNotification2.trySendBlocking(it) - } - - IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player2)).also { sendCommand -> - channelCommand2.send(sendCommand) - player2Notifications.waitNotification { commandId == sendCommand.id } - } - - player2Notifications.waitNotification { players == setOf(player1, player2) } - player2Notifications.waitNotification { player == player1 } - - IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(gameId, player2)).also { sendCommand -> - channelCommand2.send(sendCommand) - player2Notifications.waitNotification { commandId == sendCommand.id } - } - - val player2Hand = - player2Notifications.waitNotification { hand.size == 7 }.hand - - player2Notifications.waitNotification { player == player1 } - player2Notifications.waitNotification { - player == player1 && card == playedCard1 - } - playedCard2 = player2Hand.first() - - player2Notifications.waitNotification { player == player2 } - - IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(gameId, player2, player2Hand.first())).also { sendCommand -> - channelCommand2.send(sendCommand) - player2Notifications.waitNotification { commandId == sendCommand.id } - } - } - - // Wait the end of the game - joinAll(player1Job, player2Job) - - // Build the last state from the event store - val state = gameStateRepository.get(gameId) - - // Check if the state is correct - state.aggregateId shouldBeEqual gameId - assertTrue(state.isStarted) - state.players shouldBeEqual setOf(player1, player2) - state.readyPlayers shouldBeEqual setOf(player1, player2) - state.direction shouldBeEqual GameState.Direction.CLOCKWISE - assertNotNull(state.lastCardPlayer) shouldBeEqual player2 - assertNotNull(state.cardOnCurrentStack) shouldBeEqual assertNotNull(playedCard2) - } - } - } - }) - -private suspend inline fun MutableList.waitNotification(crossinline block: T.() -> Boolean): T = - eventually(3.seconds) { - filterIsInstance().first { block(it) } - } diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt deleted file mode 100644 index 60298ab..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.disableShuffleDeck -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.testApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.nondeterministic.until -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import io.ktor.client.call.body -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.runBlocking -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.seconds - -class GameStateRouteTest : - FunSpec({ - test("""The route "/games/{id}/state" should returns a non started game when is not exist""") { - testApplicationWithConfig { - val gameId = GameId() - val player1 = Player(name = "Nikola") - - httpClient() - .get("/games/$gameId/state") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - call.body().apply { - aggregateId shouldBeEqual gameId - players shouldHaveSize 0 - isStarted shouldBeEqual false - } - } - } - } - - test("""The route "/games/{id}/state" should returns the state with all informations""") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - testApplicationWithConfig({ - disableShuffleDeck() - val eventHandler = get() - val stateRepo = get() - - runBlocking { - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } - val lastPlayedCard = eventually(3.seconds) { stateRepo.get(gameId).playableCards(player1).first() } - assertIs(lastPlayedCard) - .let { - it.number shouldBeEqual 0 - it.color shouldBeEqual Card.Color.Red - } - eventHandler.handle(gameId) { - CardIsPlayedEvent( - gameId, - lastPlayedCard, - player1, - it, - ) - } - until(3.seconds) { - stateRepo - .get(gameId) - .deck.discard - .last() == lastPlayedCard - } - } - }) { - httpClient() - .get("/games/$gameId/state") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - call.body().apply { - aggregateId shouldBeEqual gameId - players shouldHaveSize 2 - isStarted shouldBeEqual true - assertIs(lastEvent) - readyPlayers shouldBeEqual setOf(player1, player2) - direction shouldBeEqual GameState.Direction.CLOCKWISE - assertNotNull(lastCardPlayer) shouldBeEqual player1 - assertNotNull(colorOnCurrentStack) shouldBeEqual Card.Color.Red - } - } - } - } - - test("""The route "/games/{id}/card/last" should return the last card played of the game""") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - var lastPlayedCard: Card? = null - testApplicationWithConfig({ - disableShuffleDeck() - val eventHandler = get() - val stateRepo = get() - - runBlocking { - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } - lastPlayedCard = eventually(3.seconds) { stateRepo.get(gameId).playableCards(player1).first() } - assertIs(lastPlayedCard) - .let { - it.number shouldBeEqual 0 - it.color shouldBeEqual Card.Color.Red - } - eventHandler.handle(gameId) { - CardIsPlayedEvent( - gameId, - assertNotNull(lastPlayedCard), - player1, - it, - ) - } - - until(3.seconds) { - stateRepo - .get(gameId) - .deck.discard - .last() == lastPlayedCard - } - } - }) { - httpClient() - .get("/games/$gameId/card/last") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - assertEquals(assertNotNull(lastPlayedCard), call.body()) - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt b/src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt new file mode 100644 index 0000000..ed9eb98 --- /dev/null +++ b/src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt @@ -0,0 +1,42 @@ +package eventDemo.architecture + +import com.tngtech.archunit.core.importer.ClassFileImporter +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.library.Architectures.layeredArchitecture +import org.junit.jupiter.api.Test + +/** + * Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters). + * + * Convention attendue : + * eventDemo.contexts.uno.domain + * eventDemo.contexts.uno.application + * eventDemo.contexts.uno.infrastructure + * + * Règles imposées : + * – domain → ne dépend d'aucune autre couche (ni application, ni infrastructure) + * – application → ne dépend que de domain (jamais d'infrastructure) + * – infrastructure → ne dépend que de domain et application + */ +class HexagonalArchitectureTest { + private val basePackage = "eventDemo.contexts.uno" + + private val classes = + ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(basePackage) + + @Test + fun `respecte les couches de l'architecture hexagonale`() { + @Suppress("ktlint:standard:chain-method-continuation") + layeredArchitecture() + .consideringAllDependencies() + .layer("Domain").definedBy("$basePackage.domain..") + .layer("Application").definedBy("$basePackage.application..") + .layer("Infrastructure").definedBy("$basePackage.infrastructure..") + .whereLayer("Domain").mayNotAccessAnyLayer() + .whereLayer("Application").mayOnlyAccessLayers("Domain") + .whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application") + .check(classes) + } +} diff --git a/src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt b/src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt new file mode 100644 index 0000000..6da3519 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt @@ -0,0 +1,34 @@ +package eventDemo.contexts.auth.domain + +import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertNotNull + +class UserTest : + FunSpec({ + + test("Create User") { + User.createNewUser("Bob", "changeit").run { + username shouldBe "Bob" + password shouldBe "changeit" + } + } + + test("Create User With event") { + User + .loadFromHistory( + setOf( + NewUserCreatedEvent( + "Bob", + "changeit", + version = 1, + ), + ), + ).run { + assertNotNull(this) + username shouldBe "Bob" + password shouldBe "changeit" + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt new file mode 100644 index 0000000..4726f52 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt @@ -0,0 +1,220 @@ +package eventDemo.contexts.game.application + +import eventDemo.GameWithCommandsInChannels.createGameWithCommandsInChannels +import eventDemo.GameWithCommandsInChannels.joinTheGame +import eventDemo.GameWithCommandsInChannels.playCard +import eventDemo.GameWithCommandsInChannels.readyToPlay +import eventDemo.NewUser +import eventDemo.Tag +import eventDemo.contexts.game.application.channels.GameChannelsSubscriber +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification +import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification +import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification +import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification +import eventDemo.contexts.game.domain.events.disableShuffleDeck +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import eventDemo.testKoinApplicationWithConfig +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.nondeterministic.until +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.assertInstanceOf +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@DelicateCoroutinesApi +class GameSimulationTest : + FunSpec({ + tags(Tag.Postgresql) + + test("Simulation of a game") { + withTimeout(10.seconds) { + disableShuffleDeck() + val gameId = GameId() + val user1 by NewUser() + val user2 by NewUser() + + val channelCommand1 = Channel(Channel.BUFFERED) + val channelCommand2 = Channel(Channel.BUFFERED) + val channelNotification1 = Channel(Channel.BUFFERED) + val channelNotification2 = Channel(Channel.BUFFERED) + + var playedCard1: Card? = null + var playedCard2: Card? = null + + var player1HasJoin = false + + testKoinApplicationWithConfig { + val gameRepository = get() + + gameRepository.create(gameId) + + // Run command/notification subscriber + // In the normal process, these subscriber is invoque on players connect to the websocket + GlobalScope.launch(Dispatchers.IO) { + get().subscribePlayerToGameChannels( + gameId, + user1.id, + channelCommand1, + channelNotification1, + ) + } + GlobalScope.launch(Dispatchers.IO) { + get().subscribePlayerToGameChannels( + gameId, + user2.id, + channelCommand2, + channelNotification2, + ) + } + + // Consume etch notification of players, and put theses in a list. + // Is used later to control when other players can execute the next action + val player1Notifications = mutableListOf() + val player2Notifications = mutableListOf() + run { + GlobalScope.launch { + for (notification in channelNotification1) { + player1Notifications.add(notification) + } + } + + GlobalScope.launch { + for (notification in channelNotification2) { + player2Notifications.add(notification) + } + } + } + + // Player 1 actions + val player1Job = + launch { + createGameWithCommandsInChannels(channelCommand1) { getPlayer -> + user1.joinTheGame() + player1Notifications.waitNotification { + players.map { it.userId }.contains(user1.id) + } + + player1HasJoin = true + + player1Notifications.waitNotification { + player.userId == user2.id + } + + getPlayer(user1).readyToPlay() + player1Notifications.waitNotification { + playerId == getPlayer(user2).id + } + + val player1Hand = + player1Notifications.waitNotification { hand.size == 7 }.hand + + playedCard1 = player1Hand.first() + player1Notifications.waitNotification { + player.userId == user1.id + } + + getPlayer(user1).playCard(playedCard1!!, null) + + player1Notifications.waitNotification { + player == getPlayer(user2) + } + + player1Notifications.waitNotification { + playerId == getPlayer(user2).id && card == playedCard2 + } + + playedCard1 = player1Hand.elementAt(1) + getPlayer(user1).playCard(playedCard1) + + player1Notifications.waitNotification { + player == getPlayer(user2) + } + } + } + + // Player 2 actions + val player2Job = + launch { + createGameWithCommandsInChannels(channelCommand2) { getPlayer -> + // wait player 1 has joined the game + until(3.seconds) { player1HasJoin } + + user2.joinTheGame() + + player2Notifications.waitNotification { + players.map { it.userId }.contains(user1.id) && + players.map { it.userId }.contains(user2.id) + } + player2Notifications.waitNotification { playerId == getPlayer(user1).id } + + getPlayer(user2).readyToPlay() + + val player2Hand = + player2Notifications.waitNotification { hand.size == 7 }.hand + + player2Notifications.waitNotification { + player.userId == user1.id + } + player2Notifications.waitNotification { + playerId == getPlayer(user1).id && card == playedCard1 + } + playedCard2 = player2Hand.first() + + player2Notifications.waitNotification { + player == getPlayer(user2) + } + + getPlayer(user2).playCard(playedCard2) + + player2Notifications.waitNotification { + player.userId == user1.id + } + player2Notifications.waitNotification { + playerId == getPlayer(user2).id && card == playedCard2 + } + } + } + + // Wait the end of the game + joinAll(player1Job, player2Job) + + // Build the last state from the event store + val game = gameRepository.get(gameId) + assertInstanceOf(game) + + // Check if the state is correct + game.aggregateId shouldBeEqual gameId + game.players.map { it.id } shouldBeEqual setOf(user1.id, user2.id) + assertNotNull(game.players.find { it.userId == user1.id }) + .hand.size shouldBeEqual 5 + assertNotNull(game.players.find { it.userId == user2.id }) + .hand.size shouldBeEqual 6 + game.direction shouldBeEqual Game.Direction.CLOCKWISE + assertNotNull(game.lastPlayed.userId) shouldBeEqual user1 + assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1) + } + } + } + }) + +private suspend inline fun MutableList.waitNotification(crossinline block: T.() -> Boolean): T = + eventually(3.seconds) { + filterIsInstance().first { block(it) } + } diff --git a/src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt new file mode 100644 index 0000000..72a1fcb --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt @@ -0,0 +1,108 @@ +package eventDemo.contexts.game.application.eventStore + +import ch.qos.logback.classic.Level +import com.rabbitmq.client.impl.ForgivingExceptionHandler +import eventDemo.GameWithCommands +import eventDemo.GameWithCommands.joinTheGame +import eventDemo.NewUser +import eventDemo.Tag +import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.testKoinApplicationWithConfig +import eventDemo.withLogLevel +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.nondeterministic.eventuallyConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import org.slf4j.Logger +import java.util.UUID +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateCoroutinesApi::class) +class GameEventStoreRepositoryTest : + FunSpec({ + tags(Tag.Postgresql) + + val user1 by NewUser() + val user2 by NewUser() + + test("GameRepository should build return the game after dispatch commands") { + testKoinApplicationWithConfig { + GameWithCommands.createGameWithCommands { getPlayer -> + user1.joinTheGame() + getPlayer(user1).userId shouldBeEqual user1.id + } + } + } + + test("get should build the last version of the state") { + withLogLevel( + ForgivingExceptionHandler::class.java.name to Level.OFF, + ) { + val gameId = GameId() + testKoinApplicationWithConfig { + val repo = get() + + GameWithCommands.createGameWithCommands { + user1.joinTheGame() + assertNotNull(repo.get(gameId)).run { + players.isNotEmpty() shouldBeEqual true + players.get(user1.id).userId shouldBeEqual user1.id + } + user2.joinTheGame() + assertNotNull(repo.get(gameId)).run { + players.isNotEmpty() shouldBeEqual true + players.size shouldBeEqual 2 + players.get(user1.id).userId shouldBeEqual user1.id + players.get(user2.id).userId shouldBeEqual user2.id + } + } + } + } + } + + test("get should be concurrently secure").config(tags = setOf(Tag.Concurrence)) { + withLogLevel( + Logger.ROOT_LOGGER_NAME to Level.ERROR, + ForgivingExceptionHandler::class.java.name to Level.OFF, + ) { + val aggregateId = GameId() + testKoinApplicationWithConfig { + val repo = get() + + val gameName = "testGame${UUID.randomUUID()}" + (1..10) + .map { r -> + GlobalScope + .launch { + GameWithCommands.createGameWithCommands(gameName) { + repeat(20) { + val userX by NewUser() + userX.joinTheGame() + } + } + } + }.joinAll() + + eventually( + eventuallyConfig { + duration = 60.seconds + interval = 2.seconds + includeFirst = false + }, + ) { + assertNotNull(repo.get(aggregateId)).run { + version shouldBeEqual 200 + players shouldHaveSize 200 + } + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt new file mode 100644 index 0000000..cc6eb8e --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt @@ -0,0 +1,71 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler +import eventDemo.contexts.game.application.command.handlers.PlayCardHandler +import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler +import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory +import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInMemory +import eventDemo.sharedKernel.UserId +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldEqual +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import org.junit.jupiter.api.assertInstanceOf + +class EventToNotificationSubscriberTest : + FunSpec({ + + test("When event when send to the bus, a notification should be published") { + val bus = GameEventBusInMemory() + val repository = GameEventStoreRepository(GameEventStoreInMemory()) + val subscriber = + EventToNotificationSubscriber( + bus, + repository, + ) + + val commentDispatcher = + GameCommandHandlerDispatcher( + PlayCardHandler(repository, bus), + ReadyToPlayHandler(repository, bus), + JoinTheGameHandler(repository, bus), + TakeCartFromDrawPileHandler(repository, bus), + ) + val notificationChannel = Channel(Channel.BUFFERED) + + val game = repository.create() + + val user1 = UserId() + val user2 = UserId() + + val player1Notifications = mutableListOf() + GlobalScope.launch { + for (notification in notificationChannel) { + player1Notifications.add(notification) + } + } + + commentDispatcher.dispatch(JoinTheGameCommand(user1, JoinTheGameCommand.Payload(game.aggregateId))) + subscriber + .subscribeToEventsAndSendNotification( + game.aggregateId, + user1, + notificationChannel, + ).use { + commentDispatcher.dispatch(JoinTheGameCommand(user2, JoinTheGameCommand.Payload(game.aggregateId))) + } + + player1Notifications.size shouldEqual 2 + player1Notifications.first().let { notification -> + assertInstanceOf(notification) + notification.player.userId shouldEqual user1 + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt b/src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt new file mode 100644 index 0000000..b4ec09a --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt @@ -0,0 +1,46 @@ +package eventDemo.contexts.game.domain.game + +import eventDemo.act +import eventDemo.arrange +import eventDemo.assert +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.ints.shouldBeExactly +import org.junit.jupiter.api.assertInstanceOf + +class PlayerHandTest : + FunSpec({ + test("can be add new card to the hand") { + arrange { + PlayerHand( + setOf( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(1, Card.Color.Red), + Card.NumericCard(2, Card.Color.Red), + ), + ) + }.act { hand -> + hand.withNewCards(setOf(Card.NumericCard(3, Card.Color.Red))) + }.assert { hand -> + hand.size shouldBeExactly 4 + } + } + + test("can be remove card to the hand") { + arrange { + PlayerHand( + setOf( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(1, Card.Color.Red), + Card.NumericCard(2, Card.Color.Red), + ), + ) + }.act { hand -> + hand.withoutTheCards(setOf(hand.cards.elementAt(0))) + }.assert { hand -> + hand.size shouldBeExactly 2 + assertInstanceOf>(hand.cards) + hand.cards.elementAt(0).number shouldBeExactly 1 + hand.cards.elementAt(1).number shouldBeExactly 2 + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt b/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt new file mode 100644 index 0000000..51ab734 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt @@ -0,0 +1,386 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.act +import eventDemo.assert +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerHand +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.sharedKernel.UserId +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertInstanceOf + +class GameStartedTest : + FunSpec({ + context(GameStarted::canBePlayThisCard.name) { + val dataOk: Map> = + listOf( + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(5, Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(0, Card.Color.Blue), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Green), + Card.NumericCard(0, Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus4Card(), + null, + ), + Triple( + Card.Plus4Card(), + Card.Plus4Card(), + Card.Color.Red, + ), + Triple( + Card.Plus4Card(), + Card.NumericCard(0, Card.Color.Blue), + Card.Color.Blue, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.Plus2Card(Card.Color.Blue), + null, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.Plus4Card(), + null, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.ChangeColorCard(), + null, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.NumericCard(0, Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.ChangeColorCard(), + null, + ), + Triple( + Card.ReverseCard(Card.Color.Red), + Card.NumericCard(0, Card.Color.Red), + null, + ), + ).associateBy { "I can play ${it.second} on ${it.first}${if (it.third != null) " when choose color is ${it.third}" else ""}" } + + withData(dataOk) { + canBePlayThisCard( + it.first, + it.second, + it.third, + ) shouldBe true + } + + val dataKo: Map> = + listOf( + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(9, Card.Color.Blue), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Blue), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Blue), + null, + ), + Triple( + Card.Plus4Card(), + Card.NumericCard(0, Card.Color.Blue), + Card.Color.Red, + ), + ).associateBy { "I cannot play ${it.second} on ${it.first}${if (it.third != null) " when choose color is ${it.third}" else ""}" } + + withData(dataKo) { + canBePlayThisCard( + it.first, + it.second, + it.third, + ) shouldBe false + } + } + + context("applyEvent") { + context("${CardIsPlayedEvent::class.simpleName}") { + test("with numeric card") { + val card1 = Card.NumericCard(2, Card.Color.Red) + assert { + gameWithCard( + played1Hand = PlayerHand(cards = setOf(card1)), + onTheDiscardPile = Card.NumericCard(0, Card.Color.Red), + ).apply { nextPlayer shouldBe player1 } + }.act { + it.applyEvent( + CardIsPlayedEvent( + it.aggregateId, + card1, + playerId = it.player1.id, + version = it.version + 1, + ), + ) + }.assert { + it.currentColor shouldBe Card.Color.Red + assertInstanceOf(it.lastPlayedCard).number shouldBe 2 + it.direction shouldBe Game.Direction.CLOCKWISE + it.nextPlayer shouldBe it.player2 + it.player1.hand.size shouldBe 0 + } + } + test("with revert turn card") { + val card1 = Card.ReverseCard(Card.Color.Red) + assert { + gameWithCard( + played1Hand = PlayerHand(cards = setOf(card1)), + onTheDiscardPile = Card.NumericCard(0, Card.Color.Red), + ).apply { nextPlayer shouldBe player1 } + }.act { + it.applyEvent( + CardIsPlayedEvent( + it.aggregateId, + card1, + playerId = it.player1.id, + version = it.version + 1, + ), + ) + }.assert { + it.currentColor shouldBe Card.Color.Red + assertInstanceOf(it.lastPlayedCard).color shouldBe Card.Color.Red + it.direction shouldBe Game.Direction.COUNTER_CLOCKWISE + it.nextPlayer shouldBe it.player3 + it.player1.hand.size shouldBe 0 + } + } + } + + test("${DrawFilledWithDiscardEvent::class.simpleName}") { + val card1 = Card.NumericCard(1, Card.Color.Blue) + val card2 = Card.NumericCard(2, Card.Color.Yellow) + val card3 = Card.NumericCard(3, Card.Color.Red) + assert { + val player1 = Player("Player 1", UserId()) + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1)), + lastPlayerId = player1.id, + drawPile = DrawPile(), + discardPile = + DiscardPile( + setOf( + card1, + card2, + card3, + ), + ), + currentColor = card3.color, + version = 0, + recordedEvents = emptySet(), + ) + }.act { + it.applyEvent( + DrawFilledWithDiscardEvent( + it.aggregateId, + version = it.version + 1, + newDrawPile = + DrawPile( + setOf( + card1, + card2, + ), + ), + newDiscardPile = + DiscardPile( + setOf( + card3, + ), + ), + ), + ) + }.assert { + it.currentColor shouldBe Card.Color.Red + assertInstanceOf(it.lastPlayedCard).color shouldBe Card.Color.Red + } + } + + test("${PlayerHaveDrawCardEvent::class.simpleName}") { + val card1 = Card.NumericCard(1, Card.Color.Blue) + val card2 = Card.NumericCard(2, Card.Color.Red) + val card3 = Card.NumericCard(3, Card.Color.Red) + val card4 = Card.NumericCard(4, Card.Color.Red) + + val player1 = + Player( + "Jo", + UserId(), + hand = PlayerHand(cards = setOf()), + ) + assert { + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1)), + lastPlayerId = player1.id, + drawPile = + DrawPile( + setOf( + card2, + card3, + card4, + ), + ), + discardPile = + DiscardPile( + setOf( + card1, + ), + ), + currentColor = card1.color, + version = 0, + recordedEvents = emptySet(), + ) + }.act { + it.applyEvent( + PlayerHaveDrawCardEvent( + it.aggregateId, + version = it.version + 1, + playerId = it.player1.id, + takenCards = + setOf( + card2, + card3, + ), + ), + ) + }.assert { + it.player1.hand + .run { + cards.elementAt(0) shouldBe card2 + cards.elementAt(1) shouldBe card3 + } + } + } + + test("${PlayerWinEvent::class.simpleName}") { + val player1 = Player("Jo", UserId()) + val player2 = Player("Poo", UserId()) + assert { + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + lastPlayerId = player1.id, + drawPile = DrawPile(), + discardPile = DiscardPile(), + currentColor = Card.Color.Yellow, + version = 0, + recordedEvents = emptySet(), + ) + }.act { + it.applyEvent( + PlayerWinEvent( + it.aggregateId, + version = it.version + 1, + playerId = it.player1.id, + ), + ) + }.assert { + it.playerWins.size shouldBe 1 + it.playersInGame.size shouldBe 0 + it.players.size shouldBe 2 + } + } + } + + test("nextPlayer") { } + + test("players") { } + + test("currentColor") { } + + test("playedTurnHistory") { } + + test("direction") { } + + test("playerWins") { } + + test("version") { } + + test("recordedEvents") { } + }) + +private val GameStarted.player1: Player + get() = players.players.elementAt(0) + +private val GameStarted.player2: Player + get() = players.players.elementAt(1) + +private val GameStarted.player3: Player + get() = players.players.elementAt(2) + +private fun canBePlayThisCard( + onTheDiscardPile: Card, + playedCard: Card, + chosenColor: Card.Color? = null, +): Boolean = + gameWithCard( + played1Hand = PlayerHand(setOf(playedCard)), + onTheDiscardPile = onTheDiscardPile, + chosenColor = chosenColor, + ).run { + canBePlayThisCard(playedCard) + } + +private fun gameWithCard( + played1Hand: PlayerHand, + played2Hand: PlayerHand = PlayerHand(setOf(Card.NumericCard(9, Card.Color.Yellow))), + onTheDiscardPile: Card, + chosenColor: Card.Color? = null, +): GameStarted { + val player1 = Player("Tesla", UserId(), hand = played1Hand) + val player2 = Player("Einstein", UserId(), hand = played2Hand) + val player3 = Player("Curie", UserId(), hand = PlayerHand(setOf(Card.NumericCard(8, Card.Color.Yellow)))) + return GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2, player3)), + lastPlayerId = player3.id, + discardPile = DiscardPile(setOf(onTheDiscardPile)), + drawPile = DrawPile(), + currentColor = (onTheDiscardPile as? Card.CardWithColor)?.color ?: chosenColor ?: error("no color"), + version = 0, + recordedEvents = emptySet(), + ) +} diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt new file mode 100644 index 0000000..d13eb67 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt @@ -0,0 +1,15 @@ +package eventDemo.contexts.game.intrastructure + +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.auth.infrastructure.configure.makeJwt +import eventDemo.contexts.game.domain.game.Player +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header + +internal fun HttpRequestBuilder.withAuth(user: User) { + header("Authorization", "Bearer ${user.makeJwt("secret")}") +} + +internal fun HttpRequestBuilder.withAuth(player: Player) { + withAuth(User(player.userId, player.name, "changeit", 1)) +} diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/TestHttpClient.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/TestHttpClient.kt similarity index 73% rename from src/test/kotlin/eventDemo/adapter/presenter/query/TestHttpClient.kt rename to src/test/kotlin/eventDemo/contexts/game/intrastructure/TestHttpClient.kt index 95ef09d..ec6cb45 100644 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/TestHttpClient.kt +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/TestHttpClient.kt @@ -1,5 +1,6 @@ -package eventDemo.adapter.presenter.query -import eventDemo.configuration.ktor.defaultJsonSerializer +package eventDemo.contexts.game.intrastructure + +import eventDemo.contexts.game.infrastructure.configuration.ktor.defaultJsonSerializer import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json diff --git a/src/test/kotlin/eventDemo/externalServices/PostgresqlTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/PostgresqlTest.kt similarity index 74% rename from src/test/kotlin/eventDemo/externalServices/PostgresqlTest.kt rename to src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/PostgresqlTest.kt index 46c8b3e..17c0ada 100644 --- a/src/test/kotlin/eventDemo/externalServices/PostgresqlTest.kt +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/PostgresqlTest.kt @@ -1,4 +1,4 @@ -package eventDemo.externalServices +package eventDemo.contexts.game.intrastructure.persistence.connectors import eventDemo.Tag import eventDemo.testKoinApplicationWithConfig @@ -10,10 +10,9 @@ class PostgresqlTest : FunSpec({ tags(Tag.Postgresql) - test("test connection with postgresql") { + test("test connection with PostgreSQL") { testKoinApplicationWithConfig { - val datasource by inject() - datasource.connection.use { connection -> + get().connection.use { connection -> connection .prepareStatement( """ diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt new file mode 100644 index 0000000..df98bf6 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt @@ -0,0 +1,60 @@ +package eventDemo.contexts.game.intrastructure.persistence.connectors + +import com.rabbitmq.client.AMQP.BasicProperties +import com.rabbitmq.client.BuiltinExchangeType +import com.rabbitmq.client.ConnectionFactory +import com.rabbitmq.client.DefaultConsumer +import com.rabbitmq.client.Envelope +import eventDemo.Tag +import eventDemo.spyPing +import eventDemo.testKoinApplicationWithConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.string.shouldStartWith +import java.util.UUID +import kotlin.time.Duration.Companion.seconds + +class RabbitMQTest : + FunSpec({ + tags(Tag.RabbitMQ) + + test("test connection with RabbitMQ") { + testKoinApplicationWithConfig { + val exchangeName = "test_" + UUID.randomUUID() + get().newConnection().use { connection -> + connection + .createChannel() + .use { channel -> + channel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT) + val queue = channel.queueDeclare("myQueue", true, false, false, emptyMap()).queue + channel.queueBind(queue, exchangeName, "") + + spyPing(3.seconds, exactly = 2) { ping -> + channel + .basicConsume( + queue, + object : DefaultConsumer(channel) { + override fun handleDelivery( + consumerTag: String, + envelope: Envelope, + properties: BasicProperties, + body: ByteArray, + ) { + val msg = body.toString(Charsets.UTF_8) + msg shouldStartWith "testMessage" + ping() + channel.basicAck(envelope.deliveryTag, false) + } + }, + ) + + channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage1".toByteArray()) + channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage2".toByteArray()) + } + + channel.queueDelete(queue) + channel.exchangeDelete(exchangeName) + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt new file mode 100644 index 0000000..9fe4426 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.intrastructure.persistence.connectors + +import eventDemo.Tag +import eventDemo.testKoinApplicationWithConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import redis.clients.jedis.UnifiedJedis + +class RedisTest : + FunSpec({ + tags(Tag.Redis) + + test("test connection with jedis") { + testKoinApplicationWithConfig { + get().also { + it.set("test", "test") + it.get("test") shouldBeEqual "test" + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt new file mode 100644 index 0000000..a661323 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt @@ -0,0 +1,44 @@ +package eventDemo.contexts.game.intrastructure.persistence.eventBus + +import com.rabbitmq.client.ConnectionFactory +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ +import eventDemo.sharedKernel.UserId +import eventDemo.spyPing +import eventDemo.testKoinApplicationWithConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.equals.shouldBeEqual +import kotlin.time.Duration.Companion.seconds + +class GameEventBusInRabbitMQTest : + FunSpec({ + context("Pub/sub") { + testKoinApplicationWithConfig { + val busListToTest: Map = + mapOf( + GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(), + GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get()), + ) + + withData(busListToTest) { bus -> + spyPing(1.seconds, exactly = 2) { ping -> + val aggregateId = GameId() + val player1 = Player(name = "Tesla", UserId()) + val player2 = Player(name = "Einstein", UserId()) + + bus.subscribe { obj -> + ping() + obj.aggregateId shouldBeEqual aggregateId + } + bus.publish(NewPlayerEvent(aggregateId, player1, 1)) + bus.publish(NewPlayerEvent(aggregateId, player2, 2)) + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt similarity index 59% rename from src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt rename to src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt index 4c50d65..c3e38b8 100644 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt @@ -1,12 +1,15 @@ -package eventDemo.adapter.presenter.query +package eventDemo.contexts.game.intrastructure.rest -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.projection.GameList +import eventDemo.GameWithCommands +import eventDemo.GameWithCommands.joinTheGame +import eventDemo.GameWithCommands.readyToPlay +import eventDemo.NewUser +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList +import eventDemo.contexts.game.intrastructure.httpClient +import eventDemo.contexts.game.intrastructure.withAuth +import eventDemo.domain.event.projection.GameListRepository +import eventDemo.sharedKernel.UserId import eventDemo.testApplicationWithConfig import io.github.oshai.kotlinlogging.KotlinLogging import io.kotest.assertions.nondeterministic.eventually @@ -31,7 +34,7 @@ class GameListRouteTest : test("/games with no game started") { testApplicationWithConfig { - val player1 = Player(name = "Nikola") + val player1 = Player(name = "Nikola", UserId()) logger.info { "Starting player1" } httpClient() .get("/games") { @@ -46,28 +49,24 @@ class GameListRouteTest : } test("/games return a game with status OPENING") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - testApplicationWithConfig( - { - get() - .handle(gameId) { - NewPlayerEvent(gameId, player1, it) - } - }, - ) { + val user1 by NewUser() + testApplicationWithConfig({ + GameWithCommands.createGameWithCommands { + user1.joinTheGame() + } + }) { // Wait until the projection is created - eventually(10.seconds) { + eventually(3.seconds) { httpClient() .get("/games") { - withAuth(player1) + withAuth(user1) accept(ContentType.Application.Json) }.apply { assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) call.body>().first().let { it.status shouldBeEqual GameList.Status.OPENING it.players shouldHaveSize 1 - it.players shouldContain player1 + it.players.map { it.userId } shouldContain user1.id it.winners shouldHaveSize 0 } } @@ -76,36 +75,28 @@ class GameListRouteTest : } test("/games return a game with status IS_STARTED") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") + val user1 by NewUser() + val user2 by NewUser() testApplicationWithConfig({ - val eventHandler = get() - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } - eventHandler.handle(gameId) { - GameStartedEvent.new( - gameId, - setOf(player1, player2), - it, - shuffleIsDisabled = true, - ) + GameWithCommands.createGameWithCommands { getPlayer -> + user1.joinTheGame() + user2.joinTheGame() + getPlayer(user1).readyToPlay() + getPlayer(user2).readyToPlay() } }) { eventually(3.seconds) { httpClient() .get("/games") { - withAuth(player1) + withAuth(user1) accept(ContentType.Application.Json) }.apply { assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) call.body>().first().let { it.status shouldBeEqual GameList.Status.IS_STARTED it.players shouldHaveSize 2 - it.players shouldContain player1 - it.players shouldContain player2 + it.players.map { it.userId } shouldContain user1 + it.players.map { it.userId } shouldContain user2 it.winners shouldHaveSize 0 } } diff --git a/src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt b/src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt deleted file mode 100644 index 0e4085b..0000000 --- a/src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package eventDemo.domain.command - -import eventDemo.Tag -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.notification.CommandSuccessNotification -import eventDemo.domain.notification.Notification -import eventDemo.domain.notification.WelcomeToTheGameNotification -import eventDemo.testKoinApplicationWithConfig -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlin.test.assertIs -import kotlin.time.Duration.Companion.seconds - -@OptIn(DelicateCoroutinesApi::class) -class GameCommandHandlerTest : - FunSpec({ - tags(Tag.Postgresql) - - test("handle a command should execute the command") { - withTimeout(5.seconds) { - testKoinApplicationWithConfig { - val commandHandler = get() - val notificationListener = get() - val gameId = GameId() - val player = Player("Tesla") - val channelCommand = Channel(Channel.BUFFERED) - val channelNotification = Channel(Channel.BUFFERED) - notificationListener.startListening( - player, - gameId, - ) { channelNotification.trySendBlocking(it) } - - GlobalScope.launch { - commandHandler.handleIncomingPlayerCommands( - player, - gameId, - channelCommand, - channelNotification, - ) - } - - IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)).also { sendCommand -> - channelCommand.send(sendCommand) - channelNotification.receive().let { - assertIs(it).commandId shouldBeEqual sendCommand.id - } - } - assertIs(channelNotification.receive()).let { - it.players shouldContain player - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt b/src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt deleted file mode 100644 index 3e77fd2..0000000 --- a/src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.domain.command - -import io.kotest.core.spec.style.FunSpec - -class GameCommandRunnerTest : - FunSpec({ - test("run should run the correct command") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt deleted file mode 100644 index 6764e95..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class ICantPlayCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt deleted file mode 100644 index 6f9647f..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class IWantToJoinTheGameCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt deleted file mode 100644 index 7c79599..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class IWantToPlayCardCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt deleted file mode 100644 index 3c10ab9..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class IamReadyToPlayCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/entity/DeckTest.kt b/src/test/kotlin/eventDemo/domain/entity/DeckTest.kt deleted file mode 100644 index 134e083..0000000 --- a/src/test/kotlin/eventDemo/domain/entity/DeckTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package eventDemo.domain.entity - -import eventDemo.allCardCount -import eventDemo.allCards -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeUnique -import io.kotest.matchers.ints.shouldBeExactly -import kotlin.test.assertNotNull - -class DeckTest : - FunSpec({ - val totalCardsNumber = 104 - test("newWithoutPlayers") { - // When - val deck = Deck.newWithoutPlayers() - - // Then - deck.stack.size shouldBeExactly totalCardsNumber - deck.discard.size shouldBeExactly 0 - deck.playersHands.size shouldBeExactly 0 - - deck.allCardCount() shouldBeExactly totalCardsNumber - deck.allCards().shouldBeUnique() - deck.allCards().map { it.id }.shouldBeUnique() - } - - test("initHands should be generate the hands of all players from the stack") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers() - - // When - val initDeck = deck.initHands(players) - - // Then - initDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - initDeck.discard.size shouldBeExactly 0 - initDeck.playersHands.size shouldBeExactly playerNumbers - initDeck.playersHands.forEach { (_, cards) -> cards.size shouldBeExactly 7 } - initDeck.allCardCount() shouldBeExactly totalCardsNumber - } - - test("takeOneCardFromStackTo player") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers().initHands(players) - val firstPlayer = players.first() - - // When - val modifiedDeck = deck.takeOneCardFromStackTo(firstPlayer) - - // Then - modifiedDeck.discard.size shouldBeExactly 0 - modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1 - modifiedDeck.playersHands.size shouldBeExactly playerNumbers - assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 7 + 1 - modifiedDeck.playersHands - .filterKeys { it != firstPlayer.id } - .forEach { (_, cards) -> cards.size shouldBeExactly 7 } - modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber - } - - test("putOneCardFromHand") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers().initHands(players) - val firstPlayer = players.first() - - // When - val card = deck.playersHands.getHand(firstPlayer)!!.first() - val modifiedDeck = deck.putOneCardFromHand(firstPlayer, card) - - // Then - modifiedDeck.discard.size shouldBeExactly 1 - modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - modifiedDeck.playersHands.size shouldBeExactly playerNumbers - assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 6 - modifiedDeck.playersHands - .filterKeys { it != firstPlayer.id } - .forEach { (_, cards) -> cards.size shouldBeExactly 7 } - modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber - } - - test("placeFirstCardOnDiscard") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers().initHands(players) - - // When - val modifiedDeck = deck.placeFirstCardOnDiscard() - - // Then - modifiedDeck.discard.size shouldBeExactly 1 - modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1 - modifiedDeck.playersHands.size shouldBeExactly playerNumbers - modifiedDeck.playersHands - .forEach { (_, cards) -> cards.size shouldBeExactly 7 } - modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber - } - }) diff --git a/src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt b/src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt deleted file mode 100644 index c5febed..0000000 --- a/src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eventDemo.domain.entity - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.ints.shouldBeExactly -import kotlin.test.assertNotNull - -class PlayerHandKtTest : - FunSpec({ - test("addCards") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val firstPlayer = players.first() - val playersHands = PlayersHands(players) - val card = Card.NumericCard(0, Card.Color.Red) - - // When - val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card)) - - assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 - assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 - } - - test("removeCard") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val firstPlayer = players.first() - val card1 = Card.NumericCard(1, Card.Color.Red) - val card2 = Card.NumericCard(2, Card.Color.Red) - val playersHands: PlayersHands = - PlayersHands(players) - .addCards(firstPlayer, listOf(card1, card2)) - - // When - val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1) - - assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 - assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 - } - }) diff --git a/src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt b/src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt deleted file mode 100644 index 72b4128..0000000 --- a/src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eventDemo.domain.entity - -import io.kotest.core.spec.style.FunSpec - -class PlayersHandsTest : - FunSpec({ - - xtest("getHand should return the hand of the player") { } - - xtest("removeCard should remove the card") { } - - xtest("addCard should add the card to the correct hand") { } - - xtest("toPlayersHands should build object from map") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt b/src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt deleted file mode 100644 index 5c221a5..0000000 --- a/src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.adapter.infrastructure.event.GameEventBusInMemory -import eventDemo.adapter.infrastructure.event.GameEventStoreInMemory -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.libs.event.VersionBuilderLocal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -class GameEventHandlerTest : - FunSpec({ - test("handle event should publish the event to the stream") { - // Given - val eventBus: GameEventBus = GameEventBusInMemory() - val eventStore: GameEventStore = spyk(GameEventStoreInMemory()) - val handler = - GameEventHandler( - eventBus = eventBus, - eventStore = eventStore, - versionBuilder = VersionBuilderLocal(), - ) - val gameId = GameId() - val player1 = Player("Tesla") - - // When - handler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - - // Then - coVerify(exactly = 1) { eventStore.publish(any()) } - - eventStore.getStream(gameId).readAll().let { events -> - events shouldHaveSize 1 - events.first().let { - assertIs(it) - it.aggregateId shouldBeEqual gameId - it.player.name shouldBeEqual "Tesla" - } - } - } - - test("handle event should publish the event to the bus") { - // Given - val eventBus: GameEventBus = spyk(GameEventBusInMemory()) - val eventStore: GameEventStore = GameEventStoreInMemory() - val handler = - GameEventHandler( - eventBus = eventBus, - eventStore = eventStore, - versionBuilder = VersionBuilderLocal(), - ) - val gameId = GameId() - val player1 = Player("Tesla") - - // When - var event: GameEvent? = null - val spy = spyk<() -> Unit>() - eventBus.subscribe { - spy() - event = it - } - handler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - - // Then - verify(exactly = 1) { spy() } - coVerify(exactly = 1) { eventBus.publish(any()) } - - assertNotNull(event).let { - assertIs(it) - it.aggregateId shouldBeEqual gameId - it.player.name shouldBeEqual "Tesla" - } - } - - test("handle event should call version builder once") { - // Given - val eventBus: GameEventBus = GameEventBusInMemory() - val eventStore: GameEventStore = GameEventStoreInMemory() - val versionBuilder = spyk(VersionBuilderLocal()) - val handler = - GameEventHandler( - eventBus = eventBus, - eventStore = eventStore, - versionBuilder = versionBuilder, - ) - val gameId = GameId() - val player1 = Player("Tesla") - - // When - handler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - - // Then - verify(exactly = 1) { versionBuilder.buildNextVersion(any()) } - - eventStore - .getStream(gameId) - .readAll() - .first() - .version shouldBeEqual 1 - } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt deleted file mode 100644 index 2dba9ae..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.disableShuffleDeck -import eventDemo.libs.event.VersionBuilderLocal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -class GameStateBuilderTest : - FunSpec({ - test("apply") { - disableShuffleDeck() - val versionBuilder = VersionBuilderLocal() - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - - GameState(gameId) - .run { - val event = - NewPlayerEvent( - aggregateId = gameId, - player = player1, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.isReady shouldBeEqual false - state.isStarted shouldBeEqual false - } - }.run { - val event = - NewPlayerEvent( - aggregateId = gameId, - player = player2, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.players shouldBeEqual setOf(player1, player2) - } - }.run { - val event = - PlayerReadyEvent( - aggregateId = gameId, - player = player1, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.readyPlayers shouldBeEqual setOf(player1) - } - }.run { - val event = - PlayerReadyEvent( - aggregateId = gameId, - player = player2, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.readyPlayers shouldBeEqual setOf(player1, player2) - state.isReady shouldBeEqual true - state.isStarted shouldBeEqual false - } - }.run { - val event = - GameStartedEvent.new( - id = gameId, - players = setOf(player1, player2), - shuffleIsDisabled = true, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.isStarted shouldBeEqual true - assertIs(state.deck.stack.first()).let { - it.number shouldBeEqual 6 - it.color shouldBeEqual Card.Color.Red - } - } - }.run { - val playedCard = playableCards(player1)[0] - val event = - CardIsPlayedEvent( - aggregateId = gameId, - card = playedCard, - player = player1, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - assertNotNull(state.cardOnCurrentStack) shouldBeEqual playedCard - assertIs(playedCard).let { - it.number shouldBeEqual 0 - it.color shouldBeEqual Card.Color.Red - } - } - }.run { - val playedCard = playableCards(player2)[0] - val event = - CardIsPlayedEvent( - aggregateId = gameId, - card = playedCard, - player = player2, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - assertNotNull(state.cardOnCurrentStack) shouldBeEqual playedCard - assertIs(playedCard).let { - it.number shouldBeEqual 7 - it.color shouldBeEqual Card.Color.Red - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt deleted file mode 100644 index e2ea216..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package eventDemo.domain.event.projection - -import ch.qos.logback.classic.Level -import com.rabbitmq.client.impl.ForgivingExceptionHandler -import eventDemo.Tag -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.testKoinApplicationWithConfig -import eventDemo.withLogLevel -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.nondeterministic.eventuallyConfig -import io.kotest.common.KotestInternal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import org.slf4j.Logger -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.seconds - -@OptIn(DelicateCoroutinesApi::class, KotestInternal::class) -class GameStateRepositoryTest : - FunSpec({ - tags(Tag.Postgresql) - - val player1 = Player("Tesla") - val player2 = Player(name = "Einstein") - - test("GameStateRepository should build the projection when a new event occurs") { - val aggregateId = GameId() - testKoinApplicationWithConfig { - val repo = get() - val eventHandler = get() - eventHandler - .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) } - .also { - // Wait until the projection is created - eventually(1.seconds) { - assertNotNull(repo.get(aggregateId)).also { - assertNotNull(it.players) shouldBeEqual setOf(player1) - } - } - } - } - } - - test("get should build the last version of the state") { - withLogLevel( - GameCommandHandler::class.java.name to Level.ERROR, - ForgivingExceptionHandler::class.java.name to Level.OFF, - ) { - val aggregateId = GameId() - testKoinApplicationWithConfig { - val repo = get() - val eventHandler = get() - val projectionBus = get() - - var state: GameState? = null - projectionBus.subscribe { - repo - .get(aggregateId) - .also { state = it } - } - - eventHandler - .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) } - .also { - eventually(1.seconds) { - assertNotNull(state).players.isNotEmpty() shouldBeEqual true - assertNotNull(state).players shouldBeEqual setOf(player1) - } - } - - eventHandler - .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) } - .also { - eventually(1.seconds) { - assertNotNull(repo.get(aggregateId)).also { - assertNotNull(it.players) shouldBeEqual setOf(player1, player2) - } - } - } - } - } - } - - test("get should be concurrently secure").config(tags = setOf(Tag.Concurrence)) { - withLogLevel( - Logger.ROOT_LOGGER_NAME to Level.ERROR, - ForgivingExceptionHandler::class.java.name to Level.OFF, - ) { - val aggregateId = GameId() - testKoinApplicationWithConfig { - val repo = get() - val eventHandler = get() - - (1..10) - .map { r -> - GlobalScope - .launch { - repeat(20) { r2 -> - val playerX = Player("player$r$r2") - eventHandler - .handle(aggregateId) { - NewPlayerEvent( - aggregateId = aggregateId, - player = playerX, - version = it, - ) - } - } - } - }.joinAll() - - eventually( - eventuallyConfig { - duration = 60.seconds - interval = 2.seconds - includeFirst = false - }, - ) { - repo.get(aggregateId).run { - lastEventVersion shouldBeEqual 200 - players shouldHaveSize 200 - } - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt deleted file mode 100644 index 22c30f8..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Deck -import eventDemo.domain.entity.Discard -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.entity.PlayersHands -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual - -class GameStateTest : - FunSpec({ - val player1 = Player("Tesla") - val player2 = Player("Einstein") - - test("isReady return false when all players in the game is not ready") { - GameState( - aggregateId = GameId(), - players = setOf(player1, player2), - readyPlayers = setOf(player1), - ).isReady shouldBeEqual false - } - - test("isReady return true when all players in the game is ready") { - GameState( - aggregateId = GameId(), - players = setOf(player1, player2), - readyPlayers = setOf(player1, player2), - ).isReady shouldBeEqual true - } - - xtest("nextPlayerTurn") { } - xtest("playerDiffIndex") { } - xtest("cardOnBoardIsForYou") { } - xtest("playableCards") { } - xtest("playerHasNoCardLeft") { } - - test("canBePlayThisCard return true when a card can be played on the current game") { - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(5, Card.Color.Red), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Blue), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Green), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Red), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Red), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus4Card(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.Plus4Card(), - playedCard = Card.Plus4Card(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.Plus4Card(), - playedCard = Card.Plus4Card(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Blue), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.ChangeColorCard(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.ReverseCard(Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Red), - ) shouldBeEqual true - } - - test("canBePlayThisCard return false when a card cannot be played on the current game") { - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(9, Card.Color.Blue), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Blue), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Blue), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.Plus4Card(), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.ChangeColorCard(), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Red), - ) shouldBeEqual false - } - }) - -private fun canBePlayThisCard( - onTheDeck: Card, - playedCard: Card, -): Boolean { - val player1 = Player("Tesla") - return gameStateWithCard( - player = player1, - onTheDeck = onTheDeck, - playerHand = listOf(playedCard), - ).canBePlayThisCard(player1, playedCard) -} - -private fun gameStateWithCard( - player: Player, - onTheDeck: Card, - playerHand: List, -): GameState { - val player2 = Player("Einstein") - return GameState( - aggregateId = GameId(), - players = setOf(player, player2), - readyPlayers = setOf(player, player2), - lastCardPlayer = player2, - deck = - Deck( - discard = Discard(setOf(onTheDeck)), - playersHands = PlayersHands(mapOf(player.id to playerHand)), - ), - ) -} diff --git a/src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt deleted file mode 100644 index 8d52556..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package eventDemo.domain.event.projection.projectionListener - -import eventDemo.adapter.infrastructure.event.projection.GameProjectionBusInMemory -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.notification.WelcomeToTheGameNotification -import io.kotest.core.spec.style.FunSpec -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlin.test.assertIs - -class PlayerNotificationListenerTest : - FunSpec({ - - test("startListening should react when a projection is sent to the bus") { - val player = Player("Tesla") - val gameId = GameId() - val bus = GameProjectionBusInMemory() - val state = - GameState( - aggregateId = gameId, - lastEvent = NewPlayerEvent(gameId, player, 1), - players = setOf(player), - ) - val spy = spyk<() -> Unit>() - PlayerNotificationListener(bus).startListening(player, gameId) { - assertIs(it) - spy() - } - bus.publish(state) - - verify(exactly = 1) { spy() } - } - }) diff --git a/src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt b/src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt deleted file mode 100644 index f833593..0000000 --- a/src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package eventDemo.externalServices - -import com.rabbitmq.client.AMQP.BasicProperties -import com.rabbitmq.client.BuiltinExchangeType -import com.rabbitmq.client.ConnectionFactory -import com.rabbitmq.client.DefaultConsumer -import com.rabbitmq.client.Envelope -import eventDemo.Tag -import eventDemo.testKoinApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.string.shouldStartWith -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import java.util.UUID -import kotlin.time.Duration.Companion.seconds - -class RabbitMQTest : - FunSpec({ - tags(Tag.RabbitMQ) - - test("test connection with RabbitMQ") { - testKoinApplicationWithConfig { - val factory = get() - - val exchangeName = "test_" + UUID.randomUUID() - - val spy = spyk<() -> Unit>() - - factory.newConnection().use { connection -> - connection - .createChannel() - .use { channel -> - channel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT) - val queue = channel.queueDeclare("qqq", true, false, false, emptyMap()).queue - channel.queueBind(queue, exchangeName, "") - - channel - .basicConsume( - queue, - object : DefaultConsumer(channel) { - override fun handleDelivery( - consumerTag: String, - envelope: Envelope, - properties: BasicProperties, - body: ByteArray, - ) { - val msg = body.toString(Charsets.UTF_8) - msg shouldStartWith "testMessage" - spy() - channel.basicAck(envelope.deliveryTag, false) - } - }, - ) - - channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage1".toByteArray()) - channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage2".toByteArray()) - - eventually(3.seconds) { - verify(exactly = 2) { spy() } - } - - channel.queueDelete(queue) - channel.exchangeDelete(exchangeName) - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/externalServices/RedisTest.kt b/src/test/kotlin/eventDemo/externalServices/RedisTest.kt deleted file mode 100644 index 6f08f20..0000000 --- a/src/test/kotlin/eventDemo/externalServices/RedisTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package eventDemo.externalServices - -import eventDemo.Tag -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import redis.clients.jedis.JedisPooled - -private val redisUrl = "redis://localhost:6379" - -class RedisTest : - FunSpec({ - tags(Tag.Redis) - - test("test connection with jedis") { - JedisPooled(redisUrl).also { - it.set("test", "test") - it.get("test") shouldBeEqual "test" - } - } - }) diff --git a/src/test/kotlin/eventDemo/libs/bus/BusTest.kt b/src/test/kotlin/eventDemo/libs/bus/BusTest.kt index 70392aa..788bdea 100644 --- a/src/test/kotlin/eventDemo/libs/bus/BusTest.kt +++ b/src/test/kotlin/eventDemo/libs/bus/BusTest.kt @@ -1,13 +1,10 @@ package eventDemo.libs.bus import com.rabbitmq.client.ConnectionFactory -import io.kotest.assertions.nondeterministic.eventually +import eventDemo.spyPing import io.kotest.core.spec.style.FunSpec import io.kotest.datatest.withData import io.kotest.matchers.string.shouldStartWith -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify import kotlin.random.Random import kotlin.time.Duration.Companion.seconds @@ -38,17 +35,13 @@ class BusTest : ) withData(list) { bus -> - val spy = spyk<() -> Unit>() - - bus.subscribe { obj -> - spy() - obj.value shouldStartWith "testMessage" - } - bus.publish(ObjTest("testMessage${Random.nextInt()}")) - bus.publish(ObjTest("testMessage${Random.nextInt()}")) - - eventually(1.seconds) { - verify(exactly = 2) { spy() } + spyPing(exactly = 2, duration = 1.seconds) { ping -> + bus.subscribe { obj -> + ping() + obj.value shouldStartWith "testMessage" + } + bus.publish(ObjTest("testMessage${Random.nextInt()}")) + bus.publish(ObjTest("testMessage${Random.nextInt()}")) } } } diff --git a/src/test/kotlin/eventDemo/libs/command/CommandForTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandForTest.kt new file mode 100644 index 0000000..1e4a0be --- /dev/null +++ b/src/test/kotlin/eventDemo/libs/command/CommandForTest.kt @@ -0,0 +1,8 @@ +package eventDemo.libs.command + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandForTest( + override val id: CommandId, +) : Command diff --git a/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt deleted file mode 100644 index fd1d43f..0000000 --- a/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package eventDemo.libs.command - -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlin.time.Duration.Companion.seconds - -@Serializable -class CommandTest( - override val id: CommandId, -) : Command - -@OptIn(DelicateCoroutinesApi::class) -class CommandStreamChannelTest : - FunSpec({ - - test("send and receive") { - val command = CommandTest(CommandId()) - - val channel = Channel() - val stream = CommandStreamChannel(CommandRunnerController()) - - val spy = spyk<() -> Unit>() - - GlobalScope.launch { - stream.process(channel) { - println("In action ${it.id}") - spy() - } - } - - channel.send(command) - - eventually(3.seconds) { - verify(exactly = 1) { spy() } - } - } - }) diff --git a/src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt new file mode 100644 index 0000000..6d7a673 --- /dev/null +++ b/src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt @@ -0,0 +1,38 @@ +package eventDemo.libs.command + +import eventDemo.spyPing +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.core.spec.style.FunSpec +import io.mockk.spyk +import io.mockk.verify +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class CommandUnicityCheckerTest : + FunSpec({ + test("runOnlyOnce must run all commands") { + spyPing(exactly = 2, duration = 3.seconds) { ping -> + val com1 = CommandForTest(CommandId()) + val com2 = CommandForTest(CommandId()) + CommandUnicityChecker().run { + runOnlyOnce(com1) { ping() } + runOnlyOnce(com2) { ping() } + } + } + } + + test("runOnlyOnce") { + spyPing(exactly = 2, duration = 3.seconds) { ping -> + val com1 = CommandForTest(CommandId()) + val com2 = CommandForTest(CommandId()) + CommandUnicityChecker().run { + runOnlyOnce(com1) { ping() } + runOnlyOnce(com2) { ping() } + assertThrows { + runOnlyOnce(com2) { ping() } + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt b/src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt deleted file mode 100644 index fa1c2a3..0000000 --- a/src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package eventDemo.libs.event - -import eventDemo.libs.bus.Bus -import eventDemo.libs.bus.BusInMemory -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual - -class EventHandlerTest : - FunSpec({ - test("EventHandler::handle should returns the built event") { - val eventBus: Bus = BusInMemory() - val eventStore: EventStore = EventStoreInMemory() - val versionBuilder: VersionBuilder = VersionBuilderLocal() - val aggregateId: IdTest = IdTest() - val handler = - EventHandlerImpl( - eventBus, - eventStore, - versionBuilder, - ) - - // When - val event = - handler.handle(aggregateId) { - EventXTest(aggregateId = aggregateId, version = it, num = 1) - } - - // Then - event.aggregateId shouldBeEqual aggregateId - event.version shouldBeEqual 1 - } - - xtest("EventHandler::handle should publish the event into the store") - - xtest("EventHandler::handle should publish the event into the bus") - - xtest("EventHandler::handle should publish the event into the bus in incremental order") - }) diff --git a/src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt b/src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt deleted file mode 100644 index fc6908f..0000000 --- a/src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package eventDemo.libs.event - -import eventDemo.Tag -import io.kotest.common.KotestInternal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch - -@OptIn(DelicateCoroutinesApi::class, KotestInternal::class) -class VersionBuilderLocalTest : - FunSpec({ - - test("buildNextVersion") { - VersionBuilderLocal().run { - val id = IdTest() - buildNextVersion(id) shouldBeEqual 1 - buildNextVersion(id) shouldBeEqual 2 - buildNextVersion(IdTest()) shouldBeEqual 1 - buildNextVersion(id) shouldBeEqual 3 - } - } - - test("buildNextVersion concurrently").config(tags = setOf(Tag.Concurrence)) { - val versionBuilder = VersionBuilderLocal() - val id = IdTest() - (1..20) - .map { - GlobalScope.launch { - (1..1000).map { - versionBuilder.buildNextVersion(id) - } - } - }.joinAll() - versionBuilder.getLastVersion(id) shouldBeEqual 20 * 1000 - } - - test("getLastVersion") { - VersionBuilderLocal().run { - val id = IdTest() - getLastVersion(id) shouldBeEqual 0 - getLastVersion(id) shouldBeEqual 0 - getLastVersion(id) shouldBeEqual 0 - } - } - }) diff --git a/src/test/kotlin/eventDemo/libs/event/EventStreamTest.kt b/src/test/kotlin/eventDemo/libs/eventSource/EventStreamTest.kt similarity index 91% rename from src/test/kotlin/eventDemo/libs/event/EventStreamTest.kt rename to src/test/kotlin/eventDemo/libs/eventSource/EventStreamTest.kt index 68db58e..7472371 100644 --- a/src/test/kotlin/eventDemo/libs/event/EventStreamTest.kt +++ b/src/test/kotlin/eventDemo/libs/eventSource/EventStreamTest.kt @@ -1,6 +1,10 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource import eventDemo.Tag +import eventDemo.libs.eventSource.eventStore.EventStream +import eventDemo.libs.eventSource.eventStore.EventStreamInMemory +import eventDemo.libs.eventSource.eventStore.EventStreamInPostgresql +import eventDemo.libs.eventSource.eventStore.EventStreamPublishException import eventDemo.testKoinApplicationWithConfig import io.kotest.common.KotestInternal import io.kotest.core.spec.style.FunSpec @@ -17,7 +21,7 @@ import org.junit.jupiter.api.assertThrows import org.koin.core.Koin import kotlin.test.assertNotNull -@OptIn(KotestInternal::class, DelicateCoroutinesApi::class) +@OptIn(DelicateCoroutinesApi::class) class EventStreamTest : FunSpec({ tags(Tag.Postgresql) @@ -38,6 +42,7 @@ class EventStreamTest : dataSource = get(), objectToString = { Json.encodeToString(it) }, stringToObject = { Json.decodeFromString(it) }, + "game.game_event_stream", ), ) diff --git a/src/test/kotlin/eventDemo/libs/event/TestEvents.kt b/src/test/kotlin/eventDemo/libs/eventSource/TestEvents.kt similarity index 66% rename from src/test/kotlin/eventDemo/libs/event/TestEvents.kt rename to src/test/kotlin/eventDemo/libs/eventSource/TestEvents.kt index 19c4e8f..bee5701 100644 --- a/src/test/kotlin/eventDemo/libs/event/TestEvents.kt +++ b/src/test/kotlin/eventDemo/libs/eventSource/TestEvents.kt @@ -1,6 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -18,8 +19,8 @@ sealed interface TestEvents : Event @Serializable data class EventXTest( - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID(), + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(), override val aggregateId: IdTest, override val createdAt: Instant = Clock.System.now(), override val version: Int, diff --git a/src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt b/src/test/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryTest.kt similarity index 89% rename from src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt rename to src/test/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryTest.kt index ad921b1..3028e63 100644 --- a/src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt +++ b/src/test/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryTest.kt @@ -1,12 +1,12 @@ -package eventDemo.libs.event.projection +package eventDemo.libs.eventSource.projection import eventDemo.cleanProjections -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import eventDemo.libs.event.EventStore -import eventDemo.libs.event.EventStoreInMemory -import eventDemo.libs.event.VersionBuilderLocal +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event +import eventDemo.libs.eventSource.EventId +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInMemory +import eventDemo.libs.serializer.UUIDSerializer import io.kotest.assertions.nondeterministic.continually import io.kotest.core.spec.style.FunSpec import io.kotest.datatest.withData @@ -119,20 +119,18 @@ class ProjectionRepositoryTest : continually(1.seconds) { withData(list) { (eventStore, repo) -> val aggregateId = IdTest() - val versionBuilder = VersionBuilderLocal() val lock = ReentrantLock() + var v = 0 (0..9) .map { GlobalScope.launch { repeat(10) { lock.withLock { - runBlocking { - EventXTest( - num = 1, - version = versionBuilder.buildNextVersion(aggregateId), - aggregateId = aggregateId, - ).also { repo.applyAndSave(it) } - } + EventXTest( + num = 1, + version = ++v, + aggregateId = aggregateId, + ).also { repo.applyAndSave(it) } } } } @@ -162,7 +160,7 @@ private data class ProjectionTest( private sealed interface TestEvents : Event private data class Event1Test( - override val eventId: UUID = UUID.randomUUID(), + override val eventId: EventId = EventId(), override val aggregateId: IdTest, override val createdAt: Instant = Clock.System.now(), override val version: Int, @@ -170,7 +168,7 @@ private data class Event1Test( ) : TestEvents private data class Event2Test( - override val eventId: UUID = UUID.randomUUID(), + override val eventId: EventId = EventId(), override val aggregateId: IdTest, override val createdAt: Instant = Clock.System.now(), override val version: Int, @@ -178,7 +176,7 @@ private data class Event2Test( ) : TestEvents private data class EventXTest( - override val eventId: UUID = UUID.randomUUID(), + override val eventId: EventId = EventId(), override val aggregateId: IdTest, override val createdAt: Instant = Clock.System.now(), override val version: Int, diff --git a/src/test/kotlin/eventDemo/libs/FrameChannelConverterTest.kt b/src/test/kotlin/eventDemo/libs/helpers/FrameChannelConverterTest.kt similarity index 76% rename from src/test/kotlin/eventDemo/libs/FrameChannelConverterTest.kt rename to src/test/kotlin/eventDemo/libs/helpers/FrameChannelConverterTest.kt index 9082cbc..56d1635 100644 --- a/src/test/kotlin/eventDemo/libs/FrameChannelConverterTest.kt +++ b/src/test/kotlin/eventDemo/libs/helpers/FrameChannelConverterTest.kt @@ -1,6 +1,6 @@ -package eventDemo.libs +package eventDemo.libs.helpers -import eventDemo.libs.command.Command +import eventDemo.libs.command.CommandForTest import eventDemo.libs.command.CommandId import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.equals.shouldBeEqual @@ -8,15 +8,9 @@ import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable import java.util.UUID import kotlin.test.assertIs -@Serializable -data class CommandTest( - override val id: CommandId, -) : Command - class FrameChannelConverterTest : FunSpec({ @@ -28,7 +22,7 @@ class FrameChannelConverterTest : val channel = Channel() launch { - val commandChannel = toObjectChannel(channel) + val commandChannel = toObjectChannel(channel) commandChannel.receive().id shouldBeEqual id channel.close() } @@ -39,13 +33,13 @@ class FrameChannelConverterTest : test("fromFrameChannel") { val uuid = "d737c631-76af-406e-bc29-f3e5b97226a5" val id = CommandId(UUID.fromString(uuid)) - val command = CommandTest(id) + val command = CommandForTest(id) val jsonCommand = """{"id":"$uuid"}""" val channel = Channel() launch { - val commandChannel = fromFrameChannel(channel) + val commandChannel = fromFrameChannel(channel) commandChannel.send(command) commandChannel.close() }