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:
@@ -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)
|
||||||
|
.process { command ->
|
||||||
if (command.payload.player.id != player.id) {
|
if (command.payload.player.id != player.id) {
|
||||||
|
logger.atWarn {
|
||||||
|
message = "Handle command Refuse, the player of the command is not the same: $command"
|
||||||
|
payload = mapOf("command" to command)
|
||||||
|
}
|
||||||
nack()
|
nack()
|
||||||
|
} else {
|
||||||
|
logger.atInfo {
|
||||||
|
message = "Handle command: $command"
|
||||||
|
payload = mapOf("command" to command)
|
||||||
|
}
|
||||||
|
command.run(outgoingErrorChannelNotification)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val playerErrorNotifier: suspend (String) -> Unit = {
|
private suspend fun GameCommand.run(outgoingErrorChannelNotification: SendChannel<Notification>) {
|
||||||
|
val gameState = gameStateRepository.get(payload.gameId)
|
||||||
|
val playerErrorNotifier = errorNotifier(outgoingErrorChannelNotification)
|
||||||
|
|
||||||
|
when (this) {
|
||||||
|
is IWantToPlayCardCommand -> run(gameState, playerErrorNotifier, eventHandler)
|
||||||
|
is IamReadyToPlayCommand -> run(gameState, playerErrorNotifier, eventHandler)
|
||||||
|
is IWantToJoinTheGameCommand -> 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)
|
val notification = ErrorNotification(message = it)
|
||||||
logger.atWarn {
|
logger.atWarn {
|
||||||
message = "Notification send ERROR: ${notification.message}"
|
message = "Notification send ERROR: ${notification.message}"
|
||||||
payload = mapOf("notification" to notification)
|
payload = mapOf("notification" to notification)
|
||||||
}
|
}
|
||||||
outgoingErrorChannelNotification.send(notification)
|
channel.send(notification)
|
||||||
}
|
|
||||||
|
|
||||||
val gameState = gameStateRepository.get(command.payload.gameId)
|
|
||||||
|
|
||||||
when (command) {
|
|
||||||
is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
|
|
||||||
is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
|
|
||||||
is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
|
|
||||||
is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -2,19 +2,49 @@ 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) {
|
||||||
|
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)
|
compute(command, action)
|
||||||
}
|
}
|
||||||
|
executedCommand
|
||||||
|
.filterValues { (_, date) ->
|
||||||
|
(date + maxCacheTime) > now
|
||||||
|
}.keys
|
||||||
|
.forEach {
|
||||||
|
executedCommand.remove(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun compute(
|
private suspend fun compute(
|
||||||
|
|||||||
@@ -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? =
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
15
src/test/kotlin/eventDemo/app/entity/PlayersHandsTest.kt
Normal file
15
src/test/kotlin/eventDemo/app/entity/PlayersHandsTest.kt
Normal 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") { }
|
||||||
|
})
|
||||||
10
src/test/kotlin/eventDemo/app/event/GameEventHandlerTest.kt
Normal file
10
src/test/kotlin/eventDemo/app/event/GameEventHandlerTest.kt
Normal 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") { }
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
10
src/test/kotlin/eventDemo/libs/event/EventBusInMemoryTest.kt
Normal file
10
src/test/kotlin/eventDemo/libs/event/EventBusInMemoryTest.kt
Normal 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") { }
|
||||||
|
})
|
||||||
@@ -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") { }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user