18 Commits
Author SHA1 Message Date
flecomte 505cfe38f0 chore: add CLAUDE.md
Tests / build (push) Successful in 7m19s
Tests / test (push) Failing after 10m44s
Tests / lint (push) Successful in 13m49s
2026-08-06 19:52:48 +02:00
flecomte 85bcdcadb8 chore: remove unused redis 2026-08-06 19:42:32 +02:00
flecomte e67c475c38 chore: auto test new context in HexagonalArchitectureTest 2026-08-06 19:33:38 +02:00
flecomte 07983dc5b0 fix: fix HexagonalArchitectureTest 2026-08-06 19:23:47 +02:00
flecomte 5e9587b93f docs: clean 2026-08-06 19:10:32 +02:00
flecomte fd62f2574d docs: update openapi 2026-08-05 00:43:12 +02:00
flecomte 828bd0639e docs: clean docs 2026-08-05 00:19:03 +02:00
flecomte da13b0d2b8 chore: update docker compose in CI/CD
Tests / build (push) Successful in 6m45s
Tests / test (push) Failing after 11m30s
Tests / lint (push) Successful in 14m32s
2026-08-05 00:06:23 +02:00
flecomte d632dc0f6b chore: refactor docker compose env's
Tests / build (push) Successful in 6m41s
Tests / test (push) Failing after 10m1s
Tests / lint (push) Successful in 13m29s
2026-08-04 23:38:01 +02:00
flecomte b60e4aa457 chore: run CI test in docker
Tests / build (push) Successful in 7m13s
Tests / test (push) Failing after 9m19s
Tests / lint (push) Successful in 13m33s
2026-08-04 20:28:49 +02:00
flecomte e262f35b27 chore: eol=lf 2026-08-01 22:13:15 +02:00
flecomte 77e4cab8ad chore: split docker-compose-tools-local 2026-07-31 22:09:46 +02:00
flecomte 7291419c9f chore: fix postgresql.secret in ci
Tests / build (push) Successful in 6m17s
Tests / test (push) Failing after 11m40s
Tests / lint (push) Successful in 14m20s
2026-07-31 21:51:27 +02:00
flecomte b2b8fcf92f refactor: fix cast warning 2026-07-31 21:36:21 +02:00
flecomte 4e4b307275 chore: fix cache for copyEnv gradle task
Tests / build (push) Successful in 9m51s
Tests / test (push) Failing after 11m0s
Tests / lint (push) Successful in 18m7s
2026-07-31 00:58:27 +02:00
flecomte e87a36caa5 chore: update CI actions versions 2026-07-31 00:24:17 +02:00
flecomte e2d7942c7e refactoring: Masive refactor to build the V2
Tests / build (push) Successful in 7m14s
Tests / lint (push) Successful in 8m35s
Tests / test (push) Failing after 11m0s
2026-07-30 23:01:50 +02:00
flecomte b313b39cf4 chore: config db in pgadmin 2026-07-30 22:42:54 +02:00
63 changed files with 1888 additions and 873 deletions
+26
View File
@@ -0,0 +1,26 @@
# 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
@@ -0,0 +1,7 @@
* text=auto
* eol=lf
*.sh text eol=lf
*.png binary
*.jar binary
gradlew.bat eol=crlf
gradlew text eol=lf
+41 -29
View File
@@ -18,10 +18,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
@@ -31,7 +31,7 @@ jobs:
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Cache Gradle dependencies
uses: actions/cache@v3
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -48,16 +48,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Restore Gradle cache
uses: actions/cache@v3
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -73,53 +73,65 @@ jobs:
run: ./gradlew ktlintCheck
- name: Publish ktlint report
uses: yutailang0119/action-ktlint@v4
uses: yutailang0119/action-ktlint@v5
if: always()
with:
report-path: build/reports/ktlint/**/*.xml
continue-on-error: false
test:
needs: build
runs-on: ubuntu-latest
env:
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Install a pinned Docker Compose version
run: |
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: Restore Gradle cache
uses: actions/cache@v3
- 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:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ needs.build.outputs.cache-key }}
path: ${{ env.GRADLE_CACHE_DIR }}
key: ${{ steps.cache-key-generator.outputs.key }}
restore-keys: |
gradle-${{ runner.os }}-
gradle-docker-${{ runner.os }}-
- name: Grant execute permission to Gradle wrapper
run: chmod +x gradlew
- name: Prepare cache directory permissions
run: |
mkdir -p "$GRADLE_CACHE_DIR"
chmod -R 777 "$GRADLE_CACHE_DIR"
- name: Start CI Docker Compose services
run: ./gradlew composeUp -Pci
- name: Run tests in Docker
run: docker compose -f docker/docker-compose-test.yaml run tests
- name: Run tests
run: ./gradlew test -x composeUp --no-daemon
- name: Shut down Docker services
if: always()
run: docker compose -f docker/docker-compose-test.yaml down -v
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: test-results
path: build/reports/tests/test
- name: Publish Test Report
uses: dorny/test-reporter@v1
uses: dorny/test-reporter@v3
if: always()
with:
name: JUnit Tests
+1
View File
@@ -37,3 +37,4 @@ out/
/docker/.env
/docker/*.secret
*.hprof
/.gradle-docker-cache/
+27
View File
@@ -0,0 +1,27 @@
<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
@@ -0,0 +1,133 @@
# 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.
+1 -27
View File
@@ -2,7 +2,6 @@ Event Demo
==========
- [Installation](./doc/installation.md)
- [What's the demo for ?](#whats-the-demo-for-)
- [What's in this demo](#whats-in-this-demo)
- [The stack](#the-stack)
- [Architecture](./doc/architecture.md)
@@ -18,31 +17,6 @@ of different patterns and architectures.
- The CQRS pattern.
- 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
---------
@@ -51,11 +25,11 @@ Language
Framework
- Ktor
- with Koin for Dependency Injection
Database
- Postgresql
- with Flyway
- Redis
- RabbitMQ
Infra
+11 -59
View File
@@ -14,7 +14,6 @@ plugins {
id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12"
}
group = "io.github.flecomte"
@@ -47,63 +46,17 @@ java {
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dockerCompose {
val composeFile =
if (project.hasProperty("ci")) {
// Use docker-compose-ci.yaml for the CI
"docker/docker-compose-ci.yaml"
} else {
// Use docker-compose-test.yaml for local tests
"docker/docker-compose-test.yaml"
}
useComposeFiles.set(listOf(composeFile))
setProjectName("event-demo-test")
}
tasks.test {
dependsOn("composeUp")
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
dockerCompose.setProjectName("event-demo-test")
}
tasks.named("run") {
dependsOn("composeUp")
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
dockerCompose.setProjectName("event-demo-dev")
}
tasks.register<Copy>("copyEnv") {
group = "docker"
description = "copy the default dotenv file"
from("docker")
into("docker")
rename {
it.removeSuffix(".template")
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")
}
include(".env.template")
eachFile {
if (File("docker/$name").exists()) {
exclude()
}
}
doLast {
val files =
listOf(
File("docker/pgadmin.secret"),
File("docker/postgresql.secret"),
)
files.forEach {
if (!it.exists()) {
it.writeText("changeit")
}
}
}
}
tasks.composeUp {
dependsOn("copyEnv")
}
dependencies {
@@ -128,7 +81,6 @@ dependencies {
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation("redis.clients:jedis:5.2.0")
implementation("org.postgresql:postgresql:42.7.13")
implementation("com.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0")
@@ -141,6 +93,6 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
testImplementation("io.mockk:mockk:1.13.17")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}
-11
View File
@@ -1,11 +0,0 @@
Architecture
============
The Workflow
------------
![Workflow Diagram](./schemas/Workflow.svg)
The business Entities
---------------------
![Entities and Projections Diagram](./schemas/Entities.svg)
-67
View File
@@ -1,67 +0,0 @@
# Exemple de structure
Les couches, du plus interne au plus externe
```
Domain (le cœur, ne dépend de RIEN d'externe)
Application (orchestre le Domain, ne connaît pas l'infra concrète)
Infrastructure (WebSocket, DB, event store — dépend de tout le reste)
```
```
src/
└── contexts/
├── auth/
└── ...
└── game/
├── domain/ ← Le cœur métier, zéro dépendance externe
│ ├── game/
│ │ ├── Game.ts ← Aggregate Root
│ │ ├── Player.ts ← Entity interne
│ │ ├── Card.ts ← Entity
│ │ ├── Color.ts ← Value Object
│ │ ├── Deck.ts ← VO ou petite structure
│ │ └── errors/
│ │ ├── InvalidMoveError.ts
│ │ └── ColorChoiceRequiredError.ts
│ └── events/ ← Events de DOMAINE (internes)
│ ├── CardPlayed.ts
│ ├── CardDrawn.ts
│ ├── TurnPassed.ts
│ └── DomainEvent.ts ← interface/type de base
├── application/ ← Orchestration, cas d'usage
│ ├── commands/ ← Les Commandes (intentions)
│ │ ├── PlayCardCommand.ts
│ │ └── DrawCardCommand.ts
│ ├── handlers/ ← Un handler par commande
│ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save
│ │ └── DrawCardHandler.ts
│ ├── projections/ ← LA LOGIQUE de construction des projections
│ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue
│ │ └── PlayerStatsProjector.kt
│ └── ports/ ← INTERFACES seulement (le "hexagone")
│ ├── GameRepository.ts ← interface, pas d'implémentation
│ ├── EventPublisher.ts ← interface, pas d'implémentation
│ └── ProjectionStore.kt ← interface, où lire/écrire la projection
├── infrastructure/ ← Tout ce qui est technique/externe
│ ├── persistence/
│ │ ├── EventStoreGameRepository.ts ← implémente GameRepository
│ │ ├── EventStore.ts
│ │ ├── projections/
│ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée)
│ │ │ └── models/
│ │ │ └── GameSummaryView.kt ← structure de la vue elle-même
│ ├── websocket/
│ │ ├── WebSocketServer.ts
│ │ ├── connectionManager.ts ← Map<gameId, Map<playerId, WebSocket>>
│ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler
│ └── eventPublisher/
│ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast
└── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer)
├── clientEvents/
│ ├── ClientEvent.ts ← types des events envoyés au front
│ └── toClientEvent.ts ← fonction de traduction domain event → client event
└── clientCommands/
└── parseIncomingCommand.ts ← valide/parse le message brut du client → Command
```
+15 -5
View File
@@ -1,23 +1,33 @@
Installation
============
To run the stack:
To run the stack in production:
```shell
docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d
```
To run only the app dependencies in development mode and run the app localy (not in docker):
```shell
docker compose -f docker\docker-compose-dev.yaml up -d
```
To run the tests in docker (it's designed for the CI):
```shell
docker compose -f docker\docker-compose-test.yaml up -d
```
Api url:
- [Backend API](http://api.traefik.me/)
- [Frontend web site](http://app.traefik.me/) (WIP)
- [Frontend web site](http://app.traefik.me/)
Exposed url on test env:
Exposed url on dev env:
- [PostgreSql](http://localhost:5432/)
- [Redis](http://localhost:6379/)
- [RabbitMQ](http://localhost:15672/)
Admin service URL:
- [Træfik dashboard](http://traefik.traefik.me/)
- [Redis insight](http://insight.redis.traefik.me/)
- [pgAdmin](http://pgadmin.postgresql.traefik.me/)
- [RabbitMQ management](http://management.rabbitmq.traefik.me/)
-93
View File
@@ -1,93 +0,0 @@
@startuml
'https://plantuml.com/class-diagram
left to right direction
class GameList <<Projection>> {
+ status: Status
}
class GameState <<Projection>> {
+ players: List<Player>
+ currentPlayerTurn: Player
+ lastCardPlayer: Player
+ colorOnCurrentStack: Color
+ direction: Direction
+ readyPlayers: List<Player>
+ deck: Deck
+ isStarted: Boolean
+ playerWins: List<Player>
}
interface Card {
+ id: UUID
}
enum Color {
+ Blue
+ Red
+ Yellow
+ Green
}
class GameId {
+ id: UUID
}
class Player {
+ id: PlayerId
+ name: String
}
class Deck {
+ stack: Stack
+ discard: Discard
+ playersHands: PlayersHands
}
class Stack {
+ cards: List<Card>
+ shuffle()
}
class Discard {
+ cards: List<Card>
}
class PlayerHands {
+ map: Map<PlayerId, List<Card>>
}
class NumericCard {
+ number: Int
+ color: Color
}
class ReverseCard {
+ color: Color
}
class PassCard {
+ color: Color
}
class Plus2Card {
+ color: Color
}
class Plus4Card
class ChangeColorCard
GameState *-- Deck
GameState o-- "many" Player
Deck *-- PlayerHands
PlayerHands *-- "many" Card
PlayerHands o-- "many" Player
Stack *-- "many" Card
Discard *-- "many" Card
Deck *-- Stack
Deck *-- Discard
GameState *-- GameId
Card <|--- NumericCard
Card <|--- ReverseCard
Card <|--- PassCard
Card <|--- ChangeColorCard
Card <|--- Plus2Card
Card <|--- Plus4Card
ReverseCard o-- Color
NumericCard o-- Color
PassCard o-- Color
Plus2Card o-- Color
GameList *-- GameId
GameList o-- "many" Player
@enduml
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 48 KiB

-84
View File
@@ -1,84 +0,0 @@
@startuml
'https://plantuml.com/use-case-diagram
package Legend {
usecase (Queries) #7693C4
usecase (Projections) #AB64C9
usecase (Events) #5FAD56
}
actor User
entity Query #7693C4
entity Command #5FAD56
entity Event #5FAD56
entity Projection #AB64C9
database Postgresql
database Redis
queue RabbitMQ
usecase (Web socket adapter) #5FAD56
usecase (Command handler) #5FAD56
usecase/ (Action) #5FAD56
usecase (Event handler) #5FAD56
usecase (Version builder) #5FAD56
usecase (Event store) #5FAD56
usecase (Event stream) #5FAD56
usecase (Event bus) #5FAD56
usecase/ (Reaction listener) #5FAD56
usecase/ (Projection builder) #AB64C9
usecase (Projection repository) #AB64C9
usecase (Projection bus) #AB64C9
usecase (Controller) #7693C4
User -> Query : <<create>>
Command <- User : <<create>>
User ---> (Controller) : Get \nprojection
User <-- (Controller) : Returns \nprojection
(Controller) --------> (Projection repository) : Get \nprojection
User -> (Web socket adapter) : Send \ncommand
(Web socket adapter) --> (Command handler) : Send \ncommand
(Web socket adapter) ...> User : Send notification \n(error or success)
(Command handler) ..> (Web socket adapter) : Send \nnotification
(Command handler) -> (Action) : Execute action
(Command handler) <- (Action) : Returns \nevent builder
(Command handler) ---> (Event handler) : Dispatch \nevent \n(send an event builder)
(Command handler) --> Event : <<Create>>
(Event handler) --> (Event store) : Publish \nevent
(Event handler) <-- (Reaction listener) : Dispatch \n new event
(Version builder) <- (Event handler) : build next version
note "Acquire a lock, \nget the next event version, \nand then, build the event " as EventHandlerNote
EventHandlerNote <-- (Event handler)
(Event store) -left-> (Event stream)
(Event store) ---> (Event bus) : Publish \nevent
(Event stream) --> Postgresql : Persist \nevent
(Event bus) -> RabbitMQ : Publish \nevent
(Event bus) -> RabbitMQ : Subscribe \nto event
(Event bus) <. RabbitMQ : Emit event
(Reaction listener) ---> (Projection bus) : Subscribe
(Reaction listener) <.. (Projection bus) : Emit projection
(Projection bus) <- (Projection repository) : Publish \nprojection
RabbitMQ <- (Projection bus) : Publish \nprojection
RabbitMQ <- (Projection bus) : Subscribe \nto projection
RabbitMQ .> (Projection bus) : Emit projection
(Event bus) <---- (Projection repository) : Subscribe
(Event bus) ..> (Projection repository) : Emit event
(Projection repository) --> Redis : Persist \nprojection
(Projection repository) <- Redis : Get \nprojection
(Projection repository) -> (Projection builder) : Build \nprojection
(Projection builder) --> Projection : <<create projection>>
@enduml
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

+2
View File
@@ -0,0 +1,2 @@
POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo
RABBITMQ_URL=rabbitmq
-1
View File
@@ -1 +0,0 @@
PGADMIN_DEFAULT_EMAIL=
+4 -4
View File
@@ -1,5 +1,5 @@
# Stage 1: Cache Gradle dependencies
FROM gradle:latest AS cache
FROM gradle:9.6.1-jdk21-alpine AS cache
RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME=/home/gradle/cache_home
COPY build.gradle.* gradle.properties /home/gradle/app/
@@ -7,7 +7,7 @@ WORKDIR /home/gradle/app
RUN gradle build -i -x check
# Stage 2: Build Application
FROM gradle:latest AS build
FROM gradle:9.6.1-jdk21-alpine AS build
COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
@@ -16,8 +16,8 @@ WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon
# Stage 3: Create the Runtime Image
FROM amazoncorretto:21 AS runtime
FROM eclipse-temurin:21-jre-alpine AS runtime
EXPOSE 8080
RUN mkdir /app
COPY --from=build /home/gradle/src/build/libs/*.jar /app/event-demo-all.jar
COPY --from=build /home/gradle/src/build/libs/*-all.jar /app/event-demo-all.jar
ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"]
+10
View File
@@ -0,0 +1,10 @@
# Image officielle Gradle avec JDK 21 déjà installé
FROM gradle:9.6.1-jdk21
WORKDIR /app
# Copie du wrapper et des fichiers de config en premier pour profiter du cache Docker
COPY build.gradle.kts settings.gradle.kts ./
# Lance les tests Kotlin
CMD ["gradle", "test", "--no-daemon"]
-6
View File
@@ -1,6 +0,0 @@
name: event-demo-test
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-traefik.yaml
+17
View File
@@ -0,0 +1,17 @@
name: event-demo-dev
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-tools-local.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
pgadmin:
environment:
PGADMIN_DEFAULT_PASSWORD: "changeit"
+13
View File
@@ -5,3 +5,16 @@ include:
- parts/docker-compose-app.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
volumes:
- ./postgresql.secret:/run/secrets/postgresql_password:ro
pgadmin:
environment:
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
volumes:
- ./pgadmin.secret:/run/secrets/pgadmin_password:ro
+7 -3
View File
@@ -2,6 +2,10 @@ name: event-demo-test
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.yaml
- parts/docker-compose-test.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
+2 -2
View File
@@ -10,8 +10,8 @@ services:
condition: service_healthy
rabbitmq:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- ../.env.docker
labels:
- "traefik.http.routers.api.rule=Host(`api.traefik.me`)"
- "traefik.http.services.api.loadbalancer.server.port=8080"
@@ -1,8 +1,4 @@
services:
redis:
ports:
- "6379:6379"
postgresql:
ports:
- "5432:5432"
@@ -1,12 +1,4 @@
services:
redis:
image: redis/redis-stack:7.4.0-v8
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
volumes:
- redis_data:/data
- redisinsight_data:/redisinsight
flyway:
image: flyway/flyway
command: migrate
@@ -22,10 +14,7 @@ services:
image: postgres:18.4
command: postgres -c 'max_connections=500'
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
POSTGRES_USER: event-demo
secrets:
- postgresql_password
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
interval: 1s
@@ -47,12 +36,6 @@ services:
volumes:
- rabbitmq_data:/var/lib/rabbitmq/
secrets:
postgresql_password:
file: ../postgresql.secret
volumes:
redis_data:
redisinsight_data:
postgresql_data:
rabbitmq_data:
+20
View File
@@ -0,0 +1,20 @@
services:
tests:
build:
context: ../..
dockerfile: docker/DockerfileTest
volumes:
- ${GRADLE_CACHE_DIR:-gradle-cache}:/home/gradle/.gradle
- ../..:/app
depends_on:
flyway:
condition: service_completed_successfully
postgresql:
condition: service_healthy
rabbitmq:
condition: service_healthy
env_file:
- ../.env.docker
volumes:
gradle-cache:
@@ -0,0 +1,18 @@
services:
pgadmin:
environment:
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
configs:
- source: pgpass
target: /pgpass
mode: 0600
uid: "5050"
gid: "5050"
- source: servers_json
target: /pgadmin4/servers.json
configs:
pgpass:
content: |
*:*:*:event-demo:changeit
+21 -13
View File
@@ -2,31 +2,39 @@ services:
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
secrets:
- pgadmin_password
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev}
volumes:
- pgadmin_data:/var/lib/pgadmin
configs:
- source: servers_json
target: /pgadmin4/servers.json
labels:
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
- "traefik.http.services.pgadmin.loadbalancer.server.port=80"
redis:
labels:
- "traefik.http.routers.redisinsight.rule=Host(`insight.redis.traefik.me`)"
- "traefik.http.routers.redisinsight.service=redisinsight"
- "traefik.http.services.redisinsight.loadbalancer.server.port=8001"
rabbitmq:
labels:
- "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)"
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
secrets:
pgadmin_password:
file: ../pgadmin.secret
configs:
servers_json:
content: |
{
"Servers": {
"1": {
"Name": "Event demo",
"Group": "Servers",
"Host": "postgresql",
"Port": 5432,
"MaintenanceDB": "event-demo",
"Username": "event-demo",
"PassFile": "/pgpass",
"SSLMode": "prefer"
}
}
}
volumes:
pgadmin_data:
+641 -37
View File
@@ -1,62 +1,666 @@
openapi: "3.0.3"
info:
title: "event_demo API"
description: "event_demo API"
version: "1.0.0"
description: |
API for the event_demo project: a small event-sourced Uno-like card game.
The API is split in two parts:
- a classic REST API used to authenticate, create users, list games and
replay a game's history.
- a WebSocket endpoint (`/games/{id}`) used to play a game in real time:
the client sends `GameCommand` messages and receives `Notification`
messages back. See the `x-websocket-channels` section below for details,
since WebSockets are not natively described by OpenAPI 3.0.
Authentication is done with a JWT bearer token obtained from `/login/{username}`.
Note that most polymorphic messages (`GameCommand`, `Notification`) are
discriminated by a `type` field whose value is the fully-qualified Kotlin
class name of the payload (since no custom serial name is declared for
these types), while `Card` uses short discriminator values
(`Simple`, `Reverse`, `Pass`, `Plus2`, `Plus4`, `ChangeColor`).
version: "2.0.0"
servers:
- url: "https://event_demo"
tags:
- name: Auth
description: User registration and authentication
- name: Games
description: Listing games and reading/playing their state
security:
- bearerAuth: []
paths:
"/game/{id}/card/last":
get:
description: get the last card played
"/login/{username}":
post:
tags: [Auth]
summary: Log in and obtain a JWT
security: []
parameters:
- name: username
in: path
required: true
schema:
type: string
- name: password
in: query
description: The user's plain-text password.
required: true
schema:
type: string
responses:
200:
description: The last card
description: Successful login
content:
application/json:
schema:
$ref: "#/components/schemas/Card"
$ref: "#/components/schemas/LoginResponse"
400:
description: Unknown username or invalid password
"/users/create":
post:
tags: [Auth]
summary: Create a new user account
description: Requires a valid JWT (any authenticated user can create new users).
parameters:
- name: username
in: query
required: true
schema:
type: string
- name: password
in: query
description: The plain-text password, hashed server-side before being stored.
required: true
schema:
type: string
responses:
200:
description: The newly created user
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserResponse"
401:
$ref: "#/components/responses/Unauthorized"
"/games":
get:
tags: [Games]
summary: List all known games
description: Returns up to the 100 most recent games (pagination is not yet exposed on this route).
responses:
200:
description: The list of games
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/GameList"
401:
$ref: "#/components/responses/Unauthorized"
"/games/{id}":
get:
tags: [Games]
summary: Replay a game's full notification history
description: |
Rebuilds every notification that would have been sent to the calling
player since the beginning of the game (from its event stream), so a
client reconnecting can catch up on the current game state.
This same path also accepts a WebSocket upgrade to play the game live,
see `x-websocket-channels` at the root of this document.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: The full list of notifications for this game, from this player's point of view
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Notification"
401:
$ref: "#/components/responses/Unauthorized"
404:
description: No game found for this id
x-websocket-channels:
"/games/{id}":
description: |
WebSocket endpoint to join and play a game in real time. Requires the
same JWT bearer authentication as the REST routes (sent the same way,
e.g. via the `Authorization` header during the WebSocket handshake).
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
send:
description: Commands sent by the client to act on the game.
schema:
$ref: "#/components/schemas/GameCommand"
receive:
description: Notifications sent by the server as the game progresses.
schema:
$ref: "#/components/schemas/Notification"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
JWT obtained from `POST /login/{username}`. It carries the `username`
and `userid` claims and currently expires 60 seconds after issuance.
responses:
Unauthorized:
description: Missing, invalid or expired JWT
content:
text/plain:
schema:
type: string
example: "Token is not valid or has expired"
schemas:
Card:
oneOf:
- $ref: "#/components/schemas/SimpleCard"
- $ref: "#/components/schemas/ReverseCard"
- $ref: "#/components/schemas/PassCard"
- $ref: "#/components/schemas/Plus2Card"
- $ref: "#/components/schemas/Plus4Card"
- $ref: "#/components/schemas/ChangeColorCard"
SimpleCard:
LoginResponse:
type: object
required: [token]
properties:
number:
token:
type: string
description: JWT bearer token to use on subsequent requests.
CreateUserResponse:
type: object
required: [id]
properties:
id:
type: string
format: uuid
HttpErrorBadRequest:
type: object
description: Generic problem-details style error body used by some validation failures.
properties:
title:
type: string
statusCode:
type: integer
color:
$ref: "#/components/schemas/CardColor"
ReverseCard:
invalidParams:
type: array
items:
$ref: "#/components/schemas/InvalidParam"
InvalidParam:
type: object
required: [name, reason]
properties:
color:
$ref: "#/components/schemas/CardColor"
PassCard:
name:
type: string
reason:
type: string
PlayerId:
type: string
format: uuid
Player:
type: object
required: [name, userId, hand, id]
properties:
color:
$ref: "#/components/schemas/CardColor"
Plus2Card:
name:
type: string
userId:
type: string
format: uuid
id:
$ref: "#/components/schemas/PlayerId"
hand:
description: The set of cards currently held by the player.
type: array
items:
$ref: "#/components/schemas/Card"
GameList:
type: object
required: [aggregateId, status, players, winners]
properties:
color:
$ref: "#/components/schemas/CardColor"
Plus4Card:
properties:
nextColor:
$ref: "#/components/schemas/CardColor"
ChangeColorCard:
properties:
nextColor:
$ref: "#/components/schemas/CardColor"
aggregateId:
type: string
format: uuid
status:
$ref: "#/components/schemas/GameStatus"
players:
type: array
items:
$ref: "#/components/schemas/Player"
winners:
type: array
items:
$ref: "#/components/schemas/PlayerId"
GameStatus:
type: string
enum:
- OPENING
- IS_STARTED
- FINISH
- CANCELED
CardColor:
type: string
enum:
- Blue
- Red
- Yellow
- Green
- Green
Card:
description: >
A playing card. Discriminated by the "type" field using the short
names declared on each Kotlin subtype (@SerialName), unlike
GameCommand/Notification below.
oneOf:
- $ref: "#/components/schemas/NumericCard"
- $ref: "#/components/schemas/ReverseCard"
- $ref: "#/components/schemas/PassCard"
- $ref: "#/components/schemas/Plus2Card"
- $ref: "#/components/schemas/Plus4Card"
- $ref: "#/components/schemas/ChangeColorCard"
discriminator:
propertyName: type
mapping:
Simple: "#/components/schemas/NumericCard"
Reverse: "#/components/schemas/ReverseCard"
Pass: "#/components/schemas/PassCard"
Plus2: "#/components/schemas/Plus2Card"
Plus4: "#/components/schemas/Plus4Card"
ChangeColor: "#/components/schemas/ChangeColorCard"
NumericCard:
description: A numbered card (0-9) of a given color.
type: object
required: [type, id, number, color]
properties:
type:
type: string
enum: [Simple]
id:
type: string
format: uuid
number:
type: integer
minimum: 0
maximum: 9
color:
$ref: "#/components/schemas/CardColor"
ReverseCard:
description: Reverses the turn order.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Reverse]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
PassCard:
description: Skips the next player's turn.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Pass]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
Plus2Card:
description: Forces the next player to draw 2 cards and skips their turn.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Plus2]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
Plus4Card:
description: >
Forces the next player to draw 4 cards and skips their turn. The new
color is chosen separately, via the `chosenColor` field of
PlayCardCommand, and is not part of the card itself.
type: object
required: [type, id]
properties:
type:
type: string
enum: [Plus4]
id:
type: string
format: uuid
ChangeColorCard:
description: >
Changes the current color. The new color is chosen separately, via
the `chosenColor` field of PlayCardCommand, and is not part of the
card itself.
type: object
required: [type, id]
properties:
type:
type: string
enum: [ChangeColor]
id:
type: string
format: uuid
GameCommand:
description: >
A command sent by the client over the game WebSocket to act on a game.
Discriminated by "type", whose value is the fully-qualified Kotlin
class name of the command (no `@SerialName` is declared on these
types).
oneOf:
- $ref: "#/components/schemas/JoinTheGameCommand"
- $ref: "#/components/schemas/PlayCardCommand"
- $ref: "#/components/schemas/ReadyToPlayCommand"
- $ref: "#/components/schemas/TakeCartFromDrawPileCommand"
discriminator:
propertyName: type
mapping:
eventDemo.contexts.game.application.command.models.JoinTheGameCommand: "#/components/schemas/JoinTheGameCommand"
eventDemo.contexts.game.application.command.models.PlayCardCommand: "#/components/schemas/PlayCardCommand"
eventDemo.contexts.game.application.command.models.ReadyToPlayCommand: "#/components/schemas/ReadyToPlayCommand"
eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand: "#/components/schemas/TakeCartFromDrawPileCommand"
JoinTheGameCommand:
description: Join an existing (not yet started) game.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.JoinTheGameCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId]
properties:
aggregateId:
type: string
format: uuid
description: The id of the game to join.
ReadyToPlayCommand:
description: Mark the calling player as ready, so the game can start once everyone is ready.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.ReadyToPlayCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
TakeCartFromDrawPileCommand:
description: Draw a card from the draw pile.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
PlayCardCommand:
description: Play a card from the calling player's hand.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.PlayCardCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId, card]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
card:
$ref: "#/components/schemas/Card"
chosenColor:
description: The color to switch to, only required when playing a Plus4Card or ChangeColorCard.
nullable: true
allOf:
- $ref: "#/components/schemas/CardColor"
Notification:
description: >
A notification sent by the server, either replayed by `GET /games/{id}`
or streamed live over the game WebSocket. Discriminated by "type",
whose value is the fully-qualified Kotlin class name of the
notification (no `@SerialName` is declared on these types).
oneOf:
- $ref: "#/components/schemas/WelcomeToTheGameNotification"
- $ref: "#/components/schemas/PlayerAsJoinTheGameNotification"
- $ref: "#/components/schemas/PlayerWasReadyNotification"
- $ref: "#/components/schemas/TheGameWasStartedNotification"
- $ref: "#/components/schemas/ItsTheTurnOfNotification"
- $ref: "#/components/schemas/PlayerAsPlayACardNotification"
- $ref: "#/components/schemas/YourNewCardNotification"
- $ref: "#/components/schemas/PlayerHavePassNotification"
- $ref: "#/components/schemas/PilesShuffledNotification"
- $ref: "#/components/schemas/PlayerWinNotification"
discriminator:
propertyName: type
mapping:
eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification: "#/components/schemas/WelcomeToTheGameNotification"
eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification: "#/components/schemas/PlayerAsJoinTheGameNotification"
eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification: "#/components/schemas/PlayerWasReadyNotification"
eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification: "#/components/schemas/TheGameWasStartedNotification"
eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification: "#/components/schemas/ItsTheTurnOfNotification"
eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification: "#/components/schemas/PlayerAsPlayACardNotification"
eventDemo.contexts.game.application.notification.models.YourNewCardNotification: "#/components/schemas/YourNewCardNotification"
eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification: "#/components/schemas/PlayerHavePassNotification"
eventDemo.contexts.game.application.notification.models.PilesShuffledNotification: "#/components/schemas/PilesShuffledNotification"
eventDemo.contexts.game.application.notification.models.PlayerWinNotification: "#/components/schemas/PlayerWinNotification"
WelcomeToTheGameNotification:
description: Sent to a player right after they join a game, listing all players currently in it.
type: object
required: [type, id, players]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification]
id:
type: string
format: uuid
players:
type: array
items:
$ref: "#/components/schemas/Player"
PlayerAsJoinTheGameNotification:
description: Sent to the other players when a new player joins the game.
type: object
required: [type, id, player]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification]
id:
type: string
format: uuid
player:
$ref: "#/components/schemas/Player"
PlayerWasReadyNotification:
description: Sent to all players when a player marks themselves as ready.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
TheGameWasStartedNotification:
description: Sent to each player when the game starts, with their initial hand.
type: object
required: [type, id, hand]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification]
id:
type: string
format: uuid
hand:
type: array
items:
$ref: "#/components/schemas/Card"
ItsTheTurnOfNotification:
description: Sent to all players to indicate whose turn it now is.
type: object
required: [type, id, player]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification]
id:
type: string
format: uuid
player:
$ref: "#/components/schemas/Player"
PlayerAsPlayACardNotification:
description: Sent to all players when a player plays a card.
type: object
required: [type, id, playerId, card]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
card:
$ref: "#/components/schemas/Card"
YourNewCardNotification:
description: Sent to a player with the cards they just drew from the draw pile.
type: object
required: [type, id, cards]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.YourNewCardNotification]
id:
type: string
format: uuid
cards:
type: array
items:
$ref: "#/components/schemas/Card"
PlayerHavePassNotification:
description: Sent to the other players when a player draws a card and passes their turn.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
PilesShuffledNotification:
description: Sent to all players when the discard pile is reshuffled into the draw pile.
type: object
required: [type, id]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PilesShuffledNotification]
id:
type: string
format: uuid
PlayerWinNotification:
description: Sent to all players when a player wins the game.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerWinNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
@@ -3,7 +3,6 @@ package eventDemo.configuration
import io.ktor.server.config.ApplicationConfig
data class Configuration(
val redisUrl: String,
val jwtSecret: String,
val postgresql: Postgresql,
val rabbitmq: RabbitMQ,
@@ -25,7 +24,6 @@ data class Configuration(
val ApplicationConfig.configuration
get() =
Configuration(
redisUrl = getProperty("redis.url"),
jwtSecret = getProperty("jwt.secret"),
postgresql =
Configuration.Postgresql(
@@ -7,8 +7,6 @@ import org.koin.core.module.Module
import org.koin.core.scope.Scope
import org.koin.core.scope.ScopeCallback
import org.koin.dsl.bind
import redis.clients.jedis.JedisPooled
import redis.clients.jedis.UnifiedJedis
import javax.sql.DataSource
fun Module.configureDIDataSource(config: Configuration) {
@@ -26,11 +24,6 @@ fun Module.configureDIDataSource(config: Configuration) {
}
} bind DataSource::class
// Redis (for Projections)
single {
JedisPooled(config.redisUrl)
} bind UnifiedJedis::class
// RabbitMQ (for EventBus)
factory {
ConnectionFactory().apply {
@@ -37,16 +37,8 @@ abstract class GameEventManager(
return this
}
protected fun <G : Game> Game.isStatusOrFail(
kClass: KClass<G>,
message: String,
): G {
if (kClass.isInstance(this)) {
return this as G
} else {
throw CommandException(message)
}
}
protected inline fun <reified G : Game> Game.isStatusOrFail(message: String): G =
this as? G ?: throw CommandException(message)
protected fun <T> retry(
mapAttempts: Int = 5,
@@ -20,7 +20,7 @@ class JoinTheGameHandler(
retry {
command
.getGame()
.isStatusOrFail(GameCreated::class, "The game is started")
.isStatusOrFail<GameCreated>("The game is started")
.userJoinTheGame(
userId = command.userId,
name = user.username,
@@ -16,7 +16,7 @@ class PlayCardHandler(
override fun handle(command: PlayCardCommand) {
command
.getGame()
.isStatusOrFail(GameStarted::class, "The game is not started")
.isStatusOrFail<GameStarted>("The game is not started")
.playTheCard(
card = command.payload.card,
playerId = command.payload.playerId,
@@ -16,7 +16,7 @@ class ReadyToPlayHandler(
override fun handle(command: ReadyToPlayCommand) {
command
.getGame()
.isStatusOrFail(GameCreated::class, "The game is started")
.isStatusOrFail<GameCreated>("The game is started")
.setReadyPlayer(command.payload.playerId)
.saveEvents()
.publishEvents()
@@ -18,7 +18,7 @@ class TakeCartFromDrawPileHandler(
override fun handle(command: TakeCartFromDrawPileCommand) {
command
.getGame()
.isStatusOrFail(GameStarted::class, "The game is not started")
.isStatusOrFail<GameStarted>("The game is not started")
.playerTakeCartFromDrawPile(command.payload.playerId, 1)
.saveEvents()
.publishEvents()
@@ -24,6 +24,10 @@ import eventDemo.contexts.game.domain.events.PlayerWinEvent
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.sharedKernel.UserId
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
private val logger = KotlinLogging.logger {}
fun GameEvent.toNotification(
game: Game,
@@ -31,15 +35,16 @@ fun GameEvent.toNotification(
): Iterable<Notification> =
Iterable {
iterator {
val currentPlayerId = game.players.get(currentUserId).id
context(iterator: SequenceScope<Notification>)
suspend fun Notification.send() {
iterator.yield(this)
withLoggingContext("notification" to (this).toString()) {
logger.info { "Notification sent" }
iterator.yield(this)
}
}
fun PlayerActionEvent.isFromCurrentUser(): Boolean =
currentPlayerId != playerId
game.players.get(currentUserId).id == playerId
when (this@toNotification) {
is GameCreatedEvent -> {
@@ -51,7 +56,7 @@ fun GameEvent.toNotification(
}
is NewPlayerEvent -> {
if (this@toNotification.isFromCurrentUser()) {
if (this@toNotification.player.userId != currentUserId) {
PlayerAsJoinTheGameNotification(
player = this@toNotification.player,
).send()
@@ -85,6 +90,7 @@ fun GameEvent.toNotification(
if (game is GameStarted) {
ItsTheTurnOfNotification(player = game.nextPlayer)
.send()
}
}
@@ -101,21 +107,20 @@ fun GameEvent.toNotification(
if (game is GameStarted) {
ItsTheTurnOfNotification(player = game.nextPlayer)
.send()
}
}
is PlayerReadyEvent -> {
if (this@toNotification.isFromCurrentUser()) {
PlayerWasReadyNotification(
playerId = this@toNotification.playerId,
)
}
PlayerWasReadyNotification(
playerId = this@toNotification.playerId,
).send()
}
is PlayerWinEvent -> {
PlayerWinNotification(
playerId = this@toNotification.playerId,
)
).send()
}
}
}
@@ -48,7 +48,7 @@ class ReactionListener(
}
private fun sendWinnerEvent(game: Game) {
if (game is GameStarted) {
if (game is GameStarted && game.lastPlayerId != null) {
val lastPlayerWin =
game
.players
@@ -4,6 +4,7 @@ import eventDemo.contexts.game.domain.game.DiscardPile
import eventDemo.contexts.game.domain.game.DrawPile
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.Player
import eventDemo.contexts.game.domain.game.PlayerHand
import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
import eventDemo.libs.eventSource.EventId
@@ -20,6 +21,7 @@ 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,
@@ -28,9 +30,3 @@ data class GameStartedEvent(
override val eventId: EventId = EventId(UUID.randomUUID())
override val createdAt: Instant = Clock.System.now()
}
private var isDisabled = false
internal fun disableShuffleDeck() {
isDisabled = true
}
@@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
value class DrawPile(
val cards: Set<Card> = emptySet(),
) {
val size: Int get() = cards.size
fun take(number: Int): Pair<DrawPile, Set<Card>> =
cards.drop(number).toDrawPile() to cards.take(number).toSet()
@@ -25,6 +25,7 @@ data class Player(
}
}
@Serializable
class PlayerList(
val players: Set<Player> = emptySet(),
) : Set<Player> by players {
@@ -39,28 +39,36 @@ sealed interface Game {
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")
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())
}
}
}
}
}
@@ -71,18 +79,3 @@ internal fun <T : GameEvent> T.checkState(
if (!block(this)) throw exception(this)
return this
}
/**
* recordedEvents versions must be ordered and incremental.
*/
internal fun Game.checkRecorderEventsConsistency() {
recordedEvents
.also { if (it.size != (it.lastOrNull()?.version ?: 0)) throw InconsistentEventVersionException(recordedEvents) }
.mapIndexed { index, event ->
(index + 1) == event.version
}.run {
if (any { !it }) {
throw InconsistentEventVersionException(recordedEvents)
}
}
}
@@ -9,6 +9,7 @@ import eventDemo.contexts.game.domain.game.DiscardPile
import eventDemo.contexts.game.domain.game.DrawPile
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.Player
import eventDemo.contexts.game.domain.game.PlayerHand
import eventDemo.contexts.game.domain.game.PlayerList
import eventDemo.contexts.game.domain.game.errors.AllPlayerNotReadyException
import eventDemo.contexts.game.domain.game.errors.DeckMissingCardsException
@@ -23,24 +24,28 @@ data class GameCreated(
override val recordedEvents: Set<GameEvent>,
override val version: Int,
) : Game {
init {
checkRecorderEventsConsistency()
}
val allPlayerIsReady: Boolean
get() {
return playersStatus.values.all { it == PlayerStatus.Ready }
return playersStatus.isNotEmpty() && playersStatus.values.all { it == PlayerStatus.Ready }
}
fun startGame(deck: Deck = newDeck().shuffleDeck()): GameStarted {
val (drawPile, discardPile) = initPiles(deck)
val (drawPile, discardPile, playersHands) =
initPiles(deck)
.let { (drawPile, discardPile) ->
createHandsFromDrawPile(drawPile)
.let { (drawPile, playersHands) ->
Triple(drawPile, discardPile, playersHands)
}
}
return GameStartedEvent(
aggregateId = aggregateId,
firstPlayer = players.random().id,
firstPlayer = players.randomPlayer().id,
version = version + 1,
drawPile = drawPile,
discardPile = discardPile,
playersHans = playersHands,
).checkState(
{ players.size > 1 },
{ NeedMorePlayersToStartGameException(players) },
@@ -50,7 +55,8 @@ data class GameCreated(
).checkState(
{ deck.size == 108 },
{ DeckMissingCardsException(players, deck) },
).run(::applyEvent)
).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> =
@@ -61,6 +67,20 @@ data class GameCreated(
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,
@@ -107,8 +127,13 @@ data class GameCreated(
internal fun applyEvent(event: GameStartedEvent): GameStarted =
GameStarted(
aggregateId = event.aggregateId,
players = players,
lastPlayerId = event.firstPlayer,
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,
@@ -133,7 +158,22 @@ fun newDeck(): Deck =
(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() =
shuffled().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
}
@@ -16,6 +16,5 @@ data class GameEnded(
if (!players.map { it.id }.containsAll(playerWins)) {
throw IllegalArgumentException("Player ${players.map { it.id }} were not in players")
}
checkRecorderEventsConsistency()
}
}
@@ -23,12 +23,39 @@ import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameExcepti
import eventDemo.contexts.game.domain.game.errors.ThePlayerMustPlayACardException
import eventDemo.contexts.game.domain.game.gameState.Game.Direction
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 lastPlayerId: Player.PlayerId?,
val nextPlayerId: Player.PlayerId,
val currentColor: Color,
val playedTurnHistory: List<History> = emptyList(),
val direction: Direction = Direction.CLOCKWISE,
@@ -40,41 +67,15 @@ data class GameStarted(
val lastPlayedCard: Card? by lazy { discardPile.topCard }
val lastPlayed: Player by lazy { players.get(lastPlayerId) }
init {
checkRecorderEventsConsistency()
}
data class History(
val playerId: Player.PlayerId,
val event: GameEvent,
val direction: Direction,
)
val lastPlayer by lazy { players.get(lastPlayerId) }
val lastPlayer: Player? by lazy { lastPlayerId?.let { players.get(it) } }
val nextPlayer: Player by lazy {
val playersLastTurn = players.filter { it.hand.cards.isNotEmpty() || it == lastPlayer }
playersLastTurn
.indexOf(lastPlayer)
.let { lastPlayerIndex ->
if (direction == Direction.CLOCKWISE) {
if (lastPlayerIndex == playersLastTurn.size - 1) {
0
} else {
lastPlayerIndex + 1
}
} else {
if (lastPlayerIndex == 0) {
playersLastTurn.size - 1
} else {
lastPlayerIndex - 1
}
}
}.let { nextPlayerIndex -> players.elementAt(nextPlayerIndex) }
}
val nextPlayer: Player by lazy { players.get(nextPlayerId) }
fun canBePlayThisCard(card: Card): Boolean {
val cardOnBoard = discardPile.topCard ?: return false
@@ -144,8 +145,12 @@ data class GameStarted(
): GameStarted =
CardIsPlayedEvent(aggregateId, card, playerId, chosenColor, version + 1)
.checkPlayerTurn()
.checkState({ card is Card.CardWithColor && chosenColor != null }, { TheCardIsAColorCardException(playerId) })
.checkState({ card is Card.CardWith4Color && chosenColor == null }, { TheCardHasNoColorException(playerId) })
.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 =
@@ -167,6 +172,7 @@ data class GameStarted(
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,
@@ -202,6 +208,8 @@ data class 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,
)
@@ -16,7 +16,7 @@ fun Route.gameWebSocket(channelSubscriber: GameChannelsSubscriber) {
authenticate {
webSocket("/games/{id}") {
channelSubscriber.subscribePlayerToGameChannels(
gameId = GameId(UUID.nameUUIDFromBytes(call.parameters["id"]?.encodeToByteArray()!!)),
gameId = GameId(UUID.fromString(call.parameters["id"]!!)),
userId = call.currentUserId,
incomingCommandChannel = toObjectChannel(incoming),
sendNotificationChannel = fromFrameChannel(outgoing),
@@ -78,7 +78,11 @@ class BusInRabbitMQ<E>(
body: ByteArray,
) {
runBlocking {
block(stringToObject(body.toString(Charsets.UTF_8)))
val obj = stringToObject(body.toString(Charsets.UTF_8))
withLoggingContext("item" to obj.toString()) {
logger.info { "Received delivery of $exchangeName" }
}
block(obj)
}
channel.basicAck(envelope.deliveryTag, false)
}
-5
View File
@@ -12,11 +12,6 @@ jwt {
secret = ${?JWT_SECRET}
}
redis {
url = "redis://localhost:6379"
url = ${?REDIS_URL}
}
postgresql {
url = "jdbc:postgresql://localhost:5432/event-demo"
url = ${?POSTGRESQL_URL}
-2
View File
@@ -7,7 +7,5 @@ object Tag {
object RabbitMQ : Tag()
object Redis : Tag()
object Concurrence : Tag()
}
@@ -6,12 +6,18 @@ 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).
* Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters),
* pour chaque bounded context sous `eventDemo.contexts`.
*
* Convention attendue :
* eventDemo.contexts.uno.domain
* eventDemo.contexts.uno.application
* eventDemo.contexts.uno.infrastructure
* 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)
@@ -19,24 +25,42 @@ import org.junit.jupiter.api.Test
* infrastructure → ne dépend que de domain et application
*/
class HexagonalArchitectureTest {
private val basePackage = "eventDemo.contexts.uno"
private val rootPackage = "eventDemo.contexts"
private val classes =
ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(basePackage)
.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`() {
@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)
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,6 +1,8 @@
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.command.models.GameCommand
import eventDemo.contexts.game.application.eventStores.GameRepository
@@ -11,11 +13,11 @@ import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACard
import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification
import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification
import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification
import eventDemo.contexts.game.domain.events.disableShuffleDeck
import eventDemo.contexts.game.domain.game.Card
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.contexts.game.domain.game.gameState.disableRandomForTest
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.createGameWithCommandsInChannels
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.joinTheGame
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.playCard
@@ -24,8 +26,14 @@ 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
@@ -43,177 +51,236 @@ class GameSimulationTest :
tags(Tag.Postgresql)
test("Simulation of a game") {
withTimeout(10.seconds) {
disableShuffleDeck()
val gameId = GameId()
val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
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)
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 playedCard1: Card? = null
var playedCard2: Card? = null
var player1HasJoin = false
var player1HasJoin = false
testKoinApplicationWithConfig {
val gameRepository = get<GameRepository>()
testKoinApplicationWithConfig {
val gameRepository = get<GameRepository>()
val userRepository = get<UserRepository>()
userRepository.run {
save(user1)
save(user2)
}
gameRepository.create(gameId)
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,
)
// 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)
}
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) { getPlayer ->
user1.joinTheGame()
player1Notifications.waitNotification<WelcomeToTheGameNotification> {
players.map { it.userId }.contains(user1.id)
}
player1HasJoin = true
player1Notifications.waitNotification<PlayerAsJoinTheGameNotification> {
player.userId == user2.id
}
getPlayer(user1).readyToPlay()
player1Notifications.waitNotification<PlayerWasReadyNotification> {
playerId == getPlayer(user2).id
}
val player1Hand =
player1Notifications.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }.hand
playedCard1 = player1Hand.first()
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player.userId == user1.id
}
getPlayer(user1).playCard(playedCard1!!, null)
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
player1Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user2).id && card == playedCard2
}
playedCard1 = player1Hand.elementAt(1)
getPlayer(user1).playCard(playedCard1)
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
}
}
// Player 2 actions
val player2Job =
launch {
createGameWithCommandsInChannels(channelCommand2) { getPlayer ->
// wait player 1 has joined the game
until(3.seconds) { player1HasJoin }
user2.joinTheGame()
player2Notifications.waitNotification<WelcomeToTheGameNotification> {
players.map { it.userId }.contains(user1.id) &&
players.map { it.userId }.contains(user2.id)
}
player2Notifications.waitNotification<PlayerWasReadyNotification> { playerId == getPlayer(user1).id }
getPlayer(user2).readyToPlay()
val player2Hand =
player2Notifications.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }.hand
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player.userId == user1.id
}
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user1).id && card == playedCard1
}
playedCard2 = player2Hand.first()
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
getPlayer(user2).playCard(playedCard2)
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player.userId == user1.id
}
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user2).id && card == playedCard2
}
}
}
// Wait the end of the game
joinAll(player1Job, player2Job)
// Build the last state from the event store
val game = gameRepository.get(gameId)
assertInstanceOf<GameStarted>(game)
// Check if the state is correct
game.aggregateId shouldBeEqual gameId
game.players.map { it.id } shouldBeEqual setOf(user1.id, user2.id)
assertNotNull(game.players.find { it.userId == user1.id })
.hand.size shouldBeEqual 5
assertNotNull(game.players.find { it.userId == user2.id })
.hand.size shouldBeEqual 6
game.direction shouldBeEqual Game.Direction.CLOCKWISE
assertNotNull(game.lastPlayed.userId) shouldBeEqual user1
assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1)
}
}
}
})
private suspend inline fun <reified T : Notification> MutableList<Notification>.waitNotification(crossinline block: T.() -> Boolean): T =
eventually(3.seconds) {
filterIsInstance<T>().first { block(it) }
}
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,6 +1,7 @@
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
@@ -14,6 +15,8 @@ import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameN
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory
import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInMemory
import eventDemo.sharedKernel.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
@@ -21,6 +24,7 @@ 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({
@@ -46,8 +50,12 @@ class EventToNotificationSubscriberTest :
val game = gameRepository.create()
val user1 = UserId()
val user2 = UserId()
val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
userRepository.run {
save(user1)
save(user2)
}
val player1Notifications = mutableListOf<Notification>()
GlobalScope.launch {
@@ -56,20 +64,22 @@ class EventToNotificationSubscriberTest :
}
}
commentDispatcher.dispatch(JoinTheGameCommand(user1, JoinTheGameCommand.Payload(game.aggregateId)))
commentDispatcher.dispatch(JoinTheGameCommand(user1.id, JoinTheGameCommand.Payload(game.aggregateId)))
subscriber
.subscribeToEventsAndSendNotification(
game.aggregateId,
user1,
user2.id,
notificationChannel,
).use {
commentDispatcher.dispatch(JoinTheGameCommand(user2, JoinTheGameCommand.Payload(game.aggregateId)))
commentDispatcher.dispatch(JoinTheGameCommand(user2.id, JoinTheGameCommand.Payload(game.aggregateId)))
}
player1Notifications.size shouldEqual 2
eventually(duration = 1.seconds) {
player1Notifications.size shouldEqual 1
}
player1Notifications.first().let { notification ->
assertInstanceOf<WelcomeToTheGameNotification>(notification)
notification.players.map { it.userId } shouldContain user1
notification.players.map { it.userId } shouldContain user2.id
}
}
})
@@ -0,0 +1,301 @@
package eventDemo.contexts.game.application.notification
import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification
import eventDemo.contexts.game.application.notification.models.PilesShuffledNotification
import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification
import eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification
import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification
import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification
import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification
import eventDemo.contexts.game.application.notification.models.YourNewCardNotification
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
import eventDemo.contexts.game.domain.events.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.Card
import eventDemo.contexts.game.domain.game.DiscardPile
import eventDemo.contexts.game.domain.game.DrawPile
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.Player
import eventDemo.contexts.game.domain.game.PlayerHand
import eventDemo.contexts.game.domain.game.PlayerList
import eventDemo.contexts.game.domain.game.gameState.GameCreated
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.sharedKernel.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
}
}
}
})
@@ -11,6 +11,7 @@ import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.Player
import eventDemo.contexts.game.domain.game.PlayerHand
import eventDemo.contexts.game.domain.game.PlayerList
import eventDemo.contexts.game.domain.game.gameState.Game.Direction
import eventDemo.sharedKernel.UserId
import eventDemo.testHelpers.act
import eventDemo.testHelpers.assert
@@ -195,10 +196,12 @@ class GameStartedTest :
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(
@@ -244,17 +247,14 @@ class GameStartedTest :
val card3 = Card.NumericCard(3, Card.Color.Red)
val card4 = Card.NumericCard(4, Card.Color.Red)
val player1 =
Player(
"Jo",
UserId(),
hand = PlayerHand(cards = setOf()),
)
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(
@@ -303,6 +303,7 @@ class GameStartedTest :
aggregateId = GameId(),
players = PlayerList(setOf(player1, player2)),
lastPlayerId = player1.id,
nextPlayerId = player2.id,
drawPile = DrawPile(),
discardPile = DiscardPile(),
currentColor = Card.Color.Yellow,
@@ -373,10 +374,12 @@ private fun gameWithCard(
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 = PlayerList(setOf(player1, player2, player3)),
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"),
@@ -0,0 +1,41 @@
package eventDemo.contexts.game.domain.game.gameState
import eventDemo.contexts.game.domain.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,21 +0,0 @@
package eventDemo.contexts.game.intrastructure.persistence.connectors
import eventDemo.Tag
import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import redis.clients.jedis.UnifiedJedis
class RedisTest :
FunSpec({
tags(Tag.Redis)
test("test connection with jedis") {
testKoinApplicationWithConfig {
get<UnifiedJedis>().also {
it.set("test", "test")
it.get("test") shouldBeEqual "test"
}
}
}
})
@@ -117,8 +117,8 @@ class GameListRouteTest :
call.body<List<GameList>>().first().let {
it.status shouldBeEqual GameList.Status.IS_STARTED
it.players shouldHaveSize 2
it.players.map { it.userId } shouldContain user1
it.players.map { it.userId } shouldContain user2
it.players.map { it.userId } shouldContain user1.id
it.players.map { it.userId } shouldContain user2.id
it.winners shouldHaveSize 0
}
}
+21 -25
View File
@@ -2,6 +2,7 @@ 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
@@ -15,33 +16,28 @@ private data class ObjTest(
class BusTest :
FunSpec({
context("Pub/sub") {
val factory =
ConnectionFactory().apply {
host = "localhost"
port = 5672
username = "event-demo"
password = "changeit"
}
val list: Map<String, Bus<ObjTest>> =
mapOf(
BusInMemory::class.java.simpleName to BusInMemory(),
BusInRabbitMQ::class.java.simpleName to
BusInRabbitMQ(
factory,
"testExchange",
{ it.value },
{ ObjTest(it) },
),
)
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"
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()}"))
}
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
}
}
}
@@ -33,16 +33,17 @@ class EventStreamTest :
block(aggregateId)
}
fun Koin.eventStreams(): List<EventStream<EventXTest, IdTest>> =
listOf(
EventStreamInMemory(IdTest()),
EventStreamInPostgresql(
IdTest(),
dataSource = get(),
objectToString = { Json.encodeToString(it) },
stringToObject = { Json.decodeFromString(it) },
"game.game_event_stream",
),
fun Koin.eventStreams(): Map<String, EventStream<EventXTest, IdTest>> =
mapOf(
EventStreamInMemory::class.simpleName.toString() to EventStreamInMemory(IdTest()),
EventStreamInPostgresql::class.simpleName.toString() to
EventStreamInPostgresql(
IdTest(),
dataSource = get(),
objectToString = { Json.encodeToString(it) },
stringToObject = { Json.decodeFromString(it) },
"game.game_event_stream",
),
)
context("readVersionBetween should only return the event of aggregate") {
@@ -9,51 +9,70 @@ import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.domain.game.Card
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.Player
import eventDemo.contexts.game.domain.game.gameState.Game
import kotlinx.coroutines.channels.Channel
import org.koin.core.Koin
import java.util.UUID
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>,
gameName: String = "testGame${UUID.randomUUID()}",
block: suspend context(Channel<GameCommand>, GameId) ((User) -> Player) -> T,
gameId: GameId,
user: User,
block:
suspend context(
CreateGameWithCommandsInChannelsHelpers,
Channel<GameCommand>,
User,
) Data.() -> T,
): T {
val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray()))
val repo = koin.get<GameRepository>()
repo.getOrCreate(gameId)
return with(gameId) {
with(channelCommand) {
return with(channelCommand) {
with(user) {
with(CreateGameWithCommandsInChannelsHelpers) {
block { user -> repo.get(gameId)!!.players.get(user.id) }
Data(repo, gameId, user).block()
}
}
}
}
context(channelCommand: Channel<GameCommand>, gameId: GameId)
suspend fun User.joinTheGame(): JoinTheGameCommand =
context(channelCommand: Channel<GameCommand>, data: Data)
suspend fun joinTheGame(): JoinTheGameCommand =
JoinTheGameCommand(
id,
JoinTheGameCommand.Payload(gameId),
data.currentUser.id,
JoinTheGameCommand.Payload(data.gameId),
).also { channelCommand.send(it) }
context(channelCommand: Channel<GameCommand>, gameId: GameId)
suspend fun Player.readyToPlay(): ReadyToPlayCommand =
context(channelCommand: Channel<GameCommand>, data: Data)
suspend fun readyToPlay(): ReadyToPlayCommand =
ReadyToPlayCommand(
userId,
ReadyToPlayCommand.Payload(gameId, id),
data.currentUser.id,
ReadyToPlayCommand.Payload(data.gameId, data.currentPlayer.id),
).also { channelCommand.send(it) }
context(channelCommand: Channel<GameCommand>, gameId: GameId)
suspend fun Player.playCard(
context(channelCommand: Channel<GameCommand>, data: Data)
suspend fun playCard(
card: Card,
chosenColor: Card.Color? = null,
): PlayCardCommand =
PlayCardCommand(
userId,
PlayCardCommand.Payload(gameId, id, card, chosenColor),
data.currentUser.id,
PlayCardCommand.Payload(data.gameId, data.currentPlayer.id, card, chosenColor),
).also { channelCommand.send(it) }
}
@@ -15,10 +15,13 @@ import org.koin.core.module.KoinApplicationDslMarker
import org.koin.dsl.koinApplication
import org.koin.ktor.ext.getKoin
const val CONFIG_FILE_NAME = "application.conf"
@KoinApplicationDslMarker
suspend fun <T> testKoinApplicationWithConfig(block: suspend Koin.() -> T): T =
koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration)) }
.koin
koinApplication {
modules(appKoinModule(ApplicationConfig(CONFIG_FILE_NAME).configuration))
}.koin
.run {
cleanDataTest()
configureProjectionListener()
@@ -34,7 +37,7 @@ fun testApplicationWithConfig(
) {
val logger = KotlinLogging.logger {}
testApplication {
val conf = ApplicationConfig("application.conf")
val conf = ApplicationConfig(CONFIG_FILE_NAME)
environment {
config = conf
}
@@ -1,7 +1,6 @@
package eventDemo.testHelpers
import org.koin.core.Koin
import redis.clients.jedis.UnifiedJedis
import javax.sql.DataSource
fun DataSource.cleanEventSource() {
@@ -17,11 +16,6 @@ fun DataSource.cleanEventSource() {
}
}
fun UnifiedJedis.cleanProjections() {
flushAll()
}
fun Koin.cleanDataTest() {
get<DataSource>().cleanEventSource()
get<UnifiedJedis>().cleanProjections()
}