Author SHA1 Message Date
flecomte 256524f63e feat: increase timeout tests
Tests / build (push) Successful in 42s
Tests / lint (push) Successful in 4m15s
Tests / test (push) Failing after 1m23s
2026-03-14 01:31:24 +01:00
321 changed files with 5511 additions and 6946 deletions
-26
View File
@@ -1,26 +0,0 @@
# Version control
.git
.github
# Gradle build outputs / caches (must always be rebuilt fresh inside the image)
.gradle
build/
.kotlin
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea
.vscode
.run
*.iml
*.iws
*.ipr
# Docker-only local files (secrets/env must never be baked into the image)
docker/.env
docker/*.env.docker
docker/*.secret
# Misc
*.hprof
.gradle-docker-cache/
-7
View File
@@ -1,7 +0,0 @@
* text=auto
* eol=lf
*.sh text eol=lf
*.png binary
*.jar binary
gradlew.bat eol=crlf
gradlew text eol=lf
+34 -50
View File
@@ -18,10 +18,10 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -31,7 +31,7 @@ jobs:
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Cache Gradle dependencies - name: Cache Gradle dependencies
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -48,16 +48,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
- name: Restore Gradle cache - name: Restore Gradle cache
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -70,74 +70,58 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Run lint - name: Run lint
# Path scoped to :backend on purpose: :composeApp applies the Android Gradle plugin, run: ./gradlew ktlintCheck
# which needs an Android SDK to even configure. Keeping every gradlew invocation on a
# fully-qualified project path (with org.gradle.configureondemand=true) lets CI skip
# configuring :composeApp entirely, so no Android SDK setup is needed on this runner.
run: ./gradlew :backend:ktlintCheck
- name: Publish ktlint report - name: Publish ktlint report
uses: yutailang0119/action-ktlint@v5 uses: yutailang0119/action-ktlint@v4
if: always() if: always()
with: with:
report-path: backend/build/reports/ktlint/**/*.xml report-path: build/reports/ktlint/**/*.xml
continue-on-error: false continue-on-error: false
test: test:
needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install a pinned Docker Compose version - name: Set up JDK 21
run: | uses: actions/setup-java@v4
mkdir -p ~/.docker/cli-plugins
curl -fSL https://github.com/docker/compose/releases/download/v5.1.4/docker-compose-linux-x86_64 \
-o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose
docker compose version
- name: Prepare docker secrets
run: |
[ -f docker/postgresql.secret ] || echo -n "changeit" > docker/postgresql.secret
- name: Generate cache key
id: cache-key-generator
run: echo "key=gradle-docker-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Restore Gradle cache (Docker)
uses: actions/cache@v6
with: with:
path: ${{ env.GRADLE_CACHE_DIR }} distribution: 'temurin'
key: ${{ steps.cache-key-generator.outputs.key }} java-version: '21'
- name: Restore Gradle cache
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ needs.build.outputs.cache-key }}
restore-keys: | restore-keys: |
gradle-docker-${{ runner.os }}- gradle-${{ runner.os }}-
- name: Prepare cache directory permissions - name: Grant execute permission to Gradle wrapper
run: | run: chmod +x gradlew
mkdir -p "$GRADLE_CACHE_DIR"
chmod -R 777 "$GRADLE_CACHE_DIR"
- name: Run tests in Docker - name: Start CI Docker Compose services
run: docker compose -f docker/docker-compose-test.yaml run tests run: ./gradlew composeUp -Pci
- name: Shut down Docker services - name: Run tests
if: always() run: ./gradlew test -x composeUp --no-daemon
run: docker compose -f docker/docker-compose-test.yaml down -v
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: test-results name: test-results
path: backend/build/reports/tests/test path: build/reports/tests/test
- name: Publish Test Report - name: Publish Test Report
uses: dorny/test-reporter@v3 uses: dorny/test-reporter@v1
if: always() if: always()
with: with:
name: JUnit Tests name: JUnit Tests
path: backend/build/test-results/test/TEST-*.xml path: build/test-results/test/TEST-*.xml
reporter: java-junit reporter: java-junit
-7
View File
@@ -1,15 +1,9 @@
.gradle .gradle
.kotlin/
build/ build/
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/ !**/src/main/**/build/
!**/src/test/**/build/ !**/src/test/**/build/
### Android / KMP ###
local.properties
captures/
.cxx/
### STS ### ### STS ###
.apt_generated .apt_generated
.classpath .classpath
@@ -43,4 +37,3 @@ out/
/docker/.env /docker/.env
/docker/*.secret /docker/*.secret
*.hprof *.hprof
/.gradle-docker-cache/
-32
View File
@@ -1,32 +0,0 @@
<DataSourcesHistory>
<DataSourceFromHistory isRemovedFromProject="false">
<data-source source="LOCAL" name="event-demo@localhost" uuid="af2eabb1-64f7-49de-a94f-be1560baa96a">
<database-info product="PostgreSQL" version="18.4 (Debian 18.4-1.pgdg13+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.7.3" dbms="POSTGRES" exact-version="18.4" exact-driver-version="42.7">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/event-demo</jdbc-url>
<secret-storage>master_key</secret-storage>
<user-name>event-demo</user-name>
<schema-mapping>
<introspection-scope>
<node negative="1">
<node kind="database" qname="@">
<node kind="schema" qname="@" />
</node>
<node kind="database" qname="event-demo">
<node kind="schema">
<name qname="auth" />
<name qname="game" />
</node>
</node>
</node>
</introspection-scope>
</schema-mapping>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</DataSourceFromHistory>
</DataSourcesHistory>
-27
View File
@@ -1,27 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docker composeUp" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="composeUp" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
-133
View File
@@ -1,133 +0,0 @@
# CLAUDE.md — event-demo
Ce fichier donne le contexte du projet pour toute session Claude Code future sur ce dépôt.
## Vue d'ensemble
`event-demo` est un projet démo personnel (Fabrice Lecomte) qui illustre plusieurs patterns
d'architecture backend :
- Event Sourcing
- Event-Driven (bus d'événements asynchrone)
- CQRS (séparation commandes / projections en lecture)
- Architecture Hexagonale (ports & adapters), un dossier par *bounded context*
Le cas d'usage servant de support est un jeu de cartes façon UNO (créer une partie, rejoindre,
jouer une carte, piocher, etc.), avec authentification des joueurs.
Dépôts distants configurés : `gitea` (auto-hébergé, git.gogn.synology.me — remote historique)
et `github` (`flecomte/event-demo`, miroir). Vérifier vers lequel pousser selon le contexte.
## Stack technique
- **Langage** : Kotlin 2.1.21, JDK 21 (toolchain Gradle)
- **Framework serveur** : Ktor 3.5.1 (Netty), DI via Koin 4.2.1
- **Sérialisation** : kotlinx.serialization (JSON)
- **Persistance** :
- PostgreSQL (event store, via HikariCP) + migrations Flyway (`migrations/events/`)
- RabbitMQ (bus d'événements / bus de commandes, via amqp-client)
- **Auth** : JWT (ktor-server-auth-jwt), hash de mot de passe via password4j
- **Infra dev/prod** : Docker Compose (fichiers `docker/docker-compose-{dev,test,prod}.yaml`
incluant des « parts » réutilisables dans `docker/parts/`), reverse proxy Træfik
- **Tests** : Kotest (runner JUnit5), MockK, kotest-extensions-koin, ArchUnit (test d'architecture)
- **Qualité** : ktlint (`ktlint_official`, standard + experimental activés), reporting checkstyle
- **CI** : GitHub Actions (`.github/workflows/tests.yml`) — build/cache Gradle, `ktlintCheck`,
puis tests exécutés **dans Docker** (`docker compose -f docker/docker-compose-test.yaml run tests`)
- **API** : documentée en OpenAPI (`resources/openapi/documentation.yaml`)
## Architecture
Un dossier par *bounded context* sous `src/main/kotlin/eventDemo/contexts/<context>/`, chacun
strictement découpé en 3 couches :
- `domain/` — aucune dépendance vers les autres couches
- `application/` — ne dépend que de `domain`
- `infrastructure/` — dépend de `domain` et `application`
Contexts actuels :
- **`auth`** : `User`, création de compte, login JWT, event store dédié (Postgresql),
projection utilisateur.
- **`game`** : cœur du jeu — `Card`, `DrawPile`/`DiscardPile`, `Player`, `GameId`, commandes
(`JoinTheGameCommand`, `PlayCardCommand`, `ReadyToPlayCommand`, `TakeCartFromDrawPileCommand`),
state machine du jeu via `sealed interface Game` (`GameInit``GameCreated``GameStarted`
`GameEnded`), notifications, projections (liste de parties), listeners/réactions.
Libs transverses dans `libs/` (indépendantes de tout contexte) :
- `bus/` — abstraction `Bus<E>` avec implémentations in-memory et RabbitMQ (fanout exchange)
- `command/``Command`, `CommandUnicityChecker` (empêche la double exécution d'une commande,
cache glissant de 10 min par défaut)
- `eventSource/``Event`, `EventStream` (append/lecture par version, gestion de
`VersionConflictException`), `EventStore` in-memory / Postgresql
- `helpers/`, `serializer/` — utilitaires (conversion de frames WebSocket, sérialiseurs UUID, etc.)
## Patterns notables dans le code
- **Event sourcing** : `Game.loadFromHistory(events)` reconstruit l'état en repliant
(`fold`) les événements sur une state machine scellée, en utilisant la syntaxe Kotlin 2.1
`when` avec garde `if` (ex. `is GameCreatedEvent if this is GameInit -> applyEvent(event)`).
- **CQRS** : écriture via les command handlers (`application/command/handlers`), lecture via des
projections dédiées (`application/projections`), propagées via le bus RabbitMQ, pas de couplage
direct avec l'écriture.
- **Event-driven** : réactions asynchrones (`ReactionListener`, `EventToNotificationSubscriber`)
déclenchées par le bus RabbitMQ (exchange fanout, une queue par abonné).
- **Exceptions métier** : hiérarchie `GameException` / `IllegalActionException` dans
`domain/game/errors`, une exception par règle métier violée (ex.
`NeedMorePlayersToStartGameException`, `ItsNotTheTurnException`).
## Commandes utiles
```shell
./gradlew build # build complet
./gradlew test # tests (JUnit5 via Kotest)
./gradlew ktlintCheck # lint
./gradlew ktlintFormat # auto-format
./gradlew buildFatJar # jar exécutable "all-in-one" (utilisé par le Dockerfile prod)
# Dépendances seules (Postgres, RabbitMQ, Træfik, pgAdmin...) pour lancer l'app en local hors docker
docker compose -f docker/docker-compose-dev.yaml up -d
# Stack de test façon CI
docker compose -f docker/docker-compose-test.yaml up -d
# ou directement (comme en CI) :
docker compose -f docker/docker-compose-test.yaml run tests
# Stack complète en prod
docker compose -f docker/docker-compose-prod.yaml -p event-demo up -d
```
URLs en dev (voir `doc/installation.md`, nécessite Træfik + résolution des `*.traefik.me`) :
API sur `http://api.traefik.me/`, dashboard
Træfik, pgAdmin et RabbitMQ management exposés via des sous-domaines `traefik.me`.
## Conventions de code
- ktlint en mode `ktlint_official` + règles `standard` et `experimental` activées
(voir `.editorconfig`), indentation **2 espaces**, virgules finales (*trailing commas*)
systématiques, wrapping forcé des expressions/signatures multi-lignes.
- Fins de ligne forcées en **LF** (`.gitattributes`), sauf `gradlew.bat` en CRLF.
- Code et identifiants en anglais.
- Style Kotlin idiomatique/fonctionnel : `fold`, `let`, `apply`, `when` exhaustifs, classes/interfaces
scellées (`sealed class`/`sealed interface`) pour modéliser états et événements plutôt que des enums
avec des champs optionnels.
## Pièges connus / choses à savoir avant de toucher au build ou à la CI
- **MockK/ByteBuddy en Docker** : l'auto-attach dynamique de MockK échoue dans les conteneurs
(le handshake SIGQUIT de l'AttachListener JVM time-out). Le `build.gradle.kts` charge donc
l'agent `byte-buddy-agent` de façon statique via `-javaagent` pour les tâches `Test`, afin
que MockK détecte l'instrumentation déjà présente et saute l'attach dynamique. Ne pas retirer
ce bloc sans repenser l'exécution des tests en Docker.
- **Secret Postgres en CI** : `docker/postgresql.secret` est généré à la volée par le workflow
GitHub Actions s'il n'existe pas (`echo -n "changeit" > docker/postgresql.secret`) — normal,
pas un fichier à committer.
- Les tests « officiels » de la CI tournent **dans Docker**, pas directement via `./gradlew test`
sur l'hôte — en cas de comportement différent entre local et CI, vérifier d'abord les
variables d'environnement/versions du `docker-compose-test.yaml`.
## Historique récent (pour contexte)
Le projet a connu un « Massive refactor to build the V2 » (commit `e2d7942`) : passage d'une
architecture par couches techniques plates (`adapter/presenter/domain`) à l'organisation actuelle
par bounded context (`auth`/`game`) avec 3 couches hexagonales chacune.
+27 -1
View File
@@ -2,6 +2,7 @@ Event Demo
========== ==========
- [Installation](./doc/installation.md) - [Installation](./doc/installation.md)
- [What's the demo for ?](#whats-the-demo-for-) - [What's the demo for ?](#whats-the-demo-for-)
- [What's in this demo](#whats-in-this-demo)
- [The stack](#the-stack) - [The stack](#the-stack)
- [Architecture](./doc/architecture.md) - [Architecture](./doc/architecture.md)
@@ -17,6 +18,31 @@ of different patterns and architectures.
- The CQRS pattern. - The CQRS pattern.
- With the Hexagonal architecture. - With the Hexagonal architecture.
What's in this demo
-------------------
- The **event sourcing** pattern.
- The **event driven** pattern.
- The **CQRS** pattern with **command** and **query**.
- A fully **asynchronous** architecture.Concurently process.
- A **pure Kotlin** implementation of **readmodel**/**projection**.
- A **Redis** implementation of **readmodel**/**projection**.
- A **pure Kotlin** implementation of **Event Store**.
- A **Postgresql** implementation of **Event Store**.
- A **pure Kotlin** implementation of **Event Bus**.
- A **RabbitMQ** implementation of **Event Bus**.
- A **Hexagonal** architecture.
- Use of **Web Sockets**.
- Use of the classic **Rest** route.
- Simple usage of the **JWT**.
- The **Ktor** framework.
- The **Koin** Dependency Injection framework
- Concurrently process.
- Use of coroutines.
- Using **docker compose** for the stack with **traefik**.
- Use of **flyway** to migrate the postgresql schema.
The stack The stack
--------- ---------
@@ -25,11 +51,11 @@ Language
Framework Framework
- Ktor - Ktor
- with Koin for Dependency Injection
Database Database
- Postgresql - Postgresql
- with Flyway - with Flyway
- Redis
- RabbitMQ - RabbitMQ
Infra Infra
-105
View File
@@ -1,105 +0,0 @@
import org.jlleitschuh.gradle.ktlint.KtlintExtension
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
plugins {
application
kotlin("jvm")
id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
}
group = "io.github.flecomte"
application {
mainClass.set("eventDemo.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
configure<KtlintExtension> {
version.set("1.8.0")
}
ktlint {
reporters {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
}
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
jvmArgs("-Djdk.attach.allowAttachSelf=true", "-XX:+EnableDynamicAgentLoading")
// Dynamic self-attach (used by MockK/ByteBuddy) times out in Docker containers because the
// SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
// pre-installed Instrumentation instance and skips dynamic attach.
doFirst {
val agentJar =
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
?: error("byte-buddy-agent jar not found on test classpath")
jvmArgs("-javaagent:$agentJar")
}
}
dependencies {
implementation(project(":shared"))
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-auto-head-response-jvm")
implementation("io.ktor:ktor-server-resources")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-websockets-jvm")
implementation("io.ktor:ktor-server-cors-jvm")
implementation("io.ktor:ktor-server-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
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:${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("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:${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.14.11")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}
@@ -1,44 +0,0 @@
package eventDemo.configuration
import io.ktor.server.config.ApplicationConfig
data class Configuration(
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(
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")
@@ -1,14 +0,0 @@
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()
}
@@ -1,48 +0,0 @@
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 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
// 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)
}
@@ -1,16 +0,0 @@
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),
)
}
}
@@ -1,11 +0,0 @@
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()
}
@@ -1,24 +0,0 @@
package eventDemo.contexts.auth.application.eventStores
import eventDemo.contexts.auth.application.ports.UserEventStore
import eventDemo.contexts.auth.domain.User
import eventDemo.shared.ids.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.append(user.recordedEvents)
}
}
@@ -1,10 +0,0 @@
package eventDemo.contexts.auth.application.eventStores
import eventDemo.contexts.auth.domain.User
import eventDemo.shared.ids.UserId
interface UserRepository {
fun get(id: UserId): User?
fun save(user: User)
}
@@ -1,7 +0,0 @@
package eventDemo.contexts.auth.application.ports
import eventDemo.contexts.auth.domain.events.UserEvent
import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.shared.ids.UserId
interface UserEventStore : EventStore<UserEvent, UserId>
@@ -1,14 +0,0 @@
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?
}
@@ -1,39 +0,0 @@
package eventDemo.contexts.auth.domain
import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent
import eventDemo.contexts.auth.domain.events.UserEvent
import eventDemo.shared.ids.UserId
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: UserId,
val username: String,
val password: String,
val version: Int,
val recordedEvents: Set<UserEvent>,
) {
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,
recordedEvents = setOf(event),
)
fun loadFromHistory(events: Set<UserEvent>): User? =
events.fold(null as User?) { acc, event ->
when (event) {
is NewUserCreatedEvent -> apply(event)
}
}
}
}
@@ -1,18 +0,0 @@
package eventDemo.contexts.auth.domain.events
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.UserId
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
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()
}
@@ -1,8 +0,0 @@
package eventDemo.contexts.auth.domain.events
import eventDemo.libs.eventSource.Event
import eventDemo.shared.ids.UserId
import kotlinx.serialization.Serializable
@Serializable
sealed interface UserEvent : Event<UserId>
@@ -1,13 +0,0 @@
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)
@@ -1,8 +0,0 @@
package eventDemo.contexts.auth.infrastructure.configure
import io.ktor.server.application.Application
fun Application.configureAuth() {
configureKtorAuth()
configureAuthRoutes()
}
@@ -1,17 +0,0 @@
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.UserProjectionRepositoryInPostgresql
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(::UserProjectionRepositoryInPostgresql) bind UserProjectionRepository::class
}
@@ -1,19 +0,0 @@
package eventDemo.contexts.auth.infrastructure.configure
import eventDemo.configuration.configuration
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
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<UserEventStoreRepository>()
val userProjectionRepository = get<UserProjectionRepository>()
routing {
createUserRoute(userRepository)
loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository)
}
}
@@ -1,14 +0,0 @@
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.shared.ids.UserId
/**
* A stream to publish and read the user events.
*/
class UserEventStoreInMemory :
UserEventStore,
EventStore<UserEvent, UserId> by EventStoreInMemory()
@@ -1,22 +0,0 @@
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.shared.ids.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<UserEvent, UserId> by EventStoreInPostgresql(
dataSource,
{ Json.encodeToString(it) },
{ Json.decodeFromString(it) },
"auth.user_event_stream",
)
@@ -1,9 +0,0 @@
package eventDemo.contexts.auth.infrastructure.persistence.projection
import eventDemo.shared.ids.UserId
data class UserProjection(
val id: UserId,
val username: String,
val password: String,
)
@@ -1,62 +0,0 @@
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.shared.ids.UserId
import javax.sql.DataSource
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
class UserProjectionRepositoryInPostgresql(
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.parse(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.id.toJavaUuid())
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
}
}
@@ -1,24 +0,0 @@
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)))
}
}
@@ -1,41 +0,0 @@
package eventDemo.contexts.auth.infrastructure.rest
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<Users.Create> {
val passwordHash = hashPassword(it.password)
val user = User.createNewUser(it.username, passwordHash.result)
userRepository.save(user)
call.respond(
object {
val id = user.id.toString()
},
)
}
}
}
@@ -1,37 +0,0 @@
package eventDemo.contexts.game.application.channels
import eventDemo.contexts.game.application.notification.CommandSubscriber
import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
import eventDemo.shared.game.command.GameCommand
import eventDemo.shared.game.notification.Notification
import eventDemo.shared.ids.GameId
import eventDemo.shared.ids.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<GameCommand>,
sendNotificationChannel: SendChannel<Notification>,
) {
val sub =
eventToNotificationSubscriber.subscribeToEventsAndSendNotification(
gameId = gameId,
currentUserId = userId,
outgoingFrameChannel = sendNotificationChannel,
)
commandSubscriber
.subscribe(
currentUserId = userId,
incomingFrameChannel = incomingCommandChannel,
).invokeOnCompletion { sub.close() }
}
}
@@ -1,31 +0,0 @@
package eventDemo.contexts.game.application.command.handlers
import eventDemo.shared.game.command.GameCommand
import eventDemo.shared.game.command.JoinTheGameCommand
import eventDemo.shared.game.command.PlayCardCommand
import eventDemo.shared.game.command.ReadyToPlayCommand
import eventDemo.shared.game.command.TakeCartFromDrawPileCommand
import eventDemo.shared.ids.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<GameId, String> = 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)
}
}
}
}
@@ -1,58 +0,0 @@
package eventDemo.contexts.game.application.command.handlers
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.eventSource.eventStore.VersionConflictException
import eventDemo.shared.command.Command
import eventDemo.shared.game.command.GameCommand
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlin.reflect.KClass
sealed interface CommandHandler<C : Command> {
fun handle(command: C)
}
abstract class GameEventManager(
private val gameRepository: GameRepository,
private val gameEventBus: GameEventBus,
) {
private val logger = KotlinLogging.logger {}
fun GameCommand.getGame(): Game =
gameRepository.get(payload.aggregateId) ?: error("Game not found")
fun GameEvent.getGame(): Game =
gameRepository.get(aggregateId) ?: error("Game not found")
@Throws(VersionConflictException::class)
protected fun Game.saveEvents(): Game {
gameRepository.save(this)
return this
}
protected fun Game.publishEvents(): Game {
gameEventBus.publish(recordedEvents)
return this
}
protected inline fun <reified G : Game> Game.isStatusOrFail(message: String): G =
this as? G ?: throw CommandException(message)
protected fun <T> retry(
mapAttempts: Int = 5,
block: () -> T,
): T =
try {
block()
} catch (e: VersionConflictException) {
if (mapAttempts > 0) {
logger.warn { "retry after version conflict (attempts left: $mapAttempts)" }
retry(mapAttempts - 1, block)
} else {
logger.error { "Version conflict retry failed" }
throw e
}
}
}
@@ -1,31 +0,0 @@
package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameCreated
import eventDemo.shared.game.command.JoinTheGameCommand
/**
* A command to perform an action to play a new card
*/
class JoinTheGameHandler(
gameRepository: GameRepository,
gameEventBus: GameEventBus,
private val userRepository: UserRepository,
) : GameEventManager(gameRepository, gameEventBus),
CommandHandler<JoinTheGameCommand> {
override fun handle(command: JoinTheGameCommand) {
val user = userRepository.get(command.userId) ?: error("User with id ${command.userId} doesn't exist")
retry {
command
.getGame()
.isStatusOrFail<GameCreated>("The game is started")
.userJoinTheGame(
userId = command.userId,
name = user.username,
).saveEvents()
.publishEvents()
}
}
}
@@ -1,27 +0,0 @@
package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.shared.game.command.PlayCardCommand
/**
* A command to perform an action to play a new card
*/
class PlayCardHandler(
gameRepository: GameRepository,
gameEventBus: GameEventBus,
) : GameEventManager(gameRepository, gameEventBus),
CommandHandler<PlayCardCommand> {
override fun handle(command: PlayCardCommand) {
command
.getGame()
.isStatusOrFail<GameStarted>("The game is not started")
.playTheCard(
card = command.payload.card,
playerId = command.payload.playerId,
chosenColor = command.payload.chosenColor,
).saveEvents()
.publishEvents()
}
}
@@ -1,24 +0,0 @@
package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameCreated
import eventDemo.shared.game.command.ReadyToPlayCommand
/**
* A command to set as ready to play
*/
class ReadyToPlayHandler(
gameRepository: GameRepository,
gameEventBus: GameEventBus,
) : GameEventManager(gameRepository, gameEventBus),
CommandHandler<ReadyToPlayCommand> {
override fun handle(command: ReadyToPlayCommand) {
command
.getGame()
.isStatusOrFail<GameCreated>("The game is started")
.setReadyPlayer(command.payload.playerId)
.saveEvents()
.publishEvents()
}
}
@@ -1,26 +0,0 @@
package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.shared.game.command.TakeCartFromDrawPileCommand
/**
* 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<TakeCartFromDrawPileCommand> {
override fun handle(command: TakeCartFromDrawPileCommand) {
command
.getGame()
.isStatusOrFail<GameStarted>("The game is not started")
.playerTakeCartFromDrawPile(command.payload.playerId, 1)
.saveEvents()
.publishEvents()
}
}
@@ -1,26 +0,0 @@
package eventDemo.contexts.game.application.eventStores
import eventDemo.contexts.game.application.ports.GameEventStore
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.libs.eventSource.eventStore.VersionConflictException
import eventDemo.shared.ids.GameId
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) }
}
@Throws(VersionConflictException::class)
override fun save(game: Game) {
eventStore.append(game.recordedEvents)
}
}
@@ -1,20 +0,0 @@
package eventDemo.contexts.game.application.eventStores
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.contexts.game.domain.game.gameState.GameCreated
import eventDemo.contexts.game.domain.game.gameState.GameInit
import eventDemo.libs.eventSource.eventStore.VersionConflictException
import eventDemo.shared.ids.GameId
interface GameRepository {
fun get(id: GameId): Game?
@Throws(VersionConflictException::class)
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) }
}
@@ -1,29 +0,0 @@
package eventDemo.contexts.game.application.logging
import io.github.oshai.kotlinlogging.withLoggingContext
inline fun <T> withLoggingContext(
vararg pair: Pair<LoggingContextKeys, *>,
body: () -> T,
): T =
withLoggingContext(
*pair
.map {
it.first.name to it.second.toString()
}.toTypedArray(),
restorePrevious = true,
body = body,
)
// inline fun withLoggingContext(
// vararg pair: Pair<LoggingContextKeys, *>,
// body: () -> Unit,
// ) =
// withLoggingContext(
// *pair
// .map {
// it.first.name to it.second.toString()
// }.toTypedArray(),
// restorePrevious = true,
// body = body,
// )
@@ -1,9 +0,0 @@
package eventDemo.contexts.game.application.logging
enum class LoggingContextKeys {
CurrentUserId,
Notification,
Game,
Event,
Command,
}
@@ -1,127 +0,0 @@
package eventDemo.contexts.game.application.notification
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.shared.game.notification.ItsTheTurnOfNotification
import eventDemo.shared.game.notification.Notification
import eventDemo.shared.game.notification.PilesShuffledNotification
import eventDemo.shared.game.notification.PlayerAsJoinTheGameNotification
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
import eventDemo.shared.game.notification.PlayerHavePassNotification
import eventDemo.shared.game.notification.PlayerWasReadyNotification
import eventDemo.shared.game.notification.PlayerWinNotification
import eventDemo.shared.game.notification.TheGameWasStartedNotification
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
import eventDemo.shared.game.notification.YourNewCardNotification
import eventDemo.shared.ids.UserId
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
private val logger = KotlinLogging.logger {}
fun GameEvent.toNotification(
game: Game,
currentUserId: UserId,
): Iterable<Notification> =
Iterable {
iterator {
context(iterator: SequenceScope<Notification>)
suspend fun Notification.send() {
withLoggingContext("notification" to (this).toString()) {
logger.info { "Notification sent" }
iterator.yield(this)
}
}
fun PlayerActionEvent.isFromCurrentUser(): Boolean =
game.players.get(currentUserId).id == playerId
when (this@toNotification) {
is GameCreatedEvent -> {
// Nothing to send
}
is DrawFilledWithDiscardEvent -> {
PilesShuffledNotification().send()
}
is NewPlayerEvent -> {
if (this@toNotification.player.userId != currentUserId) {
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)
.send()
}
}
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)
.send()
}
}
is PlayerReadyEvent -> {
PlayerWasReadyNotification(
playerId = this@toNotification.playerId,
).send()
}
is PlayerWinEvent -> {
PlayerWinNotification(
playerId = this@toNotification.playerId,
).send()
}
}
}
}
@@ -1,72 +0,0 @@
package eventDemo.contexts.game.application.notification
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
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.ports.GameEventBus
import eventDemo.libs.bus.Bus
import eventDemo.libs.command.CommandUnicityChecker
import eventDemo.shared.game.command.GameCommand
import eventDemo.shared.game.notification.Notification
import eventDemo.shared.ids.GameId
import eventDemo.shared.ids.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<Notification>,
): 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<GameCommand>()
@DelicateCoroutinesApi
fun subscribe(
currentUserId: UserId,
incomingFrameChannel: ReceiveChannel<GameCommand>,
): Job =
GlobalScope.launch {
for (command in incomingFrameChannel) {
withLoggingContext(CurrentUserId to currentUserId, Command to command) {
controller.runOnlyOnce(command) {
gameCommandHandlerDispatcher.dispatch(command)
}
}
}
}
}
@@ -1,6 +0,0 @@
package eventDemo.contexts.game.application.ports
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.libs.bus.Bus
interface GameEventBus : Bus<GameEvent>
@@ -1,7 +0,0 @@
package eventDemo.contexts.game.application.ports
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.shared.ids.GameId
interface GameEventStore : EventStore<GameEvent, GameId>
@@ -1,14 +0,0 @@
package eventDemo.domain.event.projection
import eventDemo.shared.game.projection.GameList
interface GameListRepository {
fun getList(
limit: Int = 100,
offset: Int = 0,
): List<GameList>
fun save(gameList: GameList)
fun subscribeToBus()
}
@@ -1,6 +0,0 @@
package eventDemo.contexts.game.application.ports
import eventDemo.libs.bus.Bus
import eventDemo.shared.game.projection.GameProjection
interface GameProjectionBus : Bus<GameProjection>
@@ -1,55 +0,0 @@
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.shared.game.projection.GameList
fun GameList.applyEvent(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,
status = GameList.Status.FINISH,
)
}
is CardIsPlayedEvent -> {
this
}
is PlayerHaveDrawCardEvent -> {
this
}
is PlayerReadyEvent -> {
this
}
is DrawFilledWithDiscardEvent -> {
this
}
}
@@ -1,65 +0,0 @@
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<GameEventBus>()
}
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 && game.lastPlayerId != null) {
val lastPlayerWin =
game
.players
.get(game.lastPlayerId)
.hand.size == 0
if (lastPlayerWin) {
game
.playerWin(game.lastPlayerId)
.saveEvents()
.publishEvents()
}
}
}
}
@@ -1,31 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.Card
import eventDemo.shared.game.Card.Color
import eventDemo.shared.game.Player
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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.random())
override val createdAt: Instant = Clock.System.now()
val theColorCard get() = if (card is Card.CardWithColor) card.color else chosenColor
}
@@ -1,26 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.DiscardPile
import eventDemo.shared.game.DrawPile
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,22 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,21 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.libs.eventSource.Event
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import eventDemo.shared.serializers.GameIdSerializer
import kotlinx.serialization.Serializable
/**
* An [Event] of a Game.
*/
@Serializable
sealed interface GameEvent : Event<GameId> {
@Serializable(with = EventIdSerializer::class)
override val eventId: EventId
@Serializable(with = GameIdSerializer::class)
override val aggregateId: GameId
override val version: Int
}
@@ -1,32 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.DiscardPile
import eventDemo.shared.game.DrawPile
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerHand
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import eventDemo.shared.serializers.PlayerIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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 playersHans: Map<Player.PlayerId, PlayerHand>,
val drawPile: DrawPile,
val discardPile: DiscardPile,
override val version: Int,
) : GameEvent {
@Serializable(with = EventIdSerializer::class)
override val eventId: EventId = EventId(Uuid.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,27 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.Player
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,9 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.Player
import kotlinx.serialization.Serializable
@Serializable
sealed interface PlayerActionEvent : GameEvent {
val playerId: Player.PlayerId
}
@@ -1,29 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.Card
import eventDemo.shared.game.Player
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import eventDemo.shared.serializers.PlayerIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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<Card>,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = EventIdSerializer::class)
override val eventId: EventId = EventId(Uuid.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,27 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.Player
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import eventDemo.shared.serializers.PlayerIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,27 +0,0 @@
package eventDemo.contexts.game.domain.events
import eventDemo.shared.game.Player
import eventDemo.shared.ids.EventId
import eventDemo.shared.ids.GameId
import eventDemo.shared.serializers.EventIdSerializer
import eventDemo.shared.serializers.PlayerIdSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.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.random())
override val createdAt: Instant = Clock.System.now()
}
@@ -1,68 +0,0 @@
package eventDemo.contexts.game.domain.game.errors
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.contexts.game.domain.game.gameState.Deck
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.shared.game.Card
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerList
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<Card>,
) : 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<GameEvent>,
) : GameException("Inconsistent event version")
@@ -1,81 +0,0 @@
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.errors.GameException
import eventDemo.contexts.game.domain.game.errors.InconsistentEventVersionException
import eventDemo.shared.game.PlayerList
import eventDemo.shared.ids.GameId
sealed interface Game {
val aggregateId: GameId
val players: PlayerList
/**
* On each modification, an event is put in their
*/
val recordedEvents: Set<GameEvent>
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<GameEvent>): 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")
}
}
}.let {
when (it) {
is GameInit -> it
is GameCreated -> it.copy(recordedEvents = emptySet())
is GameEnded -> it.copy(recordedEvents = emptySet())
is GameStarted -> it.copy(recordedEvents = emptySet())
}
}
}
}
internal fun <T : GameEvent> T.checkState(
block: (T) -> Boolean,
exception: (T) -> GameException,
): T {
if (!block(this)) throw exception(this)
return this
}
@@ -1,179 +0,0 @@
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.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.shared.game.Card
import eventDemo.shared.game.DiscardPile
import eventDemo.shared.game.DrawPile
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerHand
import eventDemo.shared.game.PlayerList
import eventDemo.shared.ids.GameId
import eventDemo.shared.ids.UserId
data class GameCreated(
override val aggregateId: GameId,
override val players: PlayerList = PlayerList(),
val playersStatus: Map<Player.PlayerId, PlayerStatus> = emptyMap(),
override val recordedEvents: Set<GameEvent>,
override val version: Int,
) : Game {
val allPlayerIsReady: Boolean
get() {
return playersStatus.isNotEmpty() && playersStatus.values.all { it == PlayerStatus.Ready }
}
fun startGame(deck: Deck = newDeck().shuffleDeck()): GameStarted {
val (drawPile, discardPile, playersHands) =
initPiles(deck)
.let { (drawPile, discardPile) ->
createHandsFromDrawPile(drawPile)
.let { (drawPile, playersHands) ->
Triple(drawPile, discardPile, playersHands)
}
}
return GameStartedEvent(
aggregateId = aggregateId,
firstPlayer = players.randomPlayer().id,
version = version + 1,
drawPile = drawPile,
discardPile = discardPile,
playersHans = playersHands,
).checkState(
{ players.size > 1 },
{ NeedMorePlayersToStartGameException(players) },
).checkState(
{ allPlayerIsReady },
{ AllPlayerNotReadyException(players) },
).checkState(
{ deck.size == 108 },
{ DeckMissingCardsException(players, deck) },
).also { if (it.drawPile.size + it.discardPile.size + playersHands.values.sumOf { it.size } != 108) error("missing cards!") }
.run(::applyEvent)
}
private fun initPiles(deck: Set<Card>): Pair<DrawPile, DiscardPile> =
DrawPile(deck)
.generateValidDrawPile()
.take(1)
.let { (draw, cards) ->
draw to DiscardPile(cards)
}
private fun createHandsFromDrawPile(drawPile: DrawPile) =
players
.map { it.id }
.fold(Pair(drawPile, emptyMap<Player.PlayerId, PlayerHand>())) { (drawAcc, handsAcc), playerId ->
drawAcc
.take(7)
.let { (draw, hand) ->
Pair(
draw,
handsAcc + (playerId to PlayerHand(hand)),
)
}
}
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
.map {
it.copy(hand = event.playersHans[it.id] ?: error("Player ${it.id} not found"))
}.let { PlayerList(it.toSet()) },
lastPlayerId = null,
nextPlayerId = 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<Card>
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() }
}.let {
it + (1..4).map { Card.ChangeColorCard() }
}.toSet()
fun Set<Card>.shuffleDeck(): Set<Card> {
if (isDisabled) return this
return shuffled().toSet()
}
private fun PlayerList.randomPlayer(): Player {
if (isDisabled) return first()
return random()
}
private var isDisabled = false
fun disableRandomForTest() {
isDisabled = true
}
@@ -1,20 +0,0 @@
package eventDemo.contexts.game.domain.game.gameState
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerList
import eventDemo.shared.ids.GameId
data class GameEnded(
override val aggregateId: GameId,
override val players: PlayerList,
val playerWins: Set<Player.PlayerId> = emptySet(),
override val version: Int,
override val recordedEvents: Set<GameEvent>,
) : Game {
init {
if (!players.map { it.id }.containsAll(playerWins)) {
throw IllegalArgumentException("Player ${players.map { it.id }} were not in players")
}
}
}
@@ -1,28 +0,0 @@
package eventDemo.contexts.game.domain.game.gameState
import eventDemo.contexts.game.domain.events.GameCreatedEvent
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.shared.game.PlayerList
import eventDemo.shared.ids.GameId
data class GameInit(
override val aggregateId: GameId,
) : Game {
override val players: PlayerList = PlayerList()
override var recordedEvents: Set<GameEvent> = 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)
}
@@ -1,264 +0,0 @@
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.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
import eventDemo.shared.game.Card
import eventDemo.shared.game.Card.Color
import eventDemo.shared.game.DiscardPile
import eventDemo.shared.game.DrawPile
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerList
import eventDemo.shared.ids.GameId
fun PlayerList.nextPlayerTurn(
lastPlayerId: Player.PlayerId,
direction: Direction,
): Player.PlayerId {
val lastPlayer = get(lastPlayerId)
val playersLastTurn = filter { it.hand.cards.isNotEmpty() || it == lastPlayer }
return 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 -> elementAt(nextPlayerIndex).id }
}
data class GameStarted(
override val aggregateId: GameId,
override val players: PlayerList,
val drawPile: DrawPile,
val discardPile: DiscardPile,
val lastPlayerId: Player.PlayerId?,
val nextPlayerId: Player.PlayerId,
val currentColor: Color,
val playedTurnHistory: List<History> = emptyList(),
val direction: Direction = Direction.CLOCKWISE,
val playerWins: Set<Player.PlayerId> = emptySet(),
override val version: Int,
override val recordedEvents: Set<GameEvent>,
) : Game {
val playersInGame by lazy { players.filter { it.hand.cards.isNotEmpty() } }
val lastPlayedCard: Card? by lazy { discardPile.topCard }
data class History(
val playerId: Player.PlayerId,
val event: GameEvent,
val direction: Direction,
)
val lastPlayer: Player? by lazy { lastPlayerId?.let { players.get(it) } }
val nextPlayer: Player by lazy { players.get(nextPlayerId) }
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<Card> =
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) || card is Card.CardWith4Color
}, { TheCardIsAColorCardException(playerId) })
.checkState({
(card is Card.CardWith4Color && chosenColor != null) || card is Card.CardWithColor
}, { 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,
nextPlayerId = players.nextPlayerTurn(event.playerId, nextDirectionAfterPlay),
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,
lastPlayerId = event.playerId,
nextPlayerId = players.nextPlayerTurn(event.playerId, direction),
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 : PlayerActionEvent> T.checkPlayerTurn(): T =
checkState(
{ nextPlayer.id == playerId },
{ ItsNotTheTurnException(playerId, this) },
)
private fun isPlayedLastTurn(card: Card): Boolean =
(playedTurnHistory.last().event as? CardIsPlayedEvent)?.card == card
}
@@ -1,17 +0,0 @@
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
}
@@ -1,18 +0,0 @@
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)
}
@@ -1,26 +0,0 @@
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.GameListRepositoryInMemory
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(::GameListRepositoryInMemory) bind GameListRepository::class
}
@@ -1,21 +0,0 @@
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()
}
}
@@ -1,14 +0,0 @@
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())
}
}
@@ -1,16 +0,0 @@
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(),
)
}
}
@@ -1,9 +0,0 @@
package eventDemo.contexts.game.infrastructure.configuration.listener
import eventDemo.domain.event.projection.GameListRepository
import org.koin.core.Koin
fun Koin.configureProjectionListener() {
get<GameListRepository>()
.subscribeToBus()
}
@@ -1,9 +0,0 @@
package eventDemo.contexts.game.infrastructure.configuration.listener
import eventDemo.contexts.game.application.reaction.ReactionListener
import org.koin.core.Koin
fun Koin.configureReactionListener() {
get<ReactionListener>()
.subscribeToBus()
}
@@ -1,14 +0,0 @@
package eventDemo.contexts.game.infrastructure.persistence.eventStore
import eventDemo.contexts.game.application.ports.GameEventStore
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
import eventDemo.shared.ids.GameId
/**
* A stream to publish and read the played card event.
*/
class GameEventStoreInMemory :
GameEventStore,
EventStore<GameEvent, GameId> by EventStoreInMemory()
@@ -1,22 +0,0 @@
package eventDemo.contexts.game.infrastructure.persistence.eventStore
import eventDemo.contexts.game.application.ports.GameEventStore
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
import eventDemo.shared.ids.GameId
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<GameEvent, GameId> by EventStoreInPostgresql(
dataSource,
{ Json.encodeToString(it) },
{ Json.decodeFromString(it) },
"game.game_event_stream",
)
@@ -1,49 +0,0 @@
package eventDemo.contexts.game.infrastructure.persistence.projections
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.application.projections.applyEvent
import eventDemo.domain.event.projection.GameListRepository
import eventDemo.shared.game.projection.GameList
import eventDemo.shared.ids.GameId
import io.github.oshai.kotlinlogging.withLoggingContext
/**
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
*/
class GameListRepositoryInMemory(
val gameEventStore: GameEventStore,
val projectionBus: GameProjectionBus,
val eventBus: GameEventBus,
) : GameListRepository {
val projections: MutableMap<GameId, GameList> = mutableMapOf()
override fun getList(
limit: Int,
offset: Int,
): List<GameList> =
projections
.values
.drop(offset)
.take(limit)
override fun save(gameList: GameList) {
projections[gameList.aggregateId] = gameList
}
override fun subscribeToBus() {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
gameEventStore
.getStream(event.aggregateId)
.readAll()
.fold(GameList(event.aggregateId)) { acc, event ->
acc.applyEvent(event)
}.also { save(it) }
.also { projectionBus.publish(it) }
}
}
}
}
@@ -1,45 +0,0 @@
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.shared.ids.GameId
import eventDemo.shared.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<Game> { 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)
}
}
}
@@ -1,26 +0,0 @@
package eventDemo.contexts.game.infrastructure.websocket
import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
import eventDemo.libs.helpers.fromFrameChannel
import eventDemo.libs.helpers.toObjectChannel
import eventDemo.shared.ids.GameId
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 kotlin.uuid.Uuid
@DelicateCoroutinesApi
fun Route.gameWebSocket(channelSubscriber: GameChannelsSubscriber) {
authenticate {
webSocket("/games/{id}") {
channelSubscriber.subscribePlayerToGameChannels(
gameId = GameId(Uuid.parse(call.parameters["id"]!!)),
userId = call.currentUserId,
incomingCommandChannel = toObjectChannel(incoming),
sendNotificationChannel = fromFrameChannel(outgoing),
)
}
}
}
@@ -1,16 +0,0 @@
package eventDemo.libs.eventSource
import eventDemo.shared.ids.AggregateId
import eventDemo.shared.ids.EventId
import kotlinx.datetime.Instant
/**
* The basic interface for an Event
* @see eventDemo.libs.eventSource.eventStore.EventStream
*/
interface Event<ID : AggregateId> {
val eventId: EventId
val aggregateId: ID
val createdAt: Instant
val version: Int
}
@@ -1,19 +0,0 @@
package eventDemo.libs.eventSource.eventStore
import eventDemo.libs.eventSource.Event
import eventDemo.shared.ids.AggregateId
import io.github.oshai.kotlinlogging.withLoggingContext
interface EventStore<E : Event<ID>, ID : AggregateId> {
fun getStream(aggregateId: ID): EventStream<E, ID>
@Throws(VersionConflictException::class)
fun append(event: E) =
withLoggingContext("event" to event.toString()) {
getStream(event.aggregateId).append(event)
}
@Throws(VersionConflictException::class)
fun append(events: Set<E>) =
events.forEach { append(it) }
}
@@ -1,13 +0,0 @@
package eventDemo.sharedKernel
import eventDemo.shared.ids.UserId
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import kotlin.uuid.Uuid
internal val ApplicationCall.currentUserId: UserId
get() =
principal<JWTPrincipal>()!!.run {
UserId(Uuid.parse(payload.getClaim("userid").asString()))
}
@@ -1,66 +0,0 @@
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),
* pour chaque bounded context sous `eventDemo.contexts`.
*
* Les contexts ne sont pas listés en dur : ils sont déduits des classes réellement
* présentes sous `eventDemo.contexts.*`, de sorte que l'ajout d'un nouveau context
* (nouveau dossier `eventDemo.contexts.<xxx>`) soit automatiquement couvert par ce test,
* sans modification de ce fichier.
*
* Convention attendue, pour un contexte donné :
* eventDemo.contexts.<context>.domain
* eventDemo.contexts.<context>.application
* eventDemo.contexts.<context>.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 rootPackage = "eventDemo.contexts"
private val classes =
ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(rootPackage)
// Premier segment de package après "eventDemo.contexts." (ex. "auth", "game", ...),
// recalculé à chaque exécution à partir des classes importées.
private val contexts: Set<String> =
classes
.map { it.packageName }
.filter { it.startsWith("$rootPackage.") }
.map { it.removePrefix("$rootPackage.").substringBefore('.') }
.toSet()
@Test
fun `respecte les couches de l'architecture hexagonale`() {
check(contexts.isNotEmpty()) {
"Aucun context trouvé sous `$rootPackage` : le test ne vérifie rien, " +
"vérifiez que le package racine est correct."
}
contexts.forEach { context ->
val basePackage = "$rootPackage.$context"
@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)
}
}
}
@@ -1,34 +0,0 @@
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"
}
}
})
@@ -1,286 +0,0 @@
package eventDemo.contexts.game.application
import eventDemo.Tag
import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.contexts.game.domain.game.gameState.disableRandomForTest
import eventDemo.shared.game.Card
import eventDemo.shared.game.command.GameCommand
import eventDemo.shared.game.notification.ItsTheTurnOfNotification
import eventDemo.shared.game.notification.Notification
import eventDemo.shared.game.notification.PlayerAsJoinTheGameNotification
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
import eventDemo.shared.game.notification.PlayerWasReadyNotification
import eventDemo.shared.game.notification.TheGameWasStartedNotification
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
import eventDemo.shared.ids.GameId
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.createGameWithCommandsInChannels
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.joinTheGame
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.playCard
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.readyToPlay
import eventDemo.testHelpers.createNewUser
import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.nondeterministic.until
import io.kotest.assertions.retry
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.equals.shouldEqual
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.should
import io.kotest.matchers.types.shouldBeInstanceOf
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") {
should {
retry(maxRetry = 3, timeout = 20.seconds) {
disableRandomForTest()
val gameId = GameId()
val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED)
val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification1 = Channel<Notification>(Channel.BUFFERED)
val channelNotification2 = Channel<Notification>(Channel.BUFFERED)
var playedCard1: Card? = null
var playedCard2: Card? = null
var player1HasJoin = false
testKoinApplicationWithConfig {
val gameRepository = get<GameRepository>()
val userRepository = get<UserRepository>()
userRepository.run {
save(user1)
save(user2)
}
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<GameChannelsSubscriber>().subscribePlayerToGameChannels(
gameId,
user1.id,
channelCommand1,
channelNotification1,
)
}
GlobalScope.launch(Dispatchers.IO) {
get<GameChannelsSubscriber>().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<Notification>()
val player2Notifications = mutableListOf<Notification>()
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, gameId, user1) {
joinTheGame()
player1Notifications.waitNotification<WelcomeToTheGameNotification> {
players.map { it.userId }.contains(user1.id)
}
player1HasJoin = true
player1Notifications.waitNotification<PlayerAsJoinTheGameNotification> {
player.userId == user2.id
}
readyToPlay()
player1Notifications.waitNotification<PlayerWasReadyNotification> {
playerId == getPlayer(user2).id
}
playedCard1 =
player1Notifications
.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
.hand
.first()
.apply {
this.shouldBeInstanceOf<Card.NumericCard>()
number shouldEqual 1
color shouldEqual Card.Color.Red
}
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
if (player.userId == user2.id) error("WRONG PLAYER TURN")
player.userId == user1.id
}
game
.shouldBeInstanceOf<GameStarted>()
.discardPile
.topCard
.shouldNotBeNull()
.shouldBeInstanceOf<Card.NumericCard> {
it.number shouldEqual 0
it.color shouldEqual Card.Color.Red
}
playCard(playedCard1!!)
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
player1Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user2).id && card == playedCard2
}
playedCard1 =
assertInstanceOf<GameStarted>(game)
.playableCards(currentPlayer.id)
.first()
playedCard1.run {
this.shouldBeInstanceOf<Card.NumericCard>()
number shouldEqual 2
color shouldEqual Card.Color.Red
}
playCard(playedCard1)
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
}
}
// Player 2 actions
val player2Job =
launch {
createGameWithCommandsInChannels(channelCommand2, gameId, user2) {
// wait player 1 has joined the game
until(3.seconds) { player1HasJoin }
joinTheGame()
player2Notifications.waitNotification<WelcomeToTheGameNotification> {
players.map { it.userId }.contains(user1.id) &&
players.map { it.userId }.contains(user2.id)
}
player2Notifications.waitNotification<PlayerWasReadyNotification> { playerId == getPlayer(user1).id }
readyToPlay()
playedCard2 =
player2Notifications
.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
.hand
.first()
.apply {
this.shouldBeInstanceOf<Card.NumericCard>()
number shouldEqual 8
color shouldEqual Card.Color.Red
}
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
if (player.userId == user2.id) error("WRONG PLAYER TURN")
player.userId == user1.id
}
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user1).id && card == playedCard1
}
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player == currentPlayer
}
game
.shouldBeInstanceOf<GameStarted>()
.discardPile
.topCard
.shouldNotBeNull()
.shouldBeInstanceOf<Card.NumericCard> {
it.number shouldEqual 1
it.color shouldEqual Card.Color.Red
}
playCard(playedCard2)
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player.userId == user1.id
}
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == currentPlayer.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<GameStarted>(game)
// Check if the state is correct
game.aggregateId shouldBeEqual gameId
game.players.map { it.userId } shouldContainExactly 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.lastPlayer?.userId) shouldBeEqual user1.id
assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1)
}
}
}
}
})
context(user: User)
private suspend inline fun <reified T : Notification> MutableList<Notification>.waitNotification(crossinline block: T.() -> Boolean): T {
println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}")
return eventually(5.seconds) {
filterIsInstance<T>()
.first { block(it) }
.also { remove(it) }
}.also { println("NOTIFICATION RECEIVED: ${T::class.simpleName} for user: ${user.username}") }
}
@@ -1,121 +0,0 @@
package eventDemo.contexts.game.application.eventStore
import ch.qos.logback.classic.Level
import com.rabbitmq.client.impl.ForgivingExceptionHandler
import eventDemo.Tag
import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.shared.ids.GameId
import eventDemo.testHelpers.CreateGameWithCommandsHelpers
import eventDemo.testHelpers.CreateGameWithCommandsHelpers.joinTheGame
import eventDemo.testHelpers.createNewUser
import eventDemo.testHelpers.testKoinApplicationWithConfig
import eventDemo.testHelpers.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 = createNewUser("user1")
val user2 = createNewUser("user2")
test("GameRepository should build return the game after dispatch commands") {
testKoinApplicationWithConfig {
get<UserRepository>().run {
save(user1)
}
CreateGameWithCommandsHelpers.createGameWithCommands {
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,
) {
testKoinApplicationWithConfig {
val repo = get<GameEventStoreRepository>()
get<UserRepository>().run {
save(user1)
save(user2)
}
CreateGameWithCommandsHelpers.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,
) {
var aggregateIds: MutableList<GameId> = mutableListOf()
testKoinApplicationWithConfig {
val repo = get<GameRepository>()
val gameName = "testGame${UUID.randomUUID()}"
(1..2)
.map { treadN ->
GlobalScope
.launch {
CreateGameWithCommandsHelpers.createGameWithCommands("testGame $treadN") {
repeat(3) { userN ->
val userX = createNewUser("userX $treadN:$userN")
get<UserRepository>().save(userX)
userX.joinTheGame()
}
aggregateIds.add(gameId)
}
}
}.joinAll()
eventually(
eventuallyConfig {
duration = 5.seconds
interval = 1.seconds
includeFirst = false
},
) {
aggregateIds shouldHaveSize 2
aggregateIds.forEach {
assertNotNull(repo.get(it)).run {
version shouldBeEqual 4
players shouldHaveSize 3
}
}
}
}
}
}
})
@@ -1,85 +0,0 @@
package eventDemo.contexts.game.application.notification
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInMemory
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.eventStores.GameEventStoreRepository
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory
import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInMemory
import eventDemo.shared.game.command.JoinTheGameCommand
import eventDemo.shared.game.notification.Notification
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
import eventDemo.shared.ids.UserId
import eventDemo.testHelpers.createNewUser
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain
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
import kotlin.time.Duration.Companion.seconds
class EventToNotificationSubscriberTest :
FunSpec({
test("When event when send to the bus, a notification should be published") {
val bus = GameEventBusInMemory()
val gameRepository = GameEventStoreRepository(GameEventStoreInMemory())
val userRepository = UserEventStoreRepository(UserEventStoreInMemory())
val subscriber =
EventToNotificationSubscriber(
bus,
gameRepository,
)
val commentDispatcher =
GameCommandHandlerDispatcher(
PlayCardHandler(gameRepository, bus),
ReadyToPlayHandler(gameRepository, bus),
JoinTheGameHandler(gameRepository, bus, userRepository),
TakeCartFromDrawPileHandler(gameRepository, bus),
)
val notificationChannel = Channel<Notification>(Channel.BUFFERED)
val game = gameRepository.create()
val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
userRepository.run {
save(user1)
save(user2)
}
val player1Notifications = mutableListOf<Notification>()
GlobalScope.launch {
for (notification in notificationChannel) {
player1Notifications.add(notification)
}
}
commentDispatcher.dispatch(JoinTheGameCommand(user1.id, JoinTheGameCommand.Payload(game.aggregateId)))
subscriber
.subscribeToEventsAndSendNotification(
game.aggregateId,
user2.id,
notificationChannel,
).use {
commentDispatcher.dispatch(JoinTheGameCommand(user2.id, JoinTheGameCommand.Payload(game.aggregateId)))
}
eventually(duration = 1.seconds) {
player1Notifications.size shouldEqual 1
}
player1Notifications.first().let { notification ->
assertInstanceOf<WelcomeToTheGameNotification>(notification)
notification.players.map { it.userId } shouldContain user2.id
}
}
})
@@ -1,301 +0,0 @@
package eventDemo.contexts.game.application.notification
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
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.game.gameState.GameCreated
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.shared.game.Card
import eventDemo.shared.game.DiscardPile
import eventDemo.shared.game.DrawPile
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerHand
import eventDemo.shared.game.PlayerList
import eventDemo.shared.game.notification.ItsTheTurnOfNotification
import eventDemo.shared.game.notification.PilesShuffledNotification
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
import eventDemo.shared.game.notification.PlayerHavePassNotification
import eventDemo.shared.game.notification.PlayerWasReadyNotification
import eventDemo.shared.game.notification.TheGameWasStartedNotification
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
import eventDemo.shared.game.notification.YourNewCardNotification
import eventDemo.shared.ids.GameId
import eventDemo.shared.ids.UserId
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.assertInstanceOf
class ToNotificationTest :
FunSpec({
val player1 =
Player(
name = "Bob",
userId = UserId(),
hand = PlayerHand(setOf(Card.NumericCard(1, Card.Color.Red))),
id = Player.PlayerId(),
)
val player2 =
Player(
name = "John",
userId = UserId(),
hand = PlayerHand(setOf(Card.NumericCard(1, Card.Color.Red))),
id = Player.PlayerId(),
)
test("NewPlayerEvent") {
val game =
GameCreated(
aggregateId = GameId(),
version = 1,
players = PlayerList(setOf(player1)),
recordedEvents = setOf(),
)
NewPlayerEvent(
game.aggregateId,
version = 2,
player = player1,
).toNotification(
game = game,
currentUserId = player1.userId,
).let {
it.toList().size shouldBe 1
// Check if the user is
assertInstanceOf<WelcomeToTheGameNotification>(it.first()).run {
players.size shouldBe 1
players.first().name shouldBe "Bob"
}
}
}
test("PlayerReadyEvent") {
val game =
GameCreated(
aggregateId = GameId(),
version = 1,
players = PlayerList(setOf(player1)),
recordedEvents = setOf(),
)
PlayerReadyEvent(
game.aggregateId,
version = 2,
playerId = player1.id,
).toNotification(
game = game,
currentUserId = player1.userId,
).let {
it.toList().size shouldBe 1
assertInstanceOf<PlayerWasReadyNotification>(it.first()).let {
it.playerId shouldBe player1.id
}
}
}
test("PlayerHaveDrawCardEvent on current player") {
val game =
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
drawPile = DrawPile(),
discardPile = DiscardPile(),
lastPlayerId = player1.id,
nextPlayerId = player2.id,
currentColor = Card.Color.Red,
version = 1,
recordedEvents = setOf(),
)
val card = Card.NumericCard(1, Card.Color.Blue)
PlayerHaveDrawCardEvent(
game.aggregateId,
version = 2,
playerId = player1.id,
takenCards = setOf(card),
).toNotification(
game = game,
currentUserId = player1.userId,
).let {
it.toList().size shouldBe 2
it.toList().let { notifications ->
assertInstanceOf<YourNewCardNotification>(notifications.first()).let {
it.cards.first() shouldBe card
}
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
it.player.id shouldBe player2.id
}
}
}
}
test("PlayerHaveDrawCardEvent on other player") {
val game =
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
drawPile = DrawPile(),
discardPile = DiscardPile(),
lastPlayerId = player1.id,
nextPlayerId = player2.id,
currentColor = Card.Color.Red,
version = 1,
recordedEvents = setOf(),
)
val card = Card.NumericCard(1, Card.Color.Blue)
PlayerHaveDrawCardEvent(
game.aggregateId,
version = 2,
playerId = player1.id,
takenCards = setOf(card),
).toNotification(
game = game,
currentUserId = player2.userId,
).let {
it.toList().size shouldBe 2
it.toList().let { notifications ->
assertInstanceOf<PlayerHavePassNotification>(notifications.first()).let {
it.playerId shouldBe player1.id
}
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
it.player.id shouldBe player2.id
}
}
}
}
test("CardIsPlayedEvent on current player") {
val game =
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
drawPile = DrawPile(),
discardPile = DiscardPile(),
lastPlayerId = player2.id,
nextPlayerId = player1.id,
currentColor = Card.Color.Red,
version = 1,
recordedEvents = setOf(),
)
val card = Card.NumericCard(1, Card.Color.Blue)
CardIsPlayedEvent(
game.aggregateId,
version = 2,
playerId = player1.id,
card = card,
).toNotification(
game = game,
currentUserId = player1.userId,
).toList()
.let { notifications ->
notifications.size shouldBe 2
assertInstanceOf<PlayerAsPlayACardNotification>(notifications.first()).let {
it.playerId shouldBe player1.id
it.card shouldBe card
}
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
it.player.id shouldBe player1.id
}
}
}
test("CardIsPlayedEvent on other player") {
val game =
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
drawPile = DrawPile(),
discardPile = DiscardPile(),
lastPlayerId = player1.id,
nextPlayerId = player2.id,
currentColor = Card.Color.Red,
version = 1,
recordedEvents = setOf(),
)
val card = Card.NumericCard(1, Card.Color.Blue)
CardIsPlayedEvent(
game.aggregateId,
version = 2,
playerId = player2.id,
card = card,
).toNotification(
game = game,
currentUserId = player1.userId,
).let {
it.toList().size shouldBe 2
it.toList().let { notifications ->
assertInstanceOf<PlayerAsPlayACardNotification>(notifications.first()).let {
it.playerId shouldBe player2.id
it.card shouldBe card
}
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
it.player.id shouldBe player2.id
}
}
}
}
test("DrawFilledWithDiscardEvent") {
val game =
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
drawPile = DrawPile(),
discardPile = DiscardPile(),
lastPlayerId = player1.id,
nextPlayerId = player2.id,
currentColor = Card.Color.Red,
version = 1,
recordedEvents = setOf(),
)
DrawFilledWithDiscardEvent(
game.aggregateId,
version = 2,
newDrawPile = DrawPile(),
newDiscardPile = DiscardPile(),
).toNotification(
game = game,
currentUserId = player1.userId,
).let {
it.toList().size shouldBe 1
assertInstanceOf<PilesShuffledNotification>(it.first())
}
}
test("GameStartedEvent") {
val game =
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
drawPile = DrawPile(),
discardPile = DiscardPile(),
lastPlayerId = player1.id,
nextPlayerId = player2.id,
currentColor = Card.Color.Red,
version = 1,
recordedEvents = setOf(),
)
GameStartedEvent(
game.aggregateId,
version = 2,
firstPlayer = player1.id,
playersHans =
mapOf(
player1.id to player1.hand,
player2.id to player2.hand,
),
drawPile = DrawPile(),
discardPile = DiscardPile(),
).toNotification(
game = game,
currentUserId = player1.userId,
).toList()
.let { notifications ->
notifications.size shouldBe 2
assertInstanceOf<TheGameWasStartedNotification>(notifications.first()).let {
it.hand.size shouldBe 1
it.hand.first() shouldBe player1.hand.cards.first()
}
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
it.player.id shouldBe player2.id
}
}
}
})
@@ -1,48 +0,0 @@
package eventDemo.contexts.game.domain.game
import eventDemo.shared.game.Card
import eventDemo.shared.game.PlayerHand
import eventDemo.testHelpers.act
import eventDemo.testHelpers.arrange
import eventDemo.testHelpers.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<Set<Card.NumericCard>>(hand.cards)
assertInstanceOf<Card.NumericCard>(hand.cards.elementAt(0)).number shouldBeExactly 1
assertInstanceOf<Card.NumericCard>(hand.cards.elementAt(1)).number shouldBeExactly 2
}
}
})
@@ -1,389 +0,0 @@
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.PlayerHaveDrawCardEvent
import eventDemo.contexts.game.domain.events.PlayerWinEvent
import eventDemo.contexts.game.domain.game.gameState.Game.Direction
import eventDemo.shared.game.Card
import eventDemo.shared.game.DiscardPile
import eventDemo.shared.game.DrawPile
import eventDemo.shared.game.Player
import eventDemo.shared.game.PlayerHand
import eventDemo.shared.game.PlayerList
import eventDemo.shared.ids.GameId
import eventDemo.shared.ids.UserId
import eventDemo.testHelpers.act
import eventDemo.testHelpers.assert
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<String, Triple<Card, Card, Card.Color?>> =
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<String, Triple<Card, Card, Card.Color?>> =
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<Card.NumericCard>(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<Card.ReverseCard>(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())
val player2 = Player("Player 2", UserId())
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1)),
lastPlayerId = player1.id,
nextPlayerId = player2.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<Card.NumericCard>(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())
val player2 = Player("Bob", UserId())
assert {
GameStarted(
aggregateId = GameId(),
players = PlayerList(setOf(player1)),
lastPlayerId = player1.id,
nextPlayerId = player2.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,
nextPlayerId = player2.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))))
val players = PlayerList(setOf(player1, player2, player3))
return GameStarted(
aggregateId = GameId(),
players = players,
lastPlayerId = player3.id,
nextPlayerId = players.nextPlayerTurn(player3.id, Direction.CLOCKWISE),
discardPile = DiscardPile(setOf(onTheDiscardPile)),
drawPile = DrawPile(),
currentColor = (onTheDiscardPile as? Card.CardWithColor)?.color ?: chosenColor ?: error("no color"),
version = 0,
recordedEvents = emptySet(),
)
}
@@ -1,41 +0,0 @@
package eventDemo.contexts.game.domain.game.gameState
import eventDemo.shared.game.Card
import io.kotest.assertions.retry
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlin.time.Duration.Companion.seconds
class NewDeckTest :
FunSpec({
test("newDeck") {
newDeck().let {
it shouldNotBe null
it.filterIsInstance<Card.NumericCard>() shouldHaveSize 76
it.filterIsInstance<Card.Plus2Card>() shouldHaveSize 8
it.filterIsInstance<Card.ReverseCard>() shouldHaveSize 8
it.filterIsInstance<Card.PassCard>() shouldHaveSize 8
it.filterIsInstance<Card.Plus4Card>() shouldHaveSize 4
it.filterIsInstance<Card.ChangeColorCard>() shouldHaveSize 4
it shouldHaveSize 108
}
}
test("shuffleDeck") {
val deck = (0..9).map { Card.NumericCard(it, Card.Color.Red) }
deck.run {
this[3].number shouldBe 3
}
should {
retry(maxRetry = 4, timeout = 1.seconds) {
deck.shuffled().run {
this[3].number shouldNotBe 3
}
}
}
}
})
@@ -1,10 +0,0 @@
package eventDemo.contexts.game.intrastructure
import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.auth.infrastructure.configure.makeJwt
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
internal fun HttpRequestBuilder.withAuth(user: User) {
header("Authorization", "Bearer ${user.makeJwt("secret")}")
}
@@ -1,60 +0,0 @@
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.testHelpers.spyPing
import eventDemo.testHelpers.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<ConnectionFactory>().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)
}
}
}
}
})
@@ -1,44 +0,0 @@
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.infrastructure.persistence.eventBus.GameEventBusInMemory
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ
import eventDemo.shared.game.Player
import eventDemo.shared.ids.GameId
import eventDemo.shared.ids.UserId
import eventDemo.testHelpers.spyPing
import eventDemo.testHelpers.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<String, GameEventBus> =
mapOf(
GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(),
GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get<ConnectionFactory>()),
)
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))
}
}
}
}
})
@@ -1,128 +0,0 @@
package eventDemo.contexts.game.intrastructure.rest
import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.game.intrastructure.httpClient
import eventDemo.contexts.game.intrastructure.withAuth
import eventDemo.shared.game.projection.GameList
import eventDemo.testHelpers.CreateGameWithCommandsHelpers
import eventDemo.testHelpers.CreateGameWithCommandsHelpers.joinTheGame
import eventDemo.testHelpers.CreateGameWithCommandsHelpers.readyToPlay
import eventDemo.testHelpers.createNewUser
import eventDemo.testHelpers.testApplicationWithConfig
import io.github.oshai.kotlinlogging.KotlinLogging
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.nondeterministic.eventuallyConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain
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 kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
val logger = KotlinLogging.logger {}
class GameListRouteTest :
FunSpec({
test("/games with no game started") {
val user1 = createNewUser("user1")
testApplicationWithConfig({
get<UserRepository>().save(user1)
}) {
logger.info { "Starting player1" }
httpClient()
.get("/games") {
withAuth(user1)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val list = call.body<List<GameList>>()
assertTrue(list.isEmpty())
}
}
}
test("/games return a game with status OPENING") {
val user1 = createNewUser("user1")
testApplicationWithConfig({
get<UserRepository>().save(user1)
CreateGameWithCommandsHelpers.createGameWithCommands {
user1.joinTheGame()
}
}) {
// Wait until the projection is created
eventually(
eventuallyConfig {
initialDelay = 1.seconds
interval = 1.seconds
duration = 3.seconds
},
) {
httpClient()
.get("/games") {
withAuth(user1)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
call.body<List<GameList>>().let {
assertNotNull(it)
assertTrue { it.isNotEmpty() }
it.firstOrNull()?.run {
status shouldBeEqual GameList.Status.OPENING
players shouldHaveSize 1
players.map { it.userId } shouldContain user1.id
winners shouldHaveSize 0
}
}
}
}
}
}
test("/games return a game with status IS_STARTED") {
val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
testApplicationWithConfig({
CreateGameWithCommandsHelpers.createGameWithCommands {
get<UserRepository>().run {
save(user1)
save(user2)
}
user1.joinTheGame()
user2.joinTheGame()
getPlayer(user1).readyToPlay()
getPlayer(user2).readyToPlay()
}
}) {
eventually(
eventuallyConfig {
initialDelay = 1.seconds
interval = 1.seconds
duration = 3.seconds
},
) {
httpClient()
.get("/games") {
withAuth(user1)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
call.body<List<GameList>>().first().let {
it.status shouldBeEqual GameList.Status.IS_STARTED
it.players shouldHaveSize 2
it.players.map { it.userId } shouldContain user1.id
it.players.map { it.userId } shouldContain user2.id
it.winners shouldHaveSize 0
}
}
}
}
}
})
@@ -1,44 +0,0 @@
package eventDemo.libs.bus
import com.rabbitmq.client.ConnectionFactory
import eventDemo.testHelpers.spyPing
import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.string.shouldStartWith
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
private data class ObjTest(
val value: String,
)
class BusTest :
FunSpec({
context("Pub/sub") {
testKoinApplicationWithConfig {
val list: Map<String, Bus<ObjTest>> =
mapOf(
BusInMemory::class.java.simpleName to BusInMemory(),
BusInRabbitMQ::class.java.simpleName to
BusInRabbitMQ(
get<ConnectionFactory>(),
"testExchange",
{ it.value },
{ ObjTest(it) },
),
)
withData(list) { bus ->
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()}"))
}
}
}
}
})
@@ -1,10 +0,0 @@
package eventDemo.libs.command
import eventDemo.shared.command.Command
import eventDemo.shared.ids.CommandId
import kotlinx.serialization.Serializable
@Serializable
data class CommandForTest(
override val id: CommandId,
) : Command
@@ -1,35 +0,0 @@
package eventDemo.libs.command
import eventDemo.shared.ids.CommandId
import eventDemo.testHelpers.spyPing
import io.kotest.core.spec.style.FunSpec
import org.junit.jupiter.api.assertThrows
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<CommandForTest>().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<CommandForTest>().run {
runOnlyOnce(com1) { ping() }
runOnlyOnce(com2) { ping() }
assertThrows<CommandUnicityChecker.UnicityException> {
runOnlyOnce(com2) { ping() }
}
}
}
}
})
@@ -1,64 +0,0 @@
package eventDemo.testHelpers
import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.shared.game.Card
import eventDemo.shared.game.Player
import eventDemo.shared.game.command.JoinTheGameCommand
import eventDemo.shared.game.command.PlayCardCommand
import eventDemo.shared.game.command.ReadyToPlayCommand
import eventDemo.shared.ids.GameId
import org.koin.core.Koin
import java.util.UUID
import kotlin.uuid.toKotlinUuid
object CreateGameWithCommandsHelpers {
class Data(
private val repo: GameRepository,
val gameId: GameId,
) {
fun getPlayer(user: User): Player =
repo.get(gameId)!!.players.get(user.id)
}
context(koin: Koin)
fun <T> createGameWithCommands(
gameName: String = "testGame${UUID.randomUUID()}",
block: context(CreateGameWithCommandsHelpers, GameCommandHandlerDispatcher) Data.() -> T,
): T {
val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray()).toKotlinUuid())
val repo = koin.get<GameRepository>()
repo.create(gameId)
return koin.get<GameCommandHandlerDispatcher>().run {
with(CreateGameWithCommandsHelpers) {
Data(repo, gameId).block()
}
}
}
context(dispatcher: GameCommandHandlerDispatcher, data: Data)
fun User.joinTheGame(): JoinTheGameCommand =
JoinTheGameCommand(
id,
JoinTheGameCommand.Payload(data.gameId),
).also { dispatcher.dispatch(it) }
context(dispatcher: GameCommandHandlerDispatcher, data: Data)
fun Player.readyToPlay(): ReadyToPlayCommand =
ReadyToPlayCommand(
userId,
ReadyToPlayCommand.Payload(data.gameId, id),
).also { dispatcher.dispatch(it) }
context(dispatcher: GameCommandHandlerDispatcher, data: Data)
fun Player.playCard(
card: Card,
chosenColor: Card.Color? = null,
): PlayCardCommand =
PlayCardCommand(
userId,
PlayCardCommand.Payload(data.gameId, id, card, chosenColor),
).also { dispatcher.dispatch(it) }
}
@@ -1,78 +0,0 @@
package eventDemo.testHelpers
import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.shared.game.Card
import eventDemo.shared.game.Player
import eventDemo.shared.game.command.GameCommand
import eventDemo.shared.game.command.JoinTheGameCommand
import eventDemo.shared.game.command.PlayCardCommand
import eventDemo.shared.game.command.ReadyToPlayCommand
import eventDemo.shared.ids.GameId
import kotlinx.coroutines.channels.Channel
import org.koin.core.Koin
object CreateGameWithCommandsInChannelsHelpers {
class Data(
private val repo: GameRepository,
val gameId: GameId,
val currentUser: User,
) {
fun getPlayer(user: User): Player =
game.players.get(user.id)
val currentPlayer: Player get() = getPlayer(currentUser)
val game: Game
get() = repo.get(gameId)!!
}
context(koin: Koin)
suspend fun <T> createGameWithCommandsInChannels(
channelCommand: Channel<GameCommand>,
gameId: GameId,
user: User,
block:
suspend context(
CreateGameWithCommandsInChannelsHelpers,
Channel<GameCommand>,
User,
) Data.() -> T,
): T {
val repo = koin.get<GameRepository>()
repo.getOrCreate(gameId)
return with(channelCommand) {
with(user) {
with(CreateGameWithCommandsInChannelsHelpers) {
Data(repo, gameId, user).block()
}
}
}
}
context(channelCommand: Channel<GameCommand>, data: Data)
suspend fun joinTheGame(): JoinTheGameCommand =
JoinTheGameCommand(
data.currentUser.id,
JoinTheGameCommand.Payload(data.gameId),
).also { channelCommand.send(it) }
context(channelCommand: Channel<GameCommand>, data: Data)
suspend fun readyToPlay(): ReadyToPlayCommand =
ReadyToPlayCommand(
data.currentUser.id,
ReadyToPlayCommand.Payload(data.gameId, data.currentPlayer.id),
).also { channelCommand.send(it) }
context(channelCommand: Channel<GameCommand>, data: Data)
suspend fun playCard(
card: Card,
chosenColor: Card.Color? = null,
): PlayCardCommand =
PlayCardCommand(
data.currentUser.id,
PlayCardCommand.Payload(data.gameId, data.currentPlayer.id, card, chosenColor),
).also { channelCommand.send(it) }
}

Some files were not shown because too many files have changed in this diff Show More