5 Commits
Author SHA1 Message Date
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
22 changed files with 410 additions and 349 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
+30 -18
View File
@@ -80,36 +80,48 @@ jobs:
continue-on-error: false continue-on-error: false
test: test:
needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up JDK 21 - name: Install a pinned Docker Compose version
uses: actions/setup-java@v5 run: |
with: mkdir -p ~/.docker/cli-plugins
distribution: 'temurin' curl -fSL https://github.com/docker/compose/releases/download/v5.1.4/docker-compose-linux-x86_64 \
java-version: '21' -o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose
docker compose version
- name: Restore Gradle cache - 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 uses: actions/cache@v6
with: with:
path: | path: ${{ env.GRADLE_CACHE_DIR }}
~/.gradle/caches key: ${{ steps.cache-key-generator.outputs.key }}
~/.gradle/wrapper
key: ${{ needs.build.outputs.cache-key }}
restore-keys: | restore-keys: |
gradle-${{ runner.os }}- gradle-docker-${{ runner.os }}-
- name: Grant execute permission to Gradle wrapper - name: Prepare cache directory permissions
run: chmod +x gradlew run: |
mkdir -p "$GRADLE_CACHE_DIR"
chmod -R 777 "$GRADLE_CACHE_DIR"
- name: Start CI Docker Compose services - name: Run tests in Docker
run: ./gradlew ciComposeUp -Pci run: docker compose -f docker/docker-compose-test.yaml run tests
- name: Run tests - name: Shut down Docker services
run: ./gradlew test -x ciComposeUp -Pci --no-daemon if: always()
run: docker compose -f docker/docker-compose-test.yaml down -v
- name: Upload test reports - name: Upload test reports
if: always() if: always()
+1
View File
@@ -37,3 +37,4 @@ out/
/docker/.env /docker/.env
/docker/*.secret /docker/*.secret
*.hprof *.hprof
/.gradle-docker-cache/
+11 -71
View File
@@ -1,4 +1,3 @@
import com.avast.gradle.dockercompose.ComposeExtension
import org.jlleitschuh.gradle.ktlint.KtlintExtension import org.jlleitschuh.gradle.ktlint.KtlintExtension
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version") val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
@@ -15,7 +14,6 @@ plugins {
id("io.ktor.plugin") version "3.5.1" id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0" id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12"
} }
group = "io.github.flecomte" group = "io.github.flecomte"
@@ -48,74 +46,16 @@ java {
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnitPlatform() useJUnitPlatform()
} jvmArgs("-Djdk.attach.allowAttachSelf=true", "-XX:+EnableDynamicAgentLoading")
// Dynamic self-attach (used by MockK/ByteBuddy) times out in Docker containers because the
configure<ComposeExtension> { // SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
createNested("test").apply { // agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
useComposeFiles.set(listOf("docker/docker-compose-test.yaml")) // pre-installed Instrumentation instance and skips dynamic attach.
setProjectName("event-demo-test") doFirst {
} val agentJar =
createNested("dev").apply { classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
useComposeFiles.set(listOf("docker/docker-compose-dev.yaml")) ?: error("byte-buddy-agent jar not found on test classpath")
setProjectName("event-demo-dev") jvmArgs("-javaagent:$agentJar")
}
createNested("ci").apply {
useComposeFiles.set(listOf("docker/docker-compose-ci.yaml"))
setProjectName("event-demo-ci")
}
}
tasks.test {
if (project.hasProperty("ci")) {
dependsOn("ciComposeUp")
} else {
dependsOn("testComposeUp")
}
}
tasks.named("run") {
dependsOn("devComposeUp")
}
tasks.composeUp {
dependsOn("copyEnv")
}
tasks.named("devComposeUp") {
dependsOn("copyEnv")
}
tasks.named("testComposeUp") {
dependsOn("copyEnv")
}
tasks.named("ciComposeUp") {
dependsOn("copyEnv")
}
tasks.register<Copy>("copyEnv") {
group = "docker"
description = "copy the default dotenv file"
from("docker")
into("docker")
rename {
it.removeSuffix(".template")
}
include(".env.template")
eachFile {
if (File("docker/$name").exists()) {
exclude()
}
}
val files =
buildList {
add(File("docker/postgresql.secret"))
if (!project.hasProperty("ci")) {
add(File("docker/pgadmin.secret"))
}
}
outputs.files(*files.toTypedArray())
doLast {
files.forEach {
if (!it.exists()) {
it.writeText("changeit")
}
}
} }
} }
@@ -154,6 +94,6 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}") testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.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") testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
} }
+13 -1
View File
@@ -1,12 +1,24 @@
Installation Installation
============ ============
To run the stack: To run the stack in production:
```shell ```shell
docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d 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: Api url:
- [Backend API](http://api.traefik.me/) - [Backend API](http://api.traefik.me/)
- [Frontend web site](http://app.traefik.me/) (WIP) - [Frontend web site](http://app.traefik.me/) (WIP)
+3
View File
@@ -0,0 +1,3 @@
REDIS_URL=redis://redis:6379
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 # 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 RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME=/home/gradle/cache_home ENV GRADLE_USER_HOME=/home/gradle/cache_home
COPY build.gradle.* gradle.properties /home/gradle/app/ COPY build.gradle.* gradle.properties /home/gradle/app/
@@ -7,7 +7,7 @@ WORKDIR /home/gradle/app
RUN gradle build -i -x check RUN gradle build -i -x check
# Stage 2: Build Application # 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 --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/src COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src WORKDIR /home/gradle/src
@@ -16,8 +16,8 @@ WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon RUN gradle buildFatJar --no-daemon
# Stage 3: Create the Runtime Image # Stage 3: Create the Runtime Image
FROM amazoncorretto:21 AS runtime FROM eclipse-temurin:21-jre-alpine AS runtime
EXPOSE 8080 EXPOSE 8080
RUN mkdir /app 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"] 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"]
-5
View File
@@ -1,5 +0,0 @@
name: event-demo-ci
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
+15 -2
View File
@@ -1,4 +1,17 @@
name: event-demo-test name: event-demo-dev
include: include:
- path: - path:
- docker-compose-test.yaml - 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-app.yaml
- parts/docker-compose-tools.yaml - parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.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: include:
- path: - path:
- parts/docker-compose-databases.yaml - parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml - parts/docker-compose-test.yaml
- parts/docker-compose-tools.yaml - parts/docker-compose-traefik.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
+2
View File
@@ -12,6 +12,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
env_file:
- ../.env.docker
labels: labels:
- "traefik.http.routers.api.rule=Host(`api.traefik.me`)" - "traefik.http.routers.api.rule=Host(`api.traefik.me`)"
- "traefik.http.services.api.loadbalancer.server.port=8080" - "traefik.http.services.api.loadbalancer.server.port=8080"
@@ -22,7 +22,6 @@ services:
image: postgres:18.4 image: postgres:18.4
command: postgres -c 'max_connections=500' command: postgres -c 'max_connections=500'
environment: environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
POSTGRES_USER: event-demo POSTGRES_USER: event-demo
healthcheck: healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"] test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
@@ -31,7 +30,6 @@ services:
retries: 10 retries: 10
volumes: volumes:
- postgresql_data:/var/lib/postgresql - postgresql_data:/var/lib/postgresql
- ../postgresql.secret:/run/secrets/postgresql_password:ro
rabbitmq: rabbitmq:
image: rabbitmq:4-management-alpine image: rabbitmq:4-management-alpine
+22
View File
@@ -0,0 +1,22 @@
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
redis:
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
+1 -18
View File
@@ -2,20 +2,10 @@ services:
pgadmin: pgadmin:
image: dpage/pgadmin4 image: dpage/pgadmin4
environment: environment:
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev}
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
secrets:
- pgadmin_password
volumes: volumes:
- pgadmin_data:/var/lib/pgadmin - pgadmin_data:/var/lib/pgadmin
configs: configs:
- source: pgpass
target: /pgpass
mode: 0600
uid: "5050"
gid: "5050"
- source: servers_json - source: servers_json
target: /pgadmin4/servers.json target: /pgadmin4/servers.json
labels: labels:
@@ -35,9 +25,6 @@ services:
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672" - "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
configs: configs:
pgpass:
content: |
*:*:*:event-demo:changeit
servers_json: servers_json:
content: | content: |
{ {
@@ -55,9 +42,5 @@ configs:
} }
} }
secrets:
pgadmin_password:
file: ../pgadmin.secret
volumes: volumes:
pgadmin_data: pgadmin_data:
@@ -26,11 +26,13 @@ import eventDemo.testHelpers.createNewUser
import eventDemo.testHelpers.testKoinApplicationWithConfig import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.nondeterministic.until import io.kotest.assertions.nondeterministic.until
import io.kotest.assertions.retry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.equals.shouldEqual import io.kotest.matchers.equals.shouldEqual
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.should
import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -49,223 +51,225 @@ class GameSimulationTest :
tags(Tag.Postgresql) tags(Tag.Postgresql)
test("Simulation of a game") { test("Simulation of a game") {
withTimeout(10.seconds) { should {
disableRandomForTest() retry(maxRetry = 3, timeout = 20.seconds) {
val gameId = GameId() disableRandomForTest()
val user1 = createNewUser("user1") val gameId = GameId()
val user2 = createNewUser("user2") val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED) val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED)
val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED) val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification1 = Channel<Notification>(Channel.BUFFERED) val channelNotification1 = Channel<Notification>(Channel.BUFFERED)
val channelNotification2 = Channel<Notification>(Channel.BUFFERED) val channelNotification2 = Channel<Notification>(Channel.BUFFERED)
var playedCard1: Card? = null var playedCard1: Card? = null
var playedCard2: Card? = null var playedCard2: Card? = null
var player1HasJoin = false var player1HasJoin = false
testKoinApplicationWithConfig { testKoinApplicationWithConfig {
val gameRepository = get<GameRepository>() val gameRepository = get<GameRepository>()
val userRepository = get<UserRepository>() val userRepository = get<UserRepository>()
userRepository.run { userRepository.run {
save(user1) save(user1)
save(user2) save(user2)
} }
gameRepository.create(gameId) gameRepository.create(gameId)
// Run command/notification subscriber // Run command/notification subscriber
// In the normal process, these subscriber is invoque on players connect to the websocket // In the normal process, these subscriber is invoque on players connect to the websocket
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
get<GameChannelsSubscriber>().subscribePlayerToGameChannels( get<GameChannelsSubscriber>().subscribePlayerToGameChannels(
gameId, gameId,
user1.id, user1.id,
channelCommand1, channelCommand1,
channelNotification1, channelNotification1,
) )
} }
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
get<GameChannelsSubscriber>().subscribePlayerToGameChannels( get<GameChannelsSubscriber>().subscribePlayerToGameChannels(
gameId, gameId,
user2.id, user2.id,
channelCommand2, channelCommand2,
channelNotification2, channelNotification2,
) )
} }
// Consume etch notification of players, and put theses in a list. // Consume etch notification of players, and put theses in a list.
// Is used later to control when other players can execute the next action // Is used later to control when other players can execute the next action
val player1Notifications = mutableListOf<Notification>() val player1Notifications = mutableListOf<Notification>()
val player2Notifications = mutableListOf<Notification>() val player2Notifications = mutableListOf<Notification>()
run { run {
GlobalScope.launch { GlobalScope.launch {
for (notification in channelNotification1) { for (notification in channelNotification1) {
player1Notifications.add(notification) player1Notifications.add(notification)
}
}
GlobalScope.launch {
for (notification in channelNotification2) {
player2Notifications.add(notification)
}
} }
} }
GlobalScope.launch { // Player 1 actions
for (notification in channelNotification2) { val player1Job =
player2Notifications.add(notification) launch {
} createGameWithCommandsInChannels(channelCommand1, gameId, user1) {
}
}
// Player 1 actions joinTheGame()
val player1Job = player1Notifications.waitNotification<WelcomeToTheGameNotification> {
launch { players.map { it.userId }.contains(user1.id)
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!!) player1HasJoin = true
player1Notifications.waitNotification<ItsTheTurnOfNotification> { player1Notifications.waitNotification<PlayerAsJoinTheGameNotification> {
player == getPlayer(user2) player.userId == user2.id
}
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) readyToPlay()
player1Notifications.waitNotification<PlayerWasReadyNotification> {
playerId == getPlayer(user2).id
}
player2Notifications.waitNotification<ItsTheTurnOfNotification> { playedCard1 =
player.userId == user1.id player1Notifications
} .waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
player2Notifications.waitNotification<PlayerAsPlayACardNotification> { .hand
playerId == currentPlayer.id && card == playedCard2 .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)
}
} }
} }
}
// Wait the end of the game // Player 2 actions
joinAll(player1Job, player2Job) val player2Job =
launch {
createGameWithCommandsInChannels(channelCommand2, gameId, user2) {
// wait player 1 has joined the game
until(3.seconds) { player1HasJoin }
// Build the last state from the event store joinTheGame()
val game = gameRepository.get(gameId)
assertInstanceOf<GameStarted>(game)
// Check if the state is correct player2Notifications.waitNotification<WelcomeToTheGameNotification> {
game.aggregateId shouldBeEqual gameId players.map { it.userId }.contains(user1.id) &&
game.players.map { it.userId } shouldContainExactly setOf(user1.id, user2.id) players.map { it.userId }.contains(user2.id)
assertNotNull(game.players.find { it.userId == user1.id }) }
.hand.size shouldBeEqual 5 player2Notifications.waitNotification<PlayerWasReadyNotification> { playerId == getPlayer(user1).id }
assertNotNull(game.players.find { it.userId == user2.id })
.hand.size shouldBeEqual 6 readyToPlay()
game.direction shouldBeEqual Game.Direction.CLOCKWISE
assertNotNull(game.lastPlayer?.userId) shouldBeEqual user1.id playedCard2 =
assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1) 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)
}
} }
} }
} }
@@ -274,7 +278,7 @@ class GameSimulationTest :
context(user: User) context(user: User)
private suspend inline fun <reified T : Notification> MutableList<Notification>.waitNotification(crossinline block: T.() -> Boolean): T { 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}") println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}")
return eventually(3.seconds) { return eventually(5.seconds) {
filterIsInstance<T>() filterIsInstance<T>()
.first { block(it) } .first { block(it) }
.also { remove(it) } .also { remove(it) }
+21 -25
View File
@@ -2,6 +2,7 @@ package eventDemo.libs.bus
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import eventDemo.testHelpers.spyPing import eventDemo.testHelpers.spyPing
import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData import io.kotest.datatest.withData
import io.kotest.matchers.string.shouldStartWith import io.kotest.matchers.string.shouldStartWith
@@ -15,33 +16,28 @@ private data class ObjTest(
class BusTest : class BusTest :
FunSpec({ FunSpec({
context("Pub/sub") { context("Pub/sub") {
val factory = testKoinApplicationWithConfig {
ConnectionFactory().apply { val list: Map<String, Bus<ObjTest>> =
host = "localhost" mapOf(
port = 5672 BusInMemory::class.java.simpleName to BusInMemory(),
username = "event-demo" BusInRabbitMQ::class.java.simpleName to
password = "changeit" BusInRabbitMQ(
} get<ConnectionFactory>(),
val list: Map<String, Bus<ObjTest>> = "testExchange",
mapOf( { it.value },
BusInMemory::class.java.simpleName to BusInMemory(), { ObjTest(it) },
BusInRabbitMQ::class.java.simpleName to ),
BusInRabbitMQ( )
factory,
"testExchange",
{ it.value },
{ ObjTest(it) },
),
)
withData(list) { bus -> withData(list) { bus ->
spyPing(exactly = 2, duration = 1.seconds) { ping -> spyPing(exactly = 2, duration = 1.seconds) { ping ->
bus.subscribe { obj -> bus.subscribe { obj ->
ping() ping()
obj.value shouldStartWith "testMessage" 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()}"))
} }
} }
} }
@@ -15,10 +15,13 @@ import org.koin.core.module.KoinApplicationDslMarker
import org.koin.dsl.koinApplication import org.koin.dsl.koinApplication
import org.koin.ktor.ext.getKoin import org.koin.ktor.ext.getKoin
const val CONFIG_FILE_NAME = "application.conf"
@KoinApplicationDslMarker @KoinApplicationDslMarker
suspend fun <T> testKoinApplicationWithConfig(block: suspend Koin.() -> T): T = suspend fun <T> testKoinApplicationWithConfig(block: suspend Koin.() -> T): T =
koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration)) } koinApplication {
.koin modules(appKoinModule(ApplicationConfig(CONFIG_FILE_NAME).configuration))
}.koin
.run { .run {
cleanDataTest() cleanDataTest()
configureProjectionListener() configureProjectionListener()
@@ -34,7 +37,7 @@ fun testApplicationWithConfig(
) { ) {
val logger = KotlinLogging.logger {} val logger = KotlinLogging.logger {}
testApplication { testApplication {
val conf = ApplicationConfig("application.conf") val conf = ApplicationConfig(CONFIG_FILE_NAME)
environment { environment {
config = conf config = conf
} }