CommandStreamChannel block the duplicate call

add GameCommandHandlerTest
Create a CommandStreamChannelBuilder to inject maxCacheTime
Add missing empty disabled test
fix EventStreamInMemory.readAll
This commit is contained in:
2025-03-11 21:40:16 +01:00
parent 0fbea7903a
commit c84aa7e0c1
22 changed files with 282 additions and 90 deletions

View File

@@ -11,6 +11,7 @@ import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.projection.GameStateRepository import eventDemo.app.event.projection.GameStateRepository
import eventDemo.app.notification.ErrorNotification import eventDemo.app.notification.ErrorNotification
import eventDemo.app.notification.Notification import eventDemo.app.notification.Notification
import eventDemo.libs.command.CommandStreamChannelBuilder
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
@@ -23,6 +24,7 @@ import kotlinx.coroutines.channels.SendChannel
class GameCommandHandler( class GameCommandHandler(
private val eventHandler: GameEventHandler, private val eventHandler: GameEventHandler,
private val gameStateRepository: GameStateRepository, private val gameStateRepository: GameStateRepository,
private val commandStreamChannel: CommandStreamChannelBuilder<GameCommand>,
) { ) {
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
@@ -33,27 +35,43 @@ class GameCommandHandler(
player: Player, player: Player,
incomingCommandChannel: ReceiveChannel<GameCommand>, incomingCommandChannel: ReceiveChannel<GameCommand>,
outgoingErrorChannelNotification: SendChannel<Notification>, outgoingErrorChannelNotification: SendChannel<Notification>,
) = GameCommandStream(incomingCommandChannel).process { command -> ) = commandStreamChannel(incomingCommandChannel)
if (command.payload.player.id != player.id) { .process { command ->
nack() if (command.payload.player.id != player.id) {
} logger.atWarn {
message = "Handle command Refuse, the player of the command is not the same: $command"
val playerErrorNotifier: suspend (String) -> Unit = { payload = mapOf("command" to command)
val notification = ErrorNotification(message = it) }
logger.atWarn { nack()
message = "Notification send ERROR: ${notification.message}" } else {
payload = mapOf("notification" to notification) logger.atInfo {
message = "Handle command: $command"
payload = mapOf("command" to command)
}
command.run(outgoingErrorChannelNotification)
} }
outgoingErrorChannelNotification.send(notification)
} }
val gameState = gameStateRepository.get(command.payload.gameId) private suspend fun GameCommand.run(outgoingErrorChannelNotification: SendChannel<Notification>) {
val gameState = gameStateRepository.get(payload.gameId)
val playerErrorNotifier = errorNotifier(outgoingErrorChannelNotification)
when (command) { when (this) {
is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventHandler) is IWantToPlayCardCommand -> run(gameState, playerErrorNotifier, eventHandler)
is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler) is IamReadyToPlayCommand -> run(gameState, playerErrorNotifier, eventHandler)
is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventHandler) is IWantToJoinTheGameCommand -> run(gameState, playerErrorNotifier, eventHandler)
is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler) is ICantPlayCommand -> run(gameState, playerErrorNotifier, eventHandler)
} }
} }
} }
fun errorNotifier(channel: SendChannel<Notification>): suspend (String) -> Unit =
{
val logger = KotlinLogging.logger { }
val notification = ErrorNotification(message = it)
logger.atWarn {
message = "Notification send ERROR: ${notification.message}"
payload = mapOf("notification" to notification)
}
channel.send(notification)
}

View File

