Fix: close all connections after each tests

This commit is contained in:
2025-04-10 22:44:26 +02:00
parent aa9dac74e8
commit 0aa13b9299
14 changed files with 307 additions and 226 deletions

View File

@@ -48,6 +48,12 @@ private fun DefaultWebSocketServerSession.runWebSocket(
val currentPlayer = call.getPlayer() val currentPlayer = call.getPlayer()
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing) val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
withLoggingContext("currentPlayer" to currentPlayer.toString()) { withLoggingContext("currentPlayer" to currentPlayer.toString()) {
val notificationListener =
playerNotificationListener.startListening(
currentPlayer,
gameId,
) { outgoingFrameChannel.trySendBlocking(it) }
// TODO change GlobalScope // TODO change GlobalScope
GlobalScope.launch { GlobalScope.launch {
commandHandler.handle( commandHandler.handle(
@@ -56,12 +62,8 @@ private fun DefaultWebSocketServerSession.runWebSocket(
toObjectChannel(incoming), toObjectChannel(incoming),
outgoingFrameChannel, outgoingFrameChannel,
) )
notificationListener.close()
} }
playerNotificationListener.startListening(
currentPlayer,
gameId,
) { outgoingFrameChannel.trySendBlocking(it) }
} }
} }

View File

@@ -35,8 +35,8 @@ class PlayerNotificationListener(
currentPlayer: Player, currentPlayer: Player,
gameId: GameId, gameId: GameId,
outgoingNotification: (Notification) -> Unit, outgoingNotification: (Notification) -> Unit,
) { ): AutoCloseable {
projectionBus.subscribe { currentState -> return projectionBus.subscribe { currentState ->
if (currentState !is GameState) return@subscribe if (currentState !is GameState) return@subscribe
if (currentState.aggregateId != gameId) return@subscribe if (currentState.aggregateId != gameId) return@subscribe
withLoggingContext("projection" to currentState.toString()) { withLoggingContext("projection" to currentState.toString()) {

View File

@@ -16,10 +16,13 @@ import eventDemo.business.event.projection.gameState.GameStateRepository
import eventDemo.libs.event.projection.SnapshotConfig import eventDemo.libs.event.projection.SnapshotConfig
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.scope.Scope
import org.koin.core.scope.ScopeCallback
import org.koin.dsl.bind import org.koin.dsl.bind
import javax.sql.DataSource import javax.sql.DataSource
fun Module.configureDIInfrastructure(config: Configuration) { fun Module.configureDIInfrastructure(config: Configuration) {
// Postgresql config
single { single {
HikariConfig() HikariConfig()
.apply { .apply {
@@ -30,18 +33,27 @@ fun Module.configureDIInfrastructure(config: Configuration) {
minimumIdle = 10 minimumIdle = 10
}.let { }.let {
HikariDataSource(it) HikariDataSource(it)
}.also { datasource ->
registerCallback(
object : ScopeCallback {
override fun onScopeClose(scope: Scope) {
datasource.close()
}
},
)
} }
} bind DataSource::class } bind DataSource::class
single { // RabbitMQ config
factory {
ConnectionFactory().apply { ConnectionFactory().apply {
host = config.rabbitmq.url host = config.rabbitmq.url
port = config.rabbitmq.port port = config.rabbitmq.port
virtualHost = virtualHost
username = config.rabbitmq.username username = config.rabbitmq.username
password = config.rabbitmq.password password = config.rabbitmq.password
} }
} }
singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
singleOf(::GameProjectionBusInMemory) bind GameProjectionBus::class singleOf(::GameProjectionBusInMemory) bind GameProjectionBus::class

View File

@@ -6,5 +6,9 @@ interface Bus<T> {
/** /**
* @param priority The higher the priority, the more it will be called first * @param priority The higher the priority, the more it will be called first
*/ */
fun subscribe(block: suspend (T) -> Unit) fun subscribe(block: suspend (T) -> Unit): Subscription
interface Subscription : AutoCloseable {
override fun close()
}
} }

View File

@@ -23,7 +23,12 @@ class BusInMemory<E>(
} }
} }
override fun subscribe(block: suspend (E) -> Unit) { override fun subscribe(block: suspend (E) -> Unit): Bus.Subscription {
subscribers.add(block) subscribers.add(block)
return object : Bus.Subscription {
override fun close() {
subscribers.remove(block)
}
}
} }
} }

View File

@@ -1,60 +1,90 @@
package eventDemo.libs.bus package eventDemo.libs.bus
import com.rabbitmq.client.CancelCallback import com.rabbitmq.client.AMQP
import com.rabbitmq.client.BuiltinExchangeType
import com.rabbitmq.client.Connection
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import com.rabbitmq.client.DeliverCallback import com.rabbitmq.client.DefaultConsumer
import com.rabbitmq.client.Delivery import com.rabbitmq.client.Envelope
import io.ktor.utils.io.core.toByteArray import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class BusInRabbitMQ<E>( class BusInRabbitMQ<E>(
private val connectionFactory: ConnectionFactory, private val connectionFactory: ConnectionFactory,
private val queueName: String, private val exchangeName: String,
private val objectToString: (E) -> String, private val objectToString: (E) -> String,
private val stringToObject: (String) -> E, private val stringToObject: (String) -> E,
) : Bus<E> { ) : Bus<E> {
private val connection: Connection = connectionFactory.newConnection()
get() {
return if (field.isOpen) {
field
} else {
connectionFactory.newConnection()
}
}
private val routingKey = ""
init { init {
connectionFactory connection
.newConnection()
.createChannel() .createChannel()
.use { .use {
it.queueDeclare( it.exchangeDeclare(
queueName, exchangeName,
BuiltinExchangeType.FANOUT,
true, true,
false, false,
false,
emptyMap(), emptyMap(),
) )
} }
} }
override suspend fun publish(item: E) { override suspend fun publish(item: E) {
connectionFactory connection
.newConnection()
.createChannel() .createChannel()
.use { .use {
it.basicPublish( it.basicPublish(
"", exchangeName,
queueName, routingKey,
null, AMQP.BasicProperties(),
objectToString(item).toByteArray(), objectToString(item).toByteArray(),
) )
} }
} }
override fun subscribe(block: suspend (E) -> Unit) { override fun subscribe(block: suspend (E) -> Unit): Bus.Subscription {
connectionFactory connection
.newConnection()
.createChannel() .createChannel()
.also { channel ->
val queue =
channel
.queueDeclare()
.queue
.also { channel.queueBind(it, exchangeName, routingKey) }
channel
.basicConsume( .basicConsume(
queueName, queue,
true, object : DefaultConsumer(channel) {
DeliverCallback { _: String, message: Delivery -> override fun handleDelivery(
consumerTag: String,
envelope: Envelope,
properties: AMQP.BasicProperties,
body: ByteArray,
) {
runBlocking { runBlocking {
block(stringToObject(message.body.toString(Charsets.UTF_8))) block(stringToObject(body.toString(Charsets.UTF_8)))
}
channel.basicAck(envelope.deliveryTag, false)
} }
}, },
CancelCallback {},
) )
}.let {
return object : Bus.Subscription {
override fun close() {
it.close()
}
}
}
} }
} }

View File

@@ -1,5 +1,6 @@
package eventDemo package eventDemo
import com.zaxxer.hikari.HikariDataSource
import eventDemo.business.entity.Card import eventDemo.business.entity.Card
import eventDemo.business.entity.Deck import eventDemo.business.entity.Deck
import eventDemo.configuration.business.configureGameListener import eventDemo.configuration.business.configureGameListener
@@ -26,10 +27,12 @@ fun Deck.allCards(): Set<Card> =
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 { modules(appKoinModule(ApplicationConfig("application.conf").configuration())) }
.koin .koin
.apply { .run {
cleanDataTest() cleanDataTest()
configureGameListener() configureGameListener()
}.block() block()
.apply { get<HikariDataSource>().close() }
}
@KtorDsl @KtorDsl
fun testApplicationWithConfig( fun testApplicationWithConfig(
@@ -53,13 +56,13 @@ fun testApplicationWithConfig(
} }
fun DataSource.cleanEventSource() { fun DataSource.cleanEventSource() {
this.connection this.connection.use {
it
.prepareStatement( .prepareStatement(
""" """
truncate event_stream; truncate event_stream;
""".trimIndent(), """.trimIndent(),
).use { ).execute()
it.execute()
} }
} }

View File

@@ -24,6 +24,7 @@ import eventDemo.business.notification.WelcomeToTheGameNotification
import eventDemo.libs.event.projection.ProjectionSnapshotRepositoryInMemory import eventDemo.libs.event.projection.ProjectionSnapshotRepositoryInMemory
import eventDemo.testKoinApplicationWithConfig import eventDemo.testKoinApplicationWithConfig
import io.kotest.assertions.nondeterministic.until import io.kotest.assertions.nondeterministic.until
import io.kotest.core.NamedTag
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
@@ -43,6 +44,8 @@ import kotlin.time.Duration.Companion.seconds
@DelicateCoroutinesApi @DelicateCoroutinesApi
class GameSimulationTest : class GameSimulationTest :
FunSpec({ FunSpec({
tags(NamedTag("postgresql"))
test("Simulation of a game") { test("Simulation of a game") {
withTimeout(2.seconds) { withTimeout(2.seconds) {
disableShuffleDeck() disableShuffleDeck()
@@ -59,6 +62,7 @@ class GameSimulationTest :
var player1HasJoin = false var player1HasJoin = false
testKoinApplicationWithConfig {
val player1Job = val player1Job =
launch { launch {
IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player1)).also { sendCommand -> IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player1)).also { sendCommand ->
@@ -172,7 +176,6 @@ class GameSimulationTest :
} }
} }
testKoinApplicationWithConfig {
val commandHandler by inject<GameCommandHandler>() val commandHandler by inject<GameCommandHandler>()
val eventStore by inject<GameEventStore>() val eventStore by inject<GameEventStore>()
val playerNotificationListener by inject<PlayerNotificationListener>() val playerNotificationListener by inject<PlayerNotificationListener>()

View File

@@ -9,6 +9,7 @@ import eventDemo.business.notification.CommandSuccessNotification
import eventDemo.business.notification.Notification import eventDemo.business.notification.Notification
import eventDemo.business.notification.WelcomeToTheGameNotification import eventDemo.business.notification.WelcomeToTheGameNotification
import eventDemo.testKoinApplicationWithConfig import eventDemo.testKoinApplicationWithConfig
import io.kotest.core.NamedTag
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
@@ -24,6 +25,8 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class GameCommandHandlerTest : class GameCommandHandlerTest :
FunSpec({ FunSpec({
tags(NamedTag("postgresql"))
test("handle a command should execute the command") { test("handle a command should execute the command") {
withTimeout(5.seconds) { withTimeout(5.seconds) {
testKoinApplicationWithConfig { testKoinApplicationWithConfig {

View File

@@ -9,6 +9,7 @@ import eventDemo.business.event.projection.gameState.GameStateRepository
import eventDemo.testKoinApplicationWithConfig import eventDemo.testKoinApplicationWithConfig
import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.nondeterministic.eventuallyConfig import io.kotest.assertions.nondeterministic.eventuallyConfig
import io.kotest.core.NamedTag
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
@@ -23,6 +24,8 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class GameStateRepositoryTest : class GameStateRepositoryTest :
FunSpec({ FunSpec({
tags(NamedTag("postgresql"))
val player1 = Player("Tesla") val player1 = Player("Tesla")
val player2 = Player(name = "Einstein") val player2 = Player(name = "Einstein")

View File

@@ -1,4 +1,4 @@
package eventDemo.adapter.infrastructureLayer package eventDemo.externalServices
import eventDemo.testKoinApplicationWithConfig import eventDemo.testKoinApplicationWithConfig
import io.kotest.core.NamedTag import io.kotest.core.NamedTag

View File

@@ -1,4 +1,4 @@
package eventDemo.adapter.infrastructureLayer package eventDemo.externalServices
import io.kotest.core.NamedTag import io.kotest.core.NamedTag
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec

View File

@@ -1,10 +1,13 @@
package eventDemo.libs.bus package eventDemo.libs.bus
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import io.kotest.assertions.nondeterministic.until import io.kotest.assertions.nondeterministic.eventually
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.equals.shouldBeEqual import io.kotest.matchers.string.shouldStartWith
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -19,7 +22,6 @@ class BusTest :
ConnectionFactory().apply { ConnectionFactory().apply {
host = "localhost" host = "localhost"
port = 5672 port = 5672
virtualHost = virtualHost
username = "event-demo" username = "event-demo"
password = "changeit" password = "changeit"
} }
@@ -29,24 +31,24 @@ class BusTest :
BusInRabbitMQ::class.java.simpleName to BusInRabbitMQ::class.java.simpleName to
BusInRabbitMQ( BusInRabbitMQ(
factory, factory,
"testQueue", "testExchange",
{ it.value }, { it.value },
{ ObjTest(it) }, { ObjTest(it) },
), ),
) )
withData(list) { bus -> withData(list) { bus ->
val value = "hello${Random.nextInt()}" val spy = spyk(mockk<() -> Unit>())
var isCalled = false
bus.subscribe { obj -> bus.subscribe { obj ->
isCalled = true spy()
obj.value shouldBeEqual value obj.value shouldStartWith "testMessage"
} }
bus.publish(ObjTest(value)) bus.publish(ObjTest("testMessage${Random.nextInt()}"))
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
until(3.seconds) { eventually(1.seconds) {
isCalled shouldBeEqual true verify(exactly = 2) { spy() }
} }
} }
} }

View File

@@ -1,6 +1,7 @@
package eventDemo.libs.event package eventDemo.libs.event
import eventDemo.testKoinApplicationWithConfig import eventDemo.testKoinApplicationWithConfig
import io.kotest.core.NamedTag
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.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
@@ -12,11 +13,14 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.jupiter.api.assertNull import org.junit.jupiter.api.assertNull
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.koin.core.Koin
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@DelicateCoroutinesApi @DelicateCoroutinesApi
class EventStreamTest : class EventStreamTest :
FunSpec({ FunSpec({
tags(NamedTag("postgresql"))
fun EventStream<EventXTest, IdTest>.with3Events(block: EventStream<EventXTest, IdTest>.(id: IdTest) -> Unit) = fun EventStream<EventXTest, IdTest>.with3Events(block: EventStream<EventXTest, IdTest>.(id: IdTest) -> Unit) =
also { also {
publish(EventXTest(aggregateId = aggregateId, version = 1, num = 1)) publish(EventXTest(aggregateId = aggregateId, version = 1, num = 1))
@@ -25,8 +29,7 @@ class EventStreamTest :
block(aggregateId) block(aggregateId)
} }
suspend fun eventStreams(): List<EventStream<EventXTest, IdTest>> = fun Koin.eventStreams(): List<EventStream<EventXTest, IdTest>> =
testKoinApplicationWithConfig {
listOf( listOf(
EventStreamInMemory(IdTest()), EventStreamInMemory(IdTest()),
EventStreamInPostgresql( EventStreamInPostgresql(
@@ -36,9 +39,9 @@ class EventStreamTest :
stringToObject = { Json.decodeFromString(it) }, stringToObject = { Json.decodeFromString(it) },
), ),
) )
}
context("readVersionBetween should only return the event of aggregate") { context("readVersionBetween should only return the event of aggregate") {
testKoinApplicationWithConfig {
withData(eventStreams()) { stream -> withData(eventStreams()) { stream ->
stream.with3Events { stream.with3Events {
readVersionBetween(1..2) shouldHaveSize 2 readVersionBetween(1..2) shouldHaveSize 2
@@ -48,8 +51,10 @@ class EventStreamTest :
} }
} }
} }
}
context("readAll should only return the event of aggregate") { context("readAll should only return the event of aggregate") {
testKoinApplicationWithConfig {
withData(eventStreams()) { stream -> withData(eventStreams()) { stream ->
stream.with3Events { stream.with3Events {
readAll() shouldHaveSize 3 readAll() shouldHaveSize 3
@@ -61,8 +66,10 @@ class EventStreamTest :
} }
} }
} }
}
context("getByVersion should only return the event with this version") { context("getByVersion should only return the event with this version") {
testKoinApplicationWithConfig {
withData(eventStreams()) { stream -> withData(eventStreams()) { stream ->
stream.with3Events { stream.with3Events {
assertNotNull(getByVersion(1)).version shouldBeEqual 1 assertNotNull(getByVersion(1)).version shouldBeEqual 1
@@ -72,8 +79,10 @@ class EventStreamTest :
} }
} }
} }
}
context("readGreaterOfVersion should only return the events with greater version") { context("readGreaterOfVersion should only return the events with greater version") {
testKoinApplicationWithConfig {
withData(eventStreams()) { withData(eventStreams()) {
it.with3Events { it.with3Events {
assertNotNull(readGreaterOfVersion(1)) shouldHaveSize 2 assertNotNull(readGreaterOfVersion(1)) shouldHaveSize 2
@@ -83,14 +92,18 @@ class EventStreamTest :
} }
} }
} }
}
context("publish should be throw error when publish another aggregate event") { context("publish should be throw error when publish another aggregate event") {
testKoinApplicationWithConfig {
withData(eventStreams()) { withData(eventStreams()) {
assertThrows<EventStreamPublishException> { it.publish(EventXTest(aggregateId = IdTest(), version = 1, num = 1)) } assertThrows<EventStreamPublishException> { it.publish(EventXTest(aggregateId = IdTest(), version = 1, num = 1)) }
} }
} }
}
context("publish should be concurrently secure") { context("publish should be concurrently secure") {
testKoinApplicationWithConfig {
withData(eventStreams()) { stream -> withData(eventStreams()) { stream ->
(0..9) (0..9)
.map { i1 -> .map { i1 ->
@@ -109,4 +122,5 @@ class EventStreamTest :
stream.readAll() shouldHaveSize 100 stream.readAll() shouldHaveSize 100
} }
} }
}
}) })