@@ -2,6 +2,7 @@ package eventDemo.app.command
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.app.notification.Notification
import eventDemo.libs.fromFrameChannel import eventDemo.libs.fromFrameChannel
import eventDemo.libs.toObjectChannel import eventDemo.libs.toObjectChannel
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
@@ -12,6 +13,7 @@ import io.ktor.server.routing.Route
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@DelicateCoroutinesApi @DelicateCoroutinesApi
@@ -22,14 +24,15 @@ fun Route.gameSocket(
authenticate { authenticate {
webSocket("/game") { webSocket("/game") {
val currentPlayer = call.getPlayer() val currentPlayer = call.getPlayer()
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
GlobalScope.launch { GlobalScope.launch {
commandHandler.handle( commandHandler.handle(
currentPlayer, currentPlayer,
toObjectChannel(incoming), toObjectChannel(incoming),
fromFrameChannel(outgoing), outgoingFrameChannel,
) )
} }
playerNotificationListener.startListening(outgoing, currentPlayer) playerNotificationListener.startListening(outgoingFrameChannel, currentPlayer)
} }
} }
} }

View File

@@ -12,10 +12,10 @@ class GameStateRepository(
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
private val maxSnapshotCacheSize: Int = 20, private val maxSnapshotCacheSize: Int = 20,
) { ) {
private val projections: MutableMap<GameId, GameState> = ConcurrentHashMap() private val projections: ConcurrentHashMap<GameId, GameState> = ConcurrentHashMap()
private val version: AtomicInteger = AtomicInteger(0) private val version: AtomicInteger = AtomicInteger(0)
private val projectionsSnapshot: MutableMap<GameEvent, GameState> = ConcurrentHashMap() private val projectionsSnapshot: ConcurrentHashMap<GameEvent, GameState> = ConcurrentHashMap()
private val sortedSnapshotByVersion: MutableMap<GameEvent, Int> = ConcurrentHashMap() private val sortedSnapshotByVersion: ConcurrentHashMap<GameEvent, Int> = ConcurrentHashMap()
init { init {
eventHandler.registerProjectionBuilder { event -> eventHandler.registerProjectionBuilder { event ->
@@ -68,8 +68,9 @@ class GameStateRepository(
* It fetches it from the local cache if possible, otherwise it builds it. * It fetches it from the local cache if possible, otherwise it builds it.
*/ */
fun get(gameId: GameId): GameState = fun get(gameId: GameId): GameState =
projections[gameId] projections.computeIfAbsent(gameId) {
?: gameId.buildStateFromEventStream(eventStream) gameId.buildStateFromEventStream(eventStream)
}
/** /**
* Get the [GameState] to the specific [event][GameEvent]. * Get the [GameState] to the specific [event][GameEvent].
@@ -78,8 +79,9 @@ class GameStateRepository(
* It fetches it from the local cache if possible, otherwise it builds it. * It fetches it from the local cache if possible, otherwise it builds it.
*/ */
fun getUntil(event: GameEvent): GameState = fun getUntil(event: GameEvent): GameState =
projectionsSnapshot[event] projectionsSnapshot.computeIfAbsent(event) {
?: event.buildStateFromEventStreamTo(eventStream) event.buildStateFromEventStreamTo(eventStream)
}
private fun GameState.update() { private fun GameState.update() {
projections[gameId] = this projections[gameId] = this

View File

@@ -23,9 +23,7 @@ import eventDemo.app.notification.PlayerWinNotification
import eventDemo.app.notification.TheGameWasStartedNotification import eventDemo.app.notification.TheGameWasStartedNotification
import eventDemo.app.notification.WelcomeToTheGameNotification import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.app.notification.YourNewCardNotification import eventDemo.app.notification.YourNewCardNotification
import eventDemo.shared.toFrame
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
@@ -36,7 +34,7 @@ class GameEventPlayerNotificationListener(
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
fun startListening( fun startListening(
outgoingNotificationChannel: SendChannel<Frame>, outgoingNotificationChannel: SendChannel<Notification>,
currentPlayer: Player, currentPlayer: Player,
) { ) {
eventBus.subscribe { event: GameEvent -> eventBus.subscribe { event: GameEvent ->
@@ -45,7 +43,7 @@ class GameEventPlayerNotificationListener(
fun Notification.send() { fun Notification.send() {
if (currentState.players.contains(currentPlayer)) { if (currentState.players.contains(currentPlayer)) {
// Only notify players who have already joined the game. // Only notify players who have already joined the game.
outgoingNotificationChannel.trySendBlocking(toFrame()) outgoingNotificationChannel.trySendBlocking(this)
logger.atInfo { logger.atInfo {
message = "Notification for player ${currentPlayer.name} was SEND: ${this@send}" message = "Notification for player ${currentPlayer.name} was SEND: ${this@send}"
payload = mapOf("notification" to this@send, "event" to event) payload = mapOf("notification" to this@send, "event" to event)

View File

@@ -1,11 +1,13 @@
package eventDemo.configuration package eventDemo.configuration
import eventDemo.app.command.GameCommandHandler import eventDemo.app.command.GameCommandHandler
import eventDemo.app.command.command.GameCommand
import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventHandler import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.projection.GameStateRepository import eventDemo.app.event.projection.GameStateRepository
import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.libs.command.CommandStreamChannelBuilder
import eventDemo.libs.event.EventBusInMemory import eventDemo.libs.event.EventBusInMemory
import eventDemo.libs.event.EventStreamInMemory import eventDemo.libs.event.EventStreamInMemory
import io.ktor.server.application.Application import io.ktor.server.application.Application
@@ -33,6 +35,9 @@ val appKoinModule =
single { single {
GameStateRepository(get(), get()) GameStateRepository(get(), get())
} }
single {
CommandStreamChannelBuilder<GameCommand>()
}
singleOf(::GameEventHandler) singleOf(::GameEventHandler)
singleOf(::GameCommandHandler) singleOf(::GameCommandHandler)

View File

@@ -2,18 +2,48 @@ package eventDemo.libs.command
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
class CommandStreamChannelBuilder<C : Command>(
private val maxCacheTime: Duration = 10.minutes,
) {
operator fun invoke(incoming: ReceiveChannel<C>): CommandStreamChannel<C> = CommandStreamChannel(incoming, maxCacheTime)
}
/** /**
* Manage [Command]'s with kotlin Channel * Manage [Command]'s with kotlin Channel
*/ */
class CommandStreamChannel<C : Command>( class CommandStreamChannel<C : Command>(
private val incoming: ReceiveChannel<C>, private val incoming: ReceiveChannel<C>,
private val maxCacheTime: Duration = 10.minutes,
) : CommandStream<C> { ) : CommandStream<C> {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val executedCommand: ConcurrentHashMap<CommandId, Pair<Boolean, Instant>> = ConcurrentHashMap()
override suspend fun process(action: CommandBlock<C>) { override suspend fun process(action: CommandBlock<C>) {
for (command in incoming) { for (command in incoming) {
compute(command, action) val now = Clock.System.now()
val (status, _) = executedCommand.computeIfAbsent(command.id) { Pair(false, now) }
if (status) {
logger.atWarn {
message = "Command already executed: $command"
payload = mapOf("command" to command)
}
} else {
compute(command, action)
}
executedCommand
.filterValues { (_, date) ->
(date + maxCacheTime) > now
}.keys
.forEach {
executedCommand.remove(it)
}
} }
} }

View File

@@ -36,7 +36,10 @@ class EventStreamInMemory<E : Event<ID>, ID : AggregateId> : EventStream<E, ID>
.filterIsInstance(eventType.java) .filterIsInstance(eventType.java)
.lastOrNull { it.gameId == aggregateId } .lastOrNull { it.gameId == aggregateId }
override fun readAll(aggregateId: ID): Set<E> = events.toSet() override fun readAll(aggregateId: ID): Set<E> =
events
.filter { it.gameId == aggregateId }
.toSet()
} }
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStream<E, ID>.readLastOf(aggregateId: ID): R? = inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStream<E, ID>.readLastOf(aggregateId: ID): R? =

View File

@@ -1,24 +0,0 @@
package eventDemo.shared
import eventDemo.app.command.command.GameCommand
import eventDemo.app.event.event.GameEvent
import eventDemo.app.notification.Notification
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.serialization.json.Json
fun Frame.Text.toEvent(): GameEvent = Json.decodeFromString(GameEvent.serializer(), readText())
fun GameEvent.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameEvent.serializer(), this))
fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText())
fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this))
fun Frame.toNotification(): Notification =
Json.decodeFromString(
Notification.serializer(),
(this as Frame.Text).readText(),
)
fun Notification.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(Notification.serializer(), this))

View File

@@ -0,0 +1,43 @@
package eventDemo.app.command
import eventDemo.app.command.command.GameCommand
import eventDemo.app.command.command.IWantToJoinTheGameCommand
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.app.eventListener.GameEventReactionListener
import eventDemo.app.notification.Notification
import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.configuration.appKoinModule
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.koin.dsl.koinApplication
import kotlin.test.assertIs
class GameCommandHandlerTest :
FunSpec({
test("handle a command should execute the command") {
koinApplication { modules(appKoinModule) }.koin.apply {
val commandHandler by inject<GameCommandHandler>()
val notificationListener by inject<GameEventPlayerNotificationListener>()
val gameId = GameId()
val player = Player("Tesla")
val channelCommand = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification = Channel<Notification>(Channel.BUFFERED)
GameEventReactionListener(get(), get(), get()).init()
notificationListener.startListening(channelNotification, player)
GlobalScope.launch {
commandHandler.handle(player, channelCommand, channelNotification)
}
channelCommand.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)))
assertIs<WelcomeToTheGameNotification>(channelNotification.receive()).let {
it.players shouldContain player
}
}
}
})

View File

@@ -0,0 +1,9 @@
package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec
class ICantPlayCommandTest :
FunSpec({
xtest("run should publish the event") { }
})

View File

@@ -0,0 +1,9 @@
package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec
class IWantToJoinTheGameCommandTest :
FunSpec({
xtest("run should publish the event") { }
})

View File

@@ -0,0 +1,9 @@
package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec
class IWantToPlayCardCommandTest :
FunSpec({
xtest("run should publish the event") { }
})

View File

@@ -0,0 +1,9 @@
package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec
class IamReadyToPlayCommandTest :
FunSpec({
xtest("run should publish the event") { }
})

View File

@@ -24,7 +24,7 @@ class DeckTest :
deck.allCards().map { it.id }.shouldBeUnique() deck.allCards().map { it.id }.shouldBeUnique()
} }
test("initHands") { test("initHands should be generate the hands of all players from the stack") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
@@ -41,7 +41,7 @@ class DeckTest :
initDeck.allCardCount() shouldBeExactly totalCardsNumber initDeck.allCardCount() shouldBeExactly totalCardsNumber
} }
test("takeOneCardFromStackTo") { test("takeOneCardFromStackTo player") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
@@ -89,7 +89,6 @@ class DeckTest :
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val deck = Deck.newWithoutPlayers().initHands(players) val deck = Deck.newWithoutPlayers().initHands(players)
val firstPlayer = players.first()
// When // When
val modifiedDeck = deck.placeFirstCardOnDiscard() val modifiedDeck = deck.placeFirstCardOnDiscard()

View File

@@ -0,0 +1,15 @@
package eventDemo.app.entity
import io.kotest.core.spec.style.FunSpec
class PlayersHandsTest :
FunSpec({
xtest("getHand should return the hand of the player") { }
xtest("removeCard should remove the card") { }
xtest("addCard should add the card to the correct hand") { }
xtest("toPlayersHands should build object from map") { }
})

View File

@@ -0,0 +1,10 @@
package eventDemo.app.event
import io.kotest.core.spec.style.FunSpec
class GameEventHandlerTest :
FunSpec({
xtest("handle event should publish the event to the stream") { }
xtest("handle event should build the registered projection") { }
xtest("handle event should publish the event to the bus") { }
})

View File

@@ -0,0 +1,16 @@
package eventDemo.app.event.projection
import io.kotest.core.spec.style.FunSpec
class GameStateRepositoryTest :
FunSpec({
xtest("GameStateRepository should build the projection when a new event occurs") { }
xtest("get should build the last version of the state") { }
xtest("get should be concurrently secure") { }
xtest("get should be concurrently secure") { }
xtest("getUntil should build the state until the event") { }
xtest("call getUntil twice should get the state from the cache") { }
xtest("getUntil should be concurrently secure") { }
})

View File

@@ -0,0 +1,15 @@
package eventDemo.app.event.projection
import io.kotest.core.spec.style.FunSpec
class GameStateTest :
FunSpec({
xtest("isReady") { }
xtest("nextPlayer") { }
xtest("nextPlayerTurn") { }
xtest("playerDiffIndex") { }
xtest("cardOnBoardIsForYou") { }
xtest("playableCards") { }
xtest("playerHasNoCardLeft") { }
xtest("canBePlayThisCard") { }
})

View File

@@ -50,7 +50,7 @@ class GameStateRouteTest :
}.apply { }.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val state = call.body<GameState>() val state = call.body<GameState>()
assertEquals(id, state.gameId) id shouldBeEqual state.gameId
state.players shouldHaveSize 0 state.players shouldHaveSize 0
state.isStarted shouldBeEqual false state.isStarted shouldBeEqual false
} }

View File

@@ -1,6 +1,7 @@
package eventDemo.app.query package eventDemo.app.query
import eventDemo.app.command.GameCommandHandler import eventDemo.app.command.GameCommandHandler
import eventDemo.app.command.command.GameCommand
import eventDemo.app.command.command.IWantToJoinTheGameCommand import eventDemo.app.command.command.IWantToJoinTheGameCommand
import eventDemo.app.command.command.IWantToPlayCardCommand import eventDemo.app.command.command.IWantToPlayCardCommand
import eventDemo.app.command.command.IamReadyToPlayCommand import eventDemo.app.command.command.IamReadyToPlayCommand
@@ -14,20 +15,16 @@ import eventDemo.app.event.projection.buildStateFromEventStream
import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.app.eventListener.GameEventReactionListener import eventDemo.app.eventListener.GameEventReactionListener
import eventDemo.app.notification.ItsTheTurnOfNotification import eventDemo.app.notification.ItsTheTurnOfNotification
import eventDemo.app.notification.Notification
import eventDemo.app.notification.PlayerAsJoinTheGameNotification import eventDemo.app.notification.PlayerAsJoinTheGameNotification
import eventDemo.app.notification.PlayerAsPlayACardNotification import eventDemo.app.notification.PlayerAsPlayACardNotification
import eventDemo.app.notification.PlayerWasReadyNotification import eventDemo.app.notification.PlayerWasReadyNotification
import eventDemo.app.notification.TheGameWasStartedNotification import eventDemo.app.notification.TheGameWasStartedNotification
import eventDemo.app.notification.WelcomeToTheGameNotification import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.configuration.appKoinModule import eventDemo.configuration.appKoinModule
import eventDemo.libs.fromFrameChannel
import eventDemo.libs.toObjectChannel
import eventDemo.shared.toFrame
import eventDemo.shared.toNotification
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
import io.ktor.websocket.Frame
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -48,46 +45,46 @@ class GameStateTest :
val id = GameId() val id = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein") val player2 = Player(name = "Einstein")
val channelCommand1 = Channel<Frame>(Channel.BUFFERED) val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED)
val channelCommand2 = Channel<Frame>(Channel.BUFFERED) val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification1 = Channel<Frame>(Channel.BUFFERED) val channelNotification1 = Channel<Notification>(Channel.BUFFERED)
val channelNotification2 = Channel<Frame>(Channel.BUFFERED) val channelNotification2 = Channel<Notification>(Channel.BUFFERED)
var playedCard1: Card? = null var playedCard1: Card? = null
var playedCard2: Card? = null var playedCard2: Card? = null
val player1Job = val player1Job =
launch { launch {
channelCommand1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1)).toFrame()) channelCommand1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1)))
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1) assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1)
} }
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2 assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2
} }
channelCommand1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)).toFrame()) channelCommand1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)))
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2 assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2
} }
val player1Hand = val player1Hand =
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7 assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
} }
playedCard1 = player1Hand.first() playedCard1 = player1Hand.first()
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply { assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player1 player shouldBeEqual player1
} }
} }
channelCommand1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())).toFrame()) channelCommand1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())))
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply { assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player2 player shouldBeEqual player2
} }
} }
channelNotification1.receive().toNotification().let { channelNotification1.receive().let {
assertIs<PlayerAsPlayACardNotification>(it).apply { assertIs<PlayerAsPlayACardNotification>(it).apply {
player shouldBeEqual player2 player shouldBeEqual player2
card shouldBeEqual assertNotNull(playedCard2) card shouldBeEqual assertNotNull(playedCard2)
@@ -98,24 +95,24 @@ class GameStateTest :
val player2Job = val player2Job =
launch { launch {
delay(100) delay(100)
channelCommand2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)).toFrame()) channelCommand2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)))
channelNotification2.receive().toNotification().let { channelNotification2.receive().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2) assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2)
} }
channelNotification2.receive().toNotification().let { channelNotification2.receive().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1 assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1
} }
channelCommand2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)).toFrame()) channelCommand2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)))
val player2Hand = val player2Hand =
channelNotification2.receive().toNotification().let { channelNotification2.receive().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7 assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
} }
channelNotification2.receive().toNotification().let { channelNotification2.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply { assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player1 player shouldBeEqual player1
} }
} }
channelNotification2.receive().toNotification().let { channelNotification2.receive().let {
assertIs<PlayerAsPlayACardNotification>(it).apply { assertIs<PlayerAsPlayACardNotification>(it).apply {
player shouldBeEqual player1 player shouldBeEqual player1
card shouldBeEqual assertNotNull(playedCard1) card shouldBeEqual assertNotNull(playedCard1)
@@ -123,12 +120,12 @@ class GameStateTest :
} }
playedCard2 = player2Hand.first() playedCard2 = player2Hand.first()
channelNotification2.receive().toNotification().let { channelNotification2.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply { assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player2 player shouldBeEqual player2
} }
} }
channelCommand2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())).toFrame()) channelCommand2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())))
} }
koinApplication { modules(appKoinModule) }.koin.apply { koinApplication { modules(appKoinModule) }.koin.apply {
@@ -140,10 +137,10 @@ class GameStateTest :
playerNotificationListener.startListening(channelNotification2, player2) playerNotificationListener.startListening(channelNotification2, player2)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player1, toObjectChannel(channelCommand1), fromFrameChannel(channelNotification1)) commandHandler.handle(player1, channelCommand1, channelNotification1)
} }
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player2, toObjectChannel(channelCommand2), fromFrameChannel(channelNotification2)) commandHandler.handle(player2, channelCommand2, channelNotification2)
} }
joinAll(player1Job, player2Job) joinAll(player1Job, player2Job)

View File

@@ -0,0 +1,10 @@
package eventDemo.libs.event
import io.kotest.core.spec.style.FunSpec
class EventBusInMemoryTest :
FunSpec({
xtest("publish should call the subscribed functions") { }
xtest("publish should call the subscribed functions on the priority order") { }
})

View File

@@ -0,0 +1,16 @@
package eventDemo.libs.event
import io.kotest.core.spec.style.FunSpec
class EventStreamInMemoryTest :
FunSpec({
xtest("publish should be concurrently secure") { }
xtest("readLast should only return the event of aggregate") { }
xtest("readLast should return the last event of the aggregate") { }
xtest("readLastOf should return the last event of the aggregate of the type") { }
xtest("readAll should only return the event of aggregate") { }
})