From e2d7942c7e91d740f7d355a9015423b4a07a6b43 Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Sun, 26 Jul 2026 17:51:58 +0200 Subject: [PATCH] refactoring: Masive refactor to build the V2 --- .idea/dataSources/data_sources_history.xml | 32 ++ build.gradle.kts | 42 +- doc/files-structure.md | 67 +++ docker/parts/docker-compose-traefik.yaml | 2 +- ...able.sql => V1__init_game_event_table.sql} | 3 +- .../events/V2__init_auth_event_table.sql | 8 + .../events/V3__init_auth_projection_table.sql | 6 + .../event/GameEventStoreInMemory.kt | 14 - .../event/GameEventStoreInPostgresql.kt | 21 - .../projection/GameListRepositoryInMemory.kt | 44 -- .../projection/GameListRepositoryInRedis.kt | 51 --- .../projection/GameStateRepositoryInMemory.kt | 41 -- .../projection/GameStateRepositoryInRedis.kt | 49 --- .../query/GameCommandRouteWebSocket.kt | 67 --- .../presenter/query/GetPlayerCredentials.kt | 14 - .../presenter/query/ReadTheGameState.kt | 53 --- .../eventDemo/configuration/Configuration.kt | 46 +++ .../eventDemo/configuration/Configure.kt | 29 -- .../eventDemo/configuration/ConfigureDI.kt | 14 + .../configuration/ConfigureDIDataSources.kt | 55 +++ .../eventDemo/configuration/ConfigureKoin.kt | 16 + .../eventDemo/configuration/ConfigureKtor.kt | 11 + .../domain/ConfigureGameListener.kt | 21 - .../configuration/injection/ConfigureDI.kt | 30 -- .../injection/ConfigureDIAction.kt | 18 - .../injection/ConfigureDIBusiness.kt | 19 - .../injection/ConfigureDIInfrastructure.kt | 73 ---- .../injection/ConfigureDILibs.kt | 11 - .../configuration/ktor/ConfigureKoin.kt | 42 -- .../configuration/route/DeclareHttpRoutes.kt | 14 - .../route/DeclareWebSocketsGameRoute.kt | 18 - .../eventStores/UserEventStoreRepository.kt | 24 ++ .../application/eventStores/UserRepository.kt | 10 + .../auth/application/ports/UserEventStore.kt | 7 + .../ports/UserProjectionRepository.kt | 14 + .../eventDemo/contexts/auth/domain/User.kt | 39 ++ .../auth/domain/events/NewUserCreatedEvent.kt | 18 + .../contexts/auth/domain/events/UserEvent.kt | 8 + .../auth/infrastructure/HashPassword.kt | 13 + .../infrastructure/configure/ConfigureAuth.kt | 8 + .../configure/ConfigureAuthDI.kt | 17 + .../configure/ConfigureAuthRoutes.kt | 19 + .../configure/ConfigureKtorAuth.kt} | 50 +-- .../eventStore/UserEventStoreInMemory.kt | 14 + .../eventStore/UserEventStoreInPostgresql.kt | 22 + .../persistence/projection/UserProjection.kt | 9 + .../UserProjectionRepositoryInPostgresql.kt | 61 +++ .../auth/infrastructure/rest/LoginRoute.kt | 24 ++ .../infrastructure/rest/UserCreateRoute.kt | 41 ++ .../channels/GameChannelsSubscriber.kt | 37 ++ .../command/handlers}/CommandException.kt | 2 +- .../handlers/GameCommandHandlerDispatcher.kt | 31 ++ .../command/handlers/GameEventManager.kt | 66 +++ .../command/handlers/JoinTheGameHandler.kt | 31 ++ .../command/handlers/PlayCardHandler.kt | 27 ++ .../command/handlers/ReadyToPlayHandler.kt | 24 ++ .../handlers/TakeCartFromDrawPileHandler.kt | 26 ++ .../application/command/models/GameCommand.kt | 19 + .../command/models/JoinTheGameCommand.kt} | 12 +- .../command/models/PlayCardCommand.kt | 31 ++ .../command/models/ReadyToPlayCommand.kt | 28 ++ .../models/TakeCartFromDrawPileCommand.kt | 28 ++ .../eventStores/GameEventStoreRepository.kt | 26 ++ .../application/eventStores/GameRepository.kt | 20 + .../application/logging/LoggingContext.kt | 29 ++ .../application/logging/LoggingContextKeys.kt | 9 + .../notification/EventToNotification.kt | 127 ++++++ .../EventToNotificationSubscriber.kt | 72 ++++ .../models}/ItsTheTurnOfNotification.kt | 6 +- .../notification/models}/Notification.kt | 4 +- .../models/PilesShuffledNotification.kt | 11 + .../PlayerAsJoinTheGameNotification.kt | 6 +- .../models}/PlayerAsPlayACardNotification.kt | 10 +- .../models}/PlayerHavePassNotification.kt | 8 +- .../models}/PlayerWasReadyNotification.kt | 8 +- .../models}/PlayerWinNotification.kt | 8 +- .../models}/TheGameWasStartedNotification.kt | 8 +- .../models}/WelcomeToTheGameNotification.kt | 6 +- .../models}/YourNewCardNotification.kt | 8 +- .../game/application/ports/GameEventBus.kt | 6 + .../game/application/ports/GameEventStore.kt | 7 + .../application/ports/GameListRepository.kt | 14 + .../application/ports/GameProjectionBus.kt | 6 + .../projections/GameListBuilder.kt | 55 +++ .../application/reaction/ReactionListener.kt | 65 +++ .../game/domain/events/CardIsPlayedEvent.kt | 31 ++ .../events/DrawFilledWithDiscardEvent.kt | 26 ++ .../game/domain/events/GameCreatedEvent.kt | 22 + .../contexts/game/domain/events/GameEvent.kt | 21 + .../game/domain/events/GameStartedEvent.kt | 32 ++ .../game/domain/events/NewPlayerEvent.kt | 27 ++ .../game/domain/events/PlayerActionEvent.kt | 9 + .../domain/events/PlayerHaveDrawCardEvent.kt | 29 ++ .../game/domain/events/PlayerReadyEvent.kt | 27 ++ .../game/domain/events/PlayerWinEvent.kt | 27 ++ .../game/domain/game}/Card.kt | 44 +- .../contexts/game/domain/game/DiscardPile.kt | 18 + .../contexts/game/domain/game/DrawPile.kt | 33 ++ .../game/domain/game}/GameId.kt | 9 +- .../contexts/game/domain/game/Player.kt | 62 +++ .../contexts/game/domain/game/PlayerHand.kt | 18 + .../game/domain/game/errors/GameException.kt | 68 +++ .../game/domain/game/gameState/Game.kt | 81 ++++ .../game/domain/game/gameState/GameCreated.kt | 179 ++++++++ .../game/domain/game/gameState/GameEnded.kt | 20 + .../game/domain/game/gameState/GameInit.kt | 28 ++ .../game/domain/game/gameState/GameStarted.kt | 264 ++++++++++++ .../application/ConfigureDIApplication.kt | 17 + .../application/ConfigureDICommandHandlers.kt | 18 + .../ConfigureDIInfrastructure.kt | 26 ++ .../configuration/ktor/ConfigureHttp.kt | 2 +- .../ktor/ConfigureSerialization.kt | 17 +- .../configuration/ktor/ConfigureUno.kt | 21 + .../configuration/ktor/ConfigureWebSockets.kt | 2 +- .../ktor/DeclareHttpGameRoutes.kt | 14 + .../ktor/DeclareWebSocketsGameRoute.kt | 16 + .../listener/ConfigureGameListener.kt | 9 + .../listener/ConfigureReactionListener.kt | 9 + .../eventBus}/GameEventBusInMemory.kt | 6 +- .../eventBus}/GameEventBusInRabbinMQ.kt | 6 +- .../eventStore/GameEventStoreInMemory.kt | 14 + .../eventStore/GameEventStoreInPostgresql.kt | 22 + .../projections/GameListRepositoryInMemory.kt | 49 +++ .../bus}/GameProjectionBusInMemory.kt | 6 +- .../bus}/GameProjectionBusInRabbitMQ.kt | 6 +- .../projections/models}/GameList.kt | 12 +- .../projections/models/GameProjection.kt | 6 + .../serializers}/CommandIdSerializer.kt | 2 +- .../serializers/EventIdSerializer.kt | 24 ++ .../serializers}/GameIdSerializer.kt | 4 +- .../serializers}/PlayerIdSerializer.kt | 4 +- .../infrastructure/rest/GameListRoute.kt} | 4 +- .../rest/GetFullNotificationsRoute.kt | 45 ++ .../websocket/GameCommandRouteWebSocket.kt | 26 ++ .../domain/command/GameCommandActionRunner.kt | 27 -- .../domain/command/GameCommandHandler.kt | 139 ------- .../domain/command/action/CommandAction.kt | 8 - .../domain/command/action/ICantPlay.kt | 36 -- .../command/action/IWantToJoinTheGame.kt | 28 -- .../domain/command/action/IWantToPlayCard.kt | 36 -- .../domain/command/action/IamReadyToPlay.kt | 36 -- .../domain/command/command/GameCommand.kt | 17 - .../command/IWantToJoinTheGameCommand.kt | 22 - .../command/command/IWantToPlayCardCommand.kt | 24 -- .../command/command/IamReadyToPlayCommand.kt | 22 - .../kotlin/eventDemo/domain/entity/Deck.kt | 133 ------ .../kotlin/eventDemo/domain/entity/Player.kt | 29 -- .../eventDemo/domain/entity/PlayersHands.kt | 50 --- .../eventDemo/domain/event/GameEventBus.kt | 6 - .../domain/event/GameEventHandler.kt | 16 - .../eventDemo/domain/event/GameEventStore.kt | 7 - .../domain/event/event/CardIsPlayedEvent.kt | 26 -- .../eventDemo/domain/event/event/GameEvent.kt | 16 - .../domain/event/event/GameStartedEvent.kt | 52 --- .../domain/event/event/NewPlayerEvent.kt | 23 -- .../domain/event/event/PlayerActionEvent.kt | 9 - .../event/event/PlayerChoseColorEvent.kt | 26 -- .../domain/event/event/PlayerHavePassEvent.kt | 26 -- .../domain/event/event/PlayerReadyEvent.kt | 23 -- .../domain/event/event/PlayerWinEvent.kt | 23 -- .../domain/event/projection/GameProjection.kt | 8 - .../event/projection/GameProjectionBus.kt | 5 - .../projection/gameList/GameListBuilder.kt | 51 --- .../projection/gameList/GameListRepository.kt | 5 - .../event/projection/gameState/GameState.kt | 182 -------- .../projection/gameState/GameStateBuilder.kt | 116 ------ .../gameState/GameStateRepository.kt | 7 - .../PlayerNotificationListener.kt | 149 ------- .../projectionListener/ReactionListener.kt | 75 ---- .../notification/CommandErrorNotification.kt | 15 - .../notification/CommandNotification.kt | 3 - .../CommandSuccessNotification.kt | 14 - .../PlayerWasChoseTheCardColorNotification.kt | 15 - src/main/kotlin/eventDemo/libs/bus/Bus.kt | 4 + .../eventDemo/libs/bus/BusInRabbitMQ.kt | 27 +- .../kotlin/eventDemo/libs/command/Command.kt | 2 +- .../eventDemo/libs/command/CommandHandler.kt | 108 ----- .../libs/command/CommandStreamChannel.kt | 50 --- ...Controller.kt => CommandUnicityChecker.kt} | 14 +- src/main/kotlin/eventDemo/libs/event/Event.kt | 23 -- .../eventDemo/libs/event/EventHandler.kt | 11 - .../eventDemo/libs/event/EventHandlerImpl.kt | 42 -- .../kotlin/eventDemo/libs/event/EventStore.kt | 12 - .../eventDemo/libs/event/VersionBuilder.kt | 7 - .../libs/event/VersionBuilderLocal.kt | 25 -- .../libs/event/projection/Projection.kt | 8 - .../event/projection/ProjectionRepository.kt | 34 -- .../projection/ProjectionRepositoryAbs.kt | 63 --- .../ProjectionRepositoryInMemory.kt | 46 --- .../projection/ProjectionRepositoryInRedis.kt | 80 ---- .../eventDemo/libs/eventSource/Event.kt | 32 ++ .../libs/eventSource/eventStore/EventStore.kt | 19 + .../eventStore}/EventStoreInMemory.kt | 4 +- .../eventStore}/EventStoreInPostgresql.kt | 7 +- .../eventStore}/EventStream.kt | 24 +- .../eventStore}/EventStreamInMemory.kt | 9 +- .../eventStore}/EventStreamInPostgresql.kt | 84 ++-- .../EventStreamPublishException.kt | 2 +- .../{ => helpers}/FrameChannelConverter.kt | 7 +- .../libs/{ => helpers}/ListToRange.kt | 2 +- .../eventDemo/libs/helpers/ReplaceValue.kt | 25 ++ .../serializer/UUIDSerializer.kt | 2 +- .../sharedKernel/GetUserIdCredentials.kt | 12 + .../kotlin/eventDemo/sharedKernel/UserId.kt | 13 + src/main/resources/application.conf | 2 +- .../event/GameEventBusInRabbitMQTest.kt | 48 --- .../adapter/presenter/query/AuthHelper.kt | 10 - .../presenter/query/GameListRouteTest.kt | 115 ------ .../presenter/query/GameSimulationTest.kt | 196 --------- .../presenter/query/GameStateRouteTest.kt | 157 ------- .../architecture/HexagonalArchitectureTest.kt | 42 ++ .../contexts/auth/domain/UserTest.kt | 34 ++ .../game/application/GameSimulationTest.kt | 282 +++++++++++++ .../GameEventStoreRepositoryTest.kt | 121 ++++++ .../EventToNotificationSubscriberTest.kt | 85 ++++ .../notification/ToNotificationTest.kt | 301 ++++++++++++++ .../game/domain/game/PlayerHandTest.kt | 46 +++ .../domain/game/gameState/GameStartedTest.kt | 389 ++++++++++++++++++ .../game/domain/game/gameState/NewDeckTest.kt | 41 ++ .../game/intrastructure/AuthHelper.kt | 10 + .../game/intrastructure}/TestHttpClient.kt | 5 +- .../persistence/connectors}/PostgresqlTest.kt | 9 +- .../persistence/connectors/RabbitMQTest.kt | 60 +++ .../persistence/connectors/RedisTest.kt | 21 + .../eventBus/GameEventBusInRabbitMQTest.kt | 44 ++ .../intrastructure/rest/GameListRouteTest.kt | 128 ++++++ .../domain/command/GameCommandHandlerTest.kt | 65 --- .../domain/command/GameCommandRunnerTest.kt | 8 - .../command/command/ICantPlayCommandTest.kt | 9 - .../command/IWantToJoinTheGameCommandTest.kt | 9 - .../command/IWantToPlayCardCommandTest.kt | 9 - .../command/IamReadyToPlayCommandTest.kt | 9 - .../eventDemo/domain/entity/DeckTest.kt | 104 ----- .../domain/entity/PlayerHandKtTest.kt | 41 -- .../domain/entity/PlayersHandsTest.kt | 15 - .../domain/event/GameEventHandlerTest.kt | 110 ----- .../event/projection/GameStateBuilderTest.kt | 126 ------ .../projection/GameStateRepositoryTest.kt | 136 ------ .../domain/event/projection/GameStateTest.kt | 158 ------- .../PlayerNotificationListenerTest.kt | 37 -- .../externalServices/RabbitMQTest.kt | 70 ---- .../eventDemo/externalServices/RedisTest.kt | 20 - src/test/kotlin/eventDemo/libs/bus/BusTest.kt | 23 +- .../eventDemo/libs/command/CommandForTest.kt | 8 + .../libs/command/CommandStreamChannelTest.kt | 45 -- .../libs/command/CommandUnicityCheckerTest.kt | 34 ++ .../eventDemo/libs/event/EventHandlerTest.kt | 38 -- .../libs/event/VersionBuilderLocalTest.kt | 48 --- .../projection/ProjectionRepositoryTest.kt | 225 ---------- .../{event => eventSource}/EventStreamTest.kt | 41 +- .../libs/{event => eventSource}/TestEvents.kt | 9 +- .../FrameChannelConverterTest.kt | 16 +- .../CreateGameWithCommandsHelpers.kt | 63 +++ ...CreateGameWithCommandsInChannelsHelpers.kt | 78 ++++ .../eventDemo/{ => testHelpers}/LogHelper.kt | 2 +- .../eventDemo/testHelpers/NewUserHelper.kt | 7 + .../testHelpers/TestAllCardCountHelpers.kt | 6 + .../TestApplicationHelpers.kt} | 52 +-- .../eventDemo/testHelpers/TestDataHelper.kt | 27 ++ .../eventDemo/testHelpers/TestHelper.kt | 33 ++ 260 files changed, 5049 insertions(+), 4753 deletions(-) create mode 100644 .idea/dataSources/data_sources_history.xml create mode 100644 doc/files-structure.md rename migrations/events/{V1__init_event_table.sql => V1__init_game_event_table.sql} (75%) create mode 100644 migrations/events/V2__init_auth_event_table.sql create mode 100644 migrations/events/V3__init_auth_projection_table.sql delete mode 100644 src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt delete mode 100644 src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt create mode 100644 src/main/kotlin/eventDemo/configuration/Configuration.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/Configure.kt create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureDI.kt create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/domain/User.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt rename src/main/kotlin/eventDemo/{configuration/ktor/ConfigureAuth.kt => contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt} (54%) create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjectionRepositoryInPostgresql.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt create mode 100644 src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt rename src/main/kotlin/eventDemo/{domain/command => contexts/game/application/command/handlers}/CommandException.kt (56%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt rename src/main/kotlin/eventDemo/{domain/command/command/ICantPlayCommand.kt => contexts/game/application/command/models/JoinTheGameCommand.kt} (50%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/ItsTheTurnOfNotification.kt (60%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/Notification.kt (60%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/PlayerAsJoinTheGameNotification.kt (61%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/PlayerAsPlayACardNotification.kt (50%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/PlayerHavePassNotification.kt (53%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/PlayerWasReadyNotification.kt (53%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/PlayerWinNotification.kt (53%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/TheGameWasStartedNotification.kt (55%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/WelcomeToTheGameNotification.kt (61%) rename src/main/kotlin/eventDemo/{domain/notification => contexts/game/application/notification/models}/YourNewCardNotification.kt (54%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt rename src/main/kotlin/eventDemo/{domain/entity => contexts/game/domain/game}/Card.kt (73%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt rename src/main/kotlin/eventDemo/{domain/entity => contexts/game/domain/game}/GameId.kt (56%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt rename src/main/kotlin/eventDemo/{ => contexts/game/infrastructure}/configuration/ktor/ConfigureHttp.kt (96%) rename src/main/kotlin/eventDemo/{ => contexts/game/infrastructure}/configuration/ktor/ConfigureSerialization.kt (55%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt rename src/main/kotlin/eventDemo/{ => contexts/game/infrastructure}/configuration/ktor/ConfigureWebSockets.kt (86%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt rename src/main/kotlin/eventDemo/{adapter/infrastructure/event => contexts/game/infrastructure/persistence/eventBus}/GameEventBusInMemory.kt (68%) rename src/main/kotlin/eventDemo/{adapter/infrastructure/event => contexts/game/infrastructure/persistence/eventBus}/GameEventBusInRabbinMQ.kt (77%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt rename src/main/kotlin/eventDemo/{adapter/infrastructure/event/projection => contexts/game/infrastructure/persistence/projections/bus}/GameProjectionBusInMemory.kt (64%) rename src/main/kotlin/eventDemo/{adapter/infrastructure/event/projection => contexts/game/infrastructure/persistence/projections/bus}/GameProjectionBusInRabbitMQ.kt (74%) rename src/main/kotlin/eventDemo/{domain/event/projection/gameList => contexts/game/infrastructure/persistence/projections/models}/GameList.kt (54%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt rename src/main/kotlin/eventDemo/{configuration/serializer => contexts/game/infrastructure/persistence/serializers}/CommandIdSerializer.kt (91%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt rename src/main/kotlin/eventDemo/{configuration/serializer => contexts/game/infrastructure/persistence/serializers}/GameIdSerializer.kt (85%) rename src/main/kotlin/eventDemo/{configuration/serializer => contexts/game/infrastructure/persistence/serializers}/PlayerIdSerializer.kt (86%) rename src/main/kotlin/eventDemo/{adapter/presenter/query/GameList.kt => contexts/game/infrastructure/rest/GameListRoute.kt} (82%) create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt create mode 100644 src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt delete mode 100644 src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt delete mode 100644 src/main/kotlin/eventDemo/domain/entity/Deck.kt delete mode 100644 src/main/kotlin/eventDemo/domain/entity/Player.kt delete mode 100644 src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/GameEventBus.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/GameEventStore.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt delete mode 100644 src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt delete mode 100644 src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt delete mode 100644 src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt delete mode 100644 src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt delete mode 100644 src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt delete mode 100644 src/main/kotlin/eventDemo/libs/command/CommandHandler.kt delete mode 100644 src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt rename src/main/kotlin/eventDemo/libs/command/{CommandRunnerController.kt => CommandUnicityChecker.kt} (76%) delete mode 100644 src/main/kotlin/eventDemo/libs/event/Event.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/EventHandler.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/EventStore.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/projection/Projection.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt delete mode 100644 src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt create mode 100644 src/main/kotlin/eventDemo/libs/eventSource/Event.kt create mode 100644 src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt rename src/main/kotlin/eventDemo/libs/{event => eventSource/eventStore}/EventStoreInMemory.kt (75%) rename src/main/kotlin/eventDemo/libs/{event => eventSource/eventStore}/EventStoreInPostgresql.kt (65%) rename src/main/kotlin/eventDemo/libs/{event => eventSource/eventStore}/EventStream.kt (65%) rename src/main/kotlin/eventDemo/libs/{event => eventSource/eventStore}/EventStreamInMemory.kt (81%) rename src/main/kotlin/eventDemo/libs/{event => eventSource/eventStore}/EventStreamInPostgresql.kt (54%) rename src/main/kotlin/eventDemo/libs/{event => eventSource/eventStore}/EventStreamPublishException.kt (66%) rename src/main/kotlin/eventDemo/libs/{ => helpers}/FrameChannelConverter.kt (86%) rename src/main/kotlin/eventDemo/libs/{ => helpers}/ListToRange.kt (89%) create mode 100644 src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt rename src/main/kotlin/eventDemo/{configuration => libs}/serializer/UUIDSerializer.kt (94%) create mode 100644 src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt create mode 100644 src/main/kotlin/eventDemo/sharedKernel/UserId.kt delete mode 100644 src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt delete mode 100644 src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt delete mode 100644 src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt delete mode 100644 src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt delete mode 100644 src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt create mode 100644 src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/application/notification/ToNotificationTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/NewDeckTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt rename src/test/kotlin/eventDemo/{adapter/presenter/query => contexts/game/intrastructure}/TestHttpClient.kt (73%) rename src/test/kotlin/eventDemo/{externalServices => contexts/game/intrastructure/persistence/connectors}/PostgresqlTest.kt (67%) create mode 100644 src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt create mode 100644 src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/entity/DeckTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt delete mode 100644 src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt delete mode 100644 src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt delete mode 100644 src/test/kotlin/eventDemo/externalServices/RedisTest.kt create mode 100644 src/test/kotlin/eventDemo/libs/command/CommandForTest.kt delete mode 100644 src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt create mode 100644 src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt delete mode 100644 src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt delete mode 100644 src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt delete mode 100644 src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt rename src/test/kotlin/eventDemo/libs/{event => eventSource}/EventStreamTest.kt (72%) rename src/test/kotlin/eventDemo/libs/{event => eventSource}/TestEvents.kt (66%) rename src/test/kotlin/eventDemo/libs/{ => helpers}/FrameChannelConverterTest.kt (76%) create mode 100644 src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsHelpers.kt create mode 100644 src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsInChannelsHelpers.kt rename src/test/kotlin/eventDemo/{ => testHelpers}/LogHelper.kt (95%) create mode 100644 src/test/kotlin/eventDemo/testHelpers/NewUserHelper.kt create mode 100644 src/test/kotlin/eventDemo/testHelpers/TestAllCardCountHelpers.kt rename src/test/kotlin/eventDemo/{Helpers.kt => testHelpers/TestApplicationHelpers.kt} (53%) create mode 100644 src/test/kotlin/eventDemo/testHelpers/TestDataHelper.kt create mode 100644 src/test/kotlin/eventDemo/testHelpers/TestHelper.kt diff --git a/.idea/dataSources/data_sources_history.xml b/.idea/dataSources/data_sources_history.xml new file mode 100644 index 0000000..19fba65 --- /dev/null +++ b/.idea/dataSources/data_sources_history.xml @@ -0,0 +1,32 @@ + + + + + " + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/event-demo + master_key + event-demo + + + + + + + + + + + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e78e74a..6c4a236 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,21 +1,19 @@ -@file:Suppress("PropertyName") - import org.jlleitschuh.gradle.ktlint.KtlintExtension -val ktor_version: String by project -val kotlin_version: String by project -val kotlin_serialization_version: String by project -val logback_version: String by project -val koin_version: String by project -val kotlin_logging_version: String by project -val kotest_version: String by project +val ktorVersion: Provider = providers.gradleProperty("ktor_version") +val kotlinVersion: Provider = providers.gradleProperty("kotlin_version") +val kotlinSerializationVersion: Provider = providers.gradleProperty("kotlin_serialization_version") +val logbackVersion: Provider = providers.gradleProperty("logback_version") +val koinVersion: Provider = providers.gradleProperty("koin_version") +val kotlinLoggingVersion: Provider = providers.gradleProperty("kotlin_logging_version") +val kotestVersion: Provider = providers.gradleProperty("kotest_version") plugins { application kotlin("jvm") version "2.1.21" id("io.ktor.plugin") version "3.5.1" id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" - id("org.jlleitschuh.gradle.ktlint") version "12.2.0" + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" id("com.avast.gradle.docker-compose") version "0.17.12" } @@ -29,7 +27,7 @@ application { } configure { - version.set("1.5.0") + version.set("1.8.0") } ktlint { reporters { @@ -124,23 +122,25 @@ dependencies { implementation("io.ktor:ktor-server-data-conversion") implementation("io.ktor:ktor-client-content-negotiation") implementation("io.ktor:ktor-client-auth") - implementation("ch.qos.logback:logback-classic:$logback_version") - implementation("io.insert-koin:koin-ktor:$koin_version") - implementation("io.insert-koin:koin-logger-slf4j:$koin_version") - implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version") + implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}") + implementation("io.insert-koin:koin-ktor:${koinVersion.get()}") + implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}") + implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") implementation("redis.clients:jedis:5.2.0") - implementation("org.postgresql:postgresql:42.7.5") + implementation("org.postgresql:postgresql:42.7.13") implementation("com.zaxxer:HikariCP:6.3.0") implementation("com.rabbitmq:amqp-client:5.25.0") + implementation("com.password4j:password4j:1.8.4") // Force version of sub library (for security) implementation("commons-codec:commons-codec:1.13") - testImplementation("io.kotest:kotest-extensions-koin:$kotest_version") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") - testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version") - testImplementation("io.kotest:kotest-runner-junit5:$kotest_version") + testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}") + testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}") + testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}") testImplementation("io.mockk:mockk:1.13.17") + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") } diff --git a/doc/files-structure.md b/doc/files-structure.md new file mode 100644 index 0000000..5cd8a1d --- /dev/null +++ b/doc/files-structure.md @@ -0,0 +1,67 @@ +# Exemple de structure + +Les couches, du plus interne au plus externe +``` +Domain (le cœur, ne dépend de RIEN d'externe) +↑ +Application (orchestre le Domain, ne connaît pas l'infra concrète) +↑ +Infrastructure (WebSocket, DB, event store — dépend de tout le reste) +``` + +``` +src/ +└── contexts/ + ├── auth/ + └── ... + └── game/ + ├── domain/ ← Le cœur métier, zéro dépendance externe + │ ├── game/ + │ │ ├── Game.ts ← Aggregate Root + │ │ ├── Player.ts ← Entity interne + │ │ ├── Card.ts ← Entity + │ │ ├── Color.ts ← Value Object + │ │ ├── Deck.ts ← VO ou petite structure + │ │ └── errors/ + │ │ ├── InvalidMoveError.ts + │ │ └── ColorChoiceRequiredError.ts + │ └── events/ ← Events de DOMAINE (internes) + │ ├── CardPlayed.ts + │ ├── CardDrawn.ts + │ ├── TurnPassed.ts + │ └── DomainEvent.ts ← interface/type de base + ├── application/ ← Orchestration, cas d'usage + │ ├── commands/ ← Les Commandes (intentions) + │ │ ├── PlayCardCommand.ts + │ │ └── DrawCardCommand.ts + │ ├── handlers/ ← Un handler par commande + │ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save + │ │ └── DrawCardHandler.ts + │ ├── projections/ ← LA LOGIQUE de construction des projections + │ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue + │ │ └── PlayerStatsProjector.kt + │ └── ports/ ← INTERFACES seulement (le "hexagone") + │ ├── GameRepository.ts ← interface, pas d'implémentation + │ ├── EventPublisher.ts ← interface, pas d'implémentation + │ └── ProjectionStore.kt ← interface, où lire/écrire la projection + ├── infrastructure/ ← Tout ce qui est technique/externe + │ ├── persistence/ + │ │ ├── EventStoreGameRepository.ts ← implémente GameRepository + │ │ ├── EventStore.ts + │ │ ├── projections/ + │ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée) + │ │ │ └── models/ + │ │ │ └── GameSummaryView.kt ← structure de la vue elle-même + │ ├── websocket/ + │ │ ├── WebSocketServer.ts + │ │ ├── connectionManager.ts ← Map> + │ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler + │ └── eventPublisher/ + │ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast + └── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer) + ├── clientEvents/ + │ ├── ClientEvent.ts ← types des events envoyés au front + │ └── toClientEvent.ts ← fonction de traduction domain event → client event + └── clientCommands/ + └── parseIncomingCommand.ts ← valide/parse le message brut du client → Command +``` \ No newline at end of file diff --git a/docker/parts/docker-compose-traefik.yaml b/docker/parts/docker-compose-traefik.yaml index ed40ddd..54959f2 100644 --- a/docker/parts/docker-compose-traefik.yaml +++ b/docker/parts/docker-compose-traefik.yaml @@ -1,6 +1,6 @@ services: traefik: - image: traefik:3.3.4 + image: traefik:3.7.9 command: - "--api.insecure=true" - "--api.dashboard=true" diff --git a/migrations/events/V1__init_event_table.sql b/migrations/events/V1__init_game_event_table.sql similarity index 75% rename from migrations/events/V1__init_event_table.sql rename to migrations/events/V1__init_game_event_table.sql index 6811f49..7c8c93c 100644 --- a/migrations/events/V1__init_event_table.sql +++ b/migrations/events/V1__init_game_event_table.sql @@ -1,4 +1,5 @@ -create table event_stream ( +create schema game; +create table game.game_event_stream ( id uuid not null primary key, aggregate_id uuid not null, version int not null, diff --git a/migrations/events/V2__init_auth_event_table.sql b/migrations/events/V2__init_auth_event_table.sql new file mode 100644 index 0000000..df2d9ab --- /dev/null +++ b/migrations/events/V2__init_auth_event_table.sql @@ -0,0 +1,8 @@ +create schema auth; +create table auth.user_event_stream ( + id uuid not null primary key, + aggregate_id uuid not null, + version int not null, + data jsonb not null, + unique(aggregate_id, version) +); \ No newline at end of file diff --git a/migrations/events/V3__init_auth_projection_table.sql b/migrations/events/V3__init_auth_projection_table.sql new file mode 100644 index 0000000..075f90b --- /dev/null +++ b/migrations/events/V3__init_auth_projection_table.sql @@ -0,0 +1,6 @@ +create table auth.user ( + id uuid not null primary key, + username text not null, + unique(id), + unique(username) +); \ No newline at end of file diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt deleted file mode 100644 index 386e7b3..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.adapter.infrastructure.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventStore -import eventDemo.libs.event.EventStoreInMemory - -/** - * A stream to publish and read the played card event. - */ -class GameEventStoreInMemory : - GameEventStore, - EventStore by EventStoreInMemory() diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt deleted file mode 100644 index 395fe83..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt +++ /dev/null @@ -1,21 +0,0 @@ -package eventDemo.adapter.infrastructure.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventStore -import eventDemo.libs.event.EventStoreInPostgresql -import kotlinx.serialization.json.Json -import javax.sql.DataSource - -/** - * A stream to publish and read the played card event. - */ -class GameEventStoreInPostgresql( - dataSource: DataSource, -) : GameEventStore, - EventStore by EventStoreInPostgresql( - dataSource, - { Json.encodeToString(it) }, - { Json.decodeFromString(it) }, - ) diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt deleted file mode 100644 index 42e3575..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt +++ /dev/null @@ -1,44 +0,0 @@ -package eventDemo.adapter.infrastructure.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameList -import eventDemo.domain.event.projection.GameListRepository -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInMemory -import io.github.oshai.kotlinlogging.withLoggingContext - -/** - * Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus]. - */ -class GameListRepositoryInMemory : GameListRepository { - private val projectionsRepository = - ProjectionRepositoryInMemory( - applyToProjection = GameList::apply, - initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) }, - ) - - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { - // On new event was received, build projection and publish it to the projection bus - eventBus.subscribe { event -> - withLoggingContext("event" to event.toString()) { - projectionsRepository - .applyAndSave(event) - .also { projectionBus.publish(it) } - } - } - } - - /** - * Get the last version of the [GameState] from the all eventStream. - * - * It fetches it from the local cache if possible, otherwise it builds it. - */ - override fun getList(): List = - projectionsRepository.getList() -} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt deleted file mode 100644 index 51ed7e1..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt +++ /dev/null @@ -1,51 +0,0 @@ -package eventDemo.adapter.infrastructure.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameList -import eventDemo.domain.event.projection.GameListRepository -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInRedis -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.serialization.json.Json -import redis.clients.jedis.UnifiedJedis - -/** - * Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus]. - */ -class GameListRepositoryInRedis( - jedis: UnifiedJedis, -) : GameListRepository { - private val projectionsRepository = - ProjectionRepositoryInRedis( - initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) }, - projectionClass = GameList::class, - projectionToJson = { Json.encodeToString(GameList.serializer(), it) }, - jsonToProjection = { Json.decodeFromString(GameList.serializer(), it) }, - applyToProjection = GameList::apply, - jedis = jedis, - ) - - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { - eventBus.subscribe { event -> - withLoggingContext("event" to event.toString()) { - projectionsRepository - .applyAndSave(event) - .also { projectionBus.publish(it) } - } - } - } - - /** - * Get the last version of the [GameState] from the all eventStream. - * - * It fetches it from the local cache if possible, otherwise it builds it. - */ - override fun getList(): List = - projectionsRepository.getList() -} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt deleted file mode 100644 index 5e2dd5a..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eventDemo.adapter.infrastructure.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInMemory -import io.github.oshai.kotlinlogging.withLoggingContext - -/** - * Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus]. - */ -class GameStateRepositoryInMemory : GameStateRepository { - private val projectionsRepository = - ProjectionRepositoryInMemory( - applyToProjection = GameState::apply, - initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) }, - ) - - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { - // On new event was received, build projection and publish it to the projection bus - eventBus.subscribe { event -> - withLoggingContext("event" to event.toString()) { - projectionsRepository - .applyAndSave(event) - .also { projectionBus.publish(it) } - } - } - } - - /** - * Get the [GameState]. - */ - override fun get(gameId: GameId): GameState = - projectionsRepository.get(gameId) -} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt deleted file mode 100644 index 3963b48..0000000 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt +++ /dev/null @@ -1,49 +0,0 @@ -package eventDemo.adapter.infrastructure.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.domain.event.projection.apply -import eventDemo.libs.event.projection.ProjectionRepositoryInRedis -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.serialization.json.Json -import redis.clients.jedis.UnifiedJedis - -/** - * Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus]. - */ -class GameStateRepositoryInRedis( - jedis: UnifiedJedis, -) : GameStateRepository { - private val projectionsRepository = - ProjectionRepositoryInRedis( - initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) }, - projectionClass = GameState::class, - projectionToJson = { Json.encodeToString(GameState.serializer(), it) }, - jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) }, - applyToProjection = GameState::apply, - jedis = jedis, - ) - - fun subscribeToBus( - projectionBus: GameProjectionBus, - eventBus: GameEventBus, - ) { - // On new event was received, build projection and publish it to the projection bus - eventBus.subscribe { event -> - withLoggingContext("event" to event.toString()) { - projectionsRepository - .applyAndSave(event) - .also { projectionBus.publish(it) } - } - } - } - - /** - * Get the [GameState]. - */ - override fun get(gameId: GameId): GameState = - projectionsRepository.get(gameId) -} diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt deleted file mode 100644 index b7d0b1f..0000000 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt +++ /dev/null @@ -1,67 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.notification.Notification -import eventDemo.libs.fromFrameChannel -import eventDemo.libs.toObjectChannel -import io.github.oshai.kotlinlogging.withLoggingContext -import io.ktor.server.auth.authenticate -import io.ktor.server.routing.Route -import io.ktor.server.websocket.DefaultWebSocketServerSession -import io.ktor.server.websocket.webSocket -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.launch -import java.util.UUID - -@DelicateCoroutinesApi -fun Route.gameWebSocket( - playerNotificationListener: PlayerNotificationListener, - commandHandler: GameCommandHandler, -) { - authenticate { - webSocket("/games/new") { - runWebSocket(GameId(), commandHandler, playerNotificationListener) - } - - webSocket("/games/{id}") { - val gameId = GameId(UUID.fromString(call.parameters["id"]!!)) - runWebSocket(gameId, commandHandler, playerNotificationListener) - } - } -} - -@DelicateCoroutinesApi -private fun DefaultWebSocketServerSession.runWebSocket( - gameId: GameId, - commandHandler: GameCommandHandler, - playerNotificationListener: PlayerNotificationListener, -) { - val currentPlayer = call.getPlayerCredentials() - val incomingFrameChannel: ReceiveChannel = toObjectChannel(incoming) - val outgoingFrameChannel: SendChannel = fromFrameChannel(outgoing) - withLoggingContext("currentPlayer" to currentPlayer.toString()) { - val notificationListener = - playerNotificationListener.startListening( - currentPlayer, - gameId, - ) { outgoingFrameChannel.trySendBlocking(it) } - - // TODO change GlobalScope - GlobalScope.launch { - commandHandler.handleIncomingPlayerCommands( - currentPlayer, - gameId, - incomingFrameChannel, - outgoingFrameChannel, - ) - notificationListener.close() - } - } -} diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt deleted file mode 100644 index 766996a..0000000 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.Player -import io.ktor.server.application.ApplicationCall -import io.ktor.server.auth.jwt.JWTPrincipal -import io.ktor.server.auth.principal - -internal fun ApplicationCall.getPlayerCredentials() = - principal()!!.run { - Player( - id = payload.getClaim("playerid").asString(), - name = payload.getClaim("username").asString(), - ) - } diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt deleted file mode 100644 index aa5e161..0000000 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt +++ /dev/null @@ -1,53 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.configuration.serializer.GameIdSerializer -import io.ktor.http.HttpStatusCode -import io.ktor.resources.Resource -import io.ktor.server.auth.authenticate -import io.ktor.server.resources.get -import io.ktor.server.response.respond -import io.ktor.server.routing.Route -import kotlinx.serialization.Serializable - -@Serializable -@Resource("/games/{id}") -class Game( - @Serializable(with = GameIdSerializer::class) - val id: GameId, -) { - @Serializable - @Resource("card/last") - class Card( - val game: Game, - ) - - @Serializable - @Resource("state") - class State( - val game: Game, - ) -} - -/** - * API routes to read the game state. - */ -fun Route.readTheGameState(gameStateRepository: GameStateRepository) { - authenticate { - // Read the last played card on the game. - get { body -> - gameStateRepository - .get(body.game.id) - .cardOnCurrentStack - ?.let { call.respond(it) } - ?: call.response.status(HttpStatusCode.BadRequest) - } - - // Read the last played card on the game. - get { body -> - val state = gameStateRepository.get(body.game.id) - call.respond(state) - } - } -} diff --git a/src/main/kotlin/eventDemo/configuration/Configuration.kt b/src/main/kotlin/eventDemo/configuration/Configuration.kt new file mode 100644 index 0000000..0ee7d73 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/Configuration.kt @@ -0,0 +1,46 @@ +package eventDemo.configuration + +import io.ktor.server.config.ApplicationConfig + +data class Configuration( + val redisUrl: String, + val jwtSecret: String, + val postgresql: Postgresql, + val rabbitmq: RabbitMQ, +) { + data class Postgresql( + val url: String, + val username: String, + val password: String, + ) + + data class RabbitMQ( + val url: String, + val port: Int, + val username: String, + val password: String, + ) +} + +val ApplicationConfig.configuration + get() = + Configuration( + redisUrl = getProperty("redis.url"), + jwtSecret = getProperty("jwt.secret"), + postgresql = + Configuration.Postgresql( + url = getProperty("postgresql.url"), + username = getProperty("postgresql.username"), + password = getProperty("postgresql.password"), + ), + rabbitmq = + Configuration.RabbitMQ( + url = getProperty("rabbitmq.url"), + port = getProperty("rabbitmq.port").toInt(), + username = getProperty("rabbitmq.username"), + password = getProperty("rabbitmq.password"), + ), + ) + +private fun ApplicationConfig.getProperty(path: String): String = + propertyOrNull(path)?.getString() ?: error("You must set the $path") diff --git a/src/main/kotlin/eventDemo/configuration/Configure.kt b/src/main/kotlin/eventDemo/configuration/Configure.kt deleted file mode 100644 index fb45a18..0000000 --- a/src/main/kotlin/eventDemo/configuration/Configure.kt +++ /dev/null @@ -1,29 +0,0 @@ -package eventDemo.configuration - -import eventDemo.configuration.domain.configureGameListener -import eventDemo.configuration.ktor.configureHttpRouting -import eventDemo.configuration.ktor.configureKoin -import eventDemo.configuration.ktor.configureSecurity -import eventDemo.configuration.ktor.configureSerialization -import eventDemo.configuration.ktor.configureWebSockets -import eventDemo.configuration.route.declareHttpGameRoute -import eventDemo.configuration.route.declareWebSocketsGameRoute -import io.ktor.server.application.Application -import org.koin.ktor.ext.get -import org.koin.ktor.ext.getKoin - -fun Application.configure() { - configureKoin() - - configureSecurity() - - configureSerialization() - - configureWebSockets() - declareWebSocketsGameRoute(get(), get()) - - configureHttpRouting() - declareHttpGameRoute() - - getKoin().configureGameListener() -} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt new file mode 100644 index 0000000..fdeb0ba --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt @@ -0,0 +1,14 @@ +package eventDemo.configuration + +import eventDemo.contexts.auth.infrastructure.configure.configureAuthDi +import eventDemo.contexts.game.infrastructure.configuration.injections.application.configureGameDIApplication +import eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure.configureGameDIInfrastructure +import org.koin.dsl.module + +fun appKoinModule(config: Configuration) = + module { + configureDIDataSource(config) + configureAuthDi() + configureGameDIInfrastructure() + configureGameDIApplication() + } diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt new file mode 100644 index 0000000..9a7c972 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt @@ -0,0 +1,55 @@ +package eventDemo.configuration + +import com.rabbitmq.client.ConnectionFactory +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import org.koin.core.module.Module +import org.koin.core.scope.Scope +import org.koin.core.scope.ScopeCallback +import org.koin.dsl.bind +import redis.clients.jedis.JedisPooled +import redis.clients.jedis.UnifiedJedis +import javax.sql.DataSource + +fun Module.configureDIDataSource(config: Configuration) { + // PostgreSQL (for EventStore) + single { + hikariDataSource(config) + .apply { + registerCallback( + object : ScopeCallback { + override fun onScopeClose(scope: Scope) { + close() + } + }, + ) + } + } bind DataSource::class + + // Redis (for Projections) + single { + JedisPooled(config.redisUrl) + } bind UnifiedJedis::class + + // RabbitMQ (for EventBus) + factory { + ConnectionFactory().apply { + host = config.rabbitmq.url + port = config.rabbitmq.port + username = config.rabbitmq.username + password = config.rabbitmq.password + } + } +} + +private fun hikariDataSource(config: Configuration): HikariDataSource = + HikariConfig() + .apply { + jdbcUrl = config.postgresql.url + username = config.postgresql.username + password = config.postgresql.password + maximumPoolSize = 10 + minimumIdle = 10 + }.let { + HikariDataSource(it) + } diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt b/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt new file mode 100644 index 0000000..1fb671d --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt @@ -0,0 +1,16 @@ +package eventDemo.configuration + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import org.koin.ktor.plugin.Koin +import org.koin.logger.slf4jLogger + +fun Application.configureKoin() { + install(Koin) { + slf4jLogger() + + modules( + appKoinModule(environment.config.configuration), + ) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt b/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt new file mode 100644 index 0000000..bd38479 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt @@ -0,0 +1,11 @@ +package eventDemo.configuration + +import eventDemo.contexts.auth.infrastructure.configure.configureAuth +import eventDemo.contexts.game.infrastructure.configuration.ktor.configureUno +import io.ktor.server.application.Application + +fun Application.configure() { + configureKoin() + configureAuth() + configureUno() +} diff --git a/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt deleted file mode 100644 index c49bcfe..0000000 --- a/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt +++ /dev/null @@ -1,21 +0,0 @@ -package eventDemo.configuration.domain - -import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis -import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.event.projection.projectionListener.ReactionListener -import org.koin.core.Koin - -fun Koin.configureGameListener() { - get() - .subscribeToBus(get()) - - get() - .subscribeToBus(get(), get()) - - get() - .subscribeToBus(get(), get()) - - get() - .subscribeToBus(get()) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt deleted file mode 100644 index a62be93..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt +++ /dev/null @@ -1,30 +0,0 @@ -package eventDemo.configuration.injection - -import org.koin.dsl.module - -fun appKoinModule(config: Configuration) = - module { - configureDIBusiness() - configureDIInfrastructure(config) - configureDILibs() - configureDICommandActions() - } - -data class Configuration( - val redisUrl: String, - val postgresql: Postgresql, - val rabbitmq: RabbitMQ, -) { - data class Postgresql( - val url: String, - val username: String, - val password: String, - ) - - data class RabbitMQ( - val url: String, - val port: Int, - val username: String, - val password: String, - ) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt deleted file mode 100644 index 9060c2b..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eventDemo.configuration.injection - -import eventDemo.domain.command.action.ICantPlay -import eventDemo.domain.command.action.IWantToJoinTheGame -import eventDemo.domain.command.action.IWantToPlayCard -import eventDemo.domain.command.action.IamReadyToPlay -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf - -/** - * Configure all actions - */ -fun Module.configureDICommandActions() { - singleOf(::IWantToPlayCard) - singleOf(::IamReadyToPlay) - singleOf(::IWantToJoinTheGame) - singleOf(::ICantPlay) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt deleted file mode 100644 index 8364dd0..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt +++ /dev/null @@ -1,19 +0,0 @@ -package eventDemo.configuration.injection - -import eventDemo.domain.command.GameCommandActionRunner -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.event.projection.projectionListener.ReactionListener -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf - -fun Module.configureDIBusiness() { - single { - GameCommandHandler(get(), get(), get(), get()) - } - singleOf(::GameEventHandler) - singleOf(::GameCommandActionRunner) - singleOf(::PlayerNotificationListener) - singleOf(::ReactionListener) -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt deleted file mode 100644 index e99894e..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt +++ /dev/null @@ -1,73 +0,0 @@ -package eventDemo.configuration.injection - -import com.rabbitmq.client.ConnectionFactory -import com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource -import eventDemo.adapter.infrastructure.event.GameEventBusInRabbinMQ -import eventDemo.adapter.infrastructure.event.GameEventStoreInPostgresql -import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis -import eventDemo.adapter.infrastructure.event.projection.GameProjectionBusInRabbitMQ -import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.projection.GameListRepository -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameStateRepository -import org.koin.core.module.Module -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 redis.clients.jedis.JedisPooled -import redis.clients.jedis.UnifiedJedis -import javax.sql.DataSource - -fun Module.configureDIInfrastructure(config: Configuration) { - // Postgresql config - single { - JedisPooled(config.redisUrl) - } bind UnifiedJedis::class - - single { - HikariConfig() - .apply { - jdbcUrl = config.postgresql.url - username = config.postgresql.username - password = config.postgresql.password - maximumPoolSize = 10 - minimumIdle = 10 - }.let { - HikariDataSource(it) - }.also { datasource -> - registerCallback( - object : ScopeCallback { - override fun onScopeClose(scope: Scope) { - datasource.close() - } - }, - ) - } - } bind DataSource::class - - // RabbitMQ config - factory { - ConnectionFactory().apply { - host = config.rabbitmq.url - port = config.rabbitmq.port - username = config.rabbitmq.username - password = config.rabbitmq.password - } - } - - singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class - singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class - singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class - - single { - GameStateRepositoryInRedis(get()) - } bind GameStateRepository::class - - single { - GameListRepositoryInRedis(get()) - } bind GameListRepository::class -} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt deleted file mode 100644 index d13a1f8..0000000 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eventDemo.configuration.injection - -import eventDemo.libs.event.VersionBuilder -import eventDemo.libs.event.VersionBuilderLocal -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.bind - -fun Module.configureDILibs() { - singleOf(::VersionBuilderLocal) bind VersionBuilder::class -} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt b/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt deleted file mode 100644 index 870077b..0000000 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt +++ /dev/null @@ -1,42 +0,0 @@ -package eventDemo.configuration.ktor - -import eventDemo.configuration.injection.Configuration -import eventDemo.configuration.injection.appKoinModule -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.config.ApplicationConfig -import org.koin.ktor.plugin.Koin -import org.koin.logger.slf4jLogger - -fun Application.configureKoin() { - install(Koin) { - slf4jLogger() - - modules( - appKoinModule( - environment.config.configuration(), - ), - ) - } -} - -fun ApplicationConfig.configuration() = - Configuration( - redisUrl = getProperty("redis.url"), - postgresql = - Configuration.Postgresql( - url = getProperty("postgresql.url"), - username = getProperty("postgresql.username"), - password = getProperty("postgresql.password"), - ), - rabbitmq = - Configuration.RabbitMQ( - url = getProperty("rabbitmq.url"), - port = getProperty("rabbitmq.port").toInt(), - username = getProperty("rabbitmq.username"), - password = getProperty("rabbitmq.password"), - ), - ) - -private fun ApplicationConfig.getProperty(path: String): String = - propertyOrNull(path)?.getString() ?: error("You must set the $path") diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt deleted file mode 100644 index e37e7d3..0000000 --- a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.configuration.route - -import eventDemo.adapter.presenter.query.readGamesList -import eventDemo.adapter.presenter.query.readTheGameState -import io.ktor.server.application.Application -import io.ktor.server.routing.routing -import org.koin.ktor.ext.get - -fun Application.declareHttpGameRoute() { - routing { - readTheGameState(this@declareHttpGameRoute.get()) - readGamesList(this@declareHttpGameRoute.get()) - } -} diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt deleted file mode 100644 index 573f1f3..0000000 --- a/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eventDemo.configuration.route - -import eventDemo.adapter.presenter.query.gameWebSocket -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import io.ktor.server.application.Application -import io.ktor.server.routing.routing -import kotlinx.coroutines.DelicateCoroutinesApi - -@OptIn(DelicateCoroutinesApi::class) -fun Application.declareWebSocketsGameRoute( - playerNotificationListener: PlayerNotificationListener, - commandHandler: GameCommandHandler, -) { - routing { - gameWebSocket(playerNotificationListener, commandHandler) - } -} diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt new file mode 100644 index 0000000..62f64f3 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.auth.application.eventStores + +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.domain.User +import eventDemo.sharedKernel.UserId + +class UserEventStoreRepository( + val eventStore: UserEventStore, +) : UserRepository { + override fun get(id: UserId): User? { + val events = + eventStore + .getStream(id) + .readAll() + if (events.isEmpty()) { + return null + } + return events.let { User.loadFromHistory(it) } + } + + override fun save(user: User) { + eventStore.append(user.recordedEvents) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt new file mode 100644 index 0000000..4f36eb1 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt @@ -0,0 +1,10 @@ +package eventDemo.contexts.auth.application.eventStores + +import eventDemo.contexts.auth.domain.User +import eventDemo.sharedKernel.UserId + +interface UserRepository { + fun get(id: UserId): User? + + fun save(user: User) +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt new file mode 100644 index 0000000..e576aad --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt @@ -0,0 +1,7 @@ +package eventDemo.contexts.auth.application.ports + +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.sharedKernel.UserId + +interface UserEventStore : EventStore diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt new file mode 100644 index 0000000..9f05c56 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.auth.application.ports + +import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection + +interface UserProjectionRepository { + fun getByUsername(username: String): UserProjection? + + fun save(user: UserProjection) + + fun getUserIfPasswordIsValid( + username: String, + rawPassword: String, + ): UserProjection? +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt new file mode 100644 index 0000000..b818559 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt @@ -0,0 +1,39 @@ +package eventDemo.contexts.auth.domain + +import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: UserId, + val username: String, + val password: String, + val version: Int, + val recordedEvents: Set, +) { + companion object { + fun createNewUser( + username: String, + password: String, + ): User = + apply(NewUserCreatedEvent(username, password, version = 1)) + + fun apply(event: NewUserCreatedEvent): User = + User( + id = event.aggregateId, + username = event.username, + password = event.password, + version = event.version, + recordedEvents = setOf(event), + ) + + fun loadFromHistory(events: Set): User? = + events.fold(null as User?) { acc, event -> + when (event) { + is NewUserCreatedEvent -> apply(event) + } + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt new file mode 100644 index 0000000..a269cbc --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.auth.domain.events + +import eventDemo.libs.eventSource.EventId +import eventDemo.sharedKernel.UserId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +class NewUserCreatedEvent( + val username: String, + val password: String, + override val version: Int, + override val createdAt: Instant = Clock.System.now(), + override val aggregateId: UserId = UserId(), +) : UserEvent { + override val eventId: EventId = EventId() +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt new file mode 100644 index 0000000..b3913a6 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt @@ -0,0 +1,8 @@ +package eventDemo.contexts.auth.domain.events + +import eventDemo.libs.eventSource.Event +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +@Serializable +sealed interface UserEvent : Event diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt new file mode 100644 index 0000000..726ccd8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt @@ -0,0 +1,13 @@ +package eventDemo.contexts.auth.infrastructure + +import com.password4j.Hash +import com.password4j.Password + +internal fun hashPassword(password: String): Hash = + Password.hash(password).addRandomSalt().withArgon2() + +internal fun checkPassword( + password: String, + hash: Hash, +): Boolean = + Password.check(password, hash) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt new file mode 100644 index 0000000..c52bb3e --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt @@ -0,0 +1,8 @@ +package eventDemo.contexts.auth.infrastructure.configure + +import io.ktor.server.application.Application + +fun Application.configureAuth() { + configureKtorAuth() + configureAuthRoutes() +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt new file mode 100644 index 0000000..0bc995c --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt @@ -0,0 +1,17 @@ +package eventDemo.contexts.auth.infrastructure.configure + +import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInPostgresql +import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjectionRepositoryInPostgresql +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind + +fun Module.configureAuthDi() { + singleOf(::UserEventStoreRepository) bind UserRepository::class + singleOf(::UserEventStoreInPostgresql) bind UserEventStore::class + singleOf(::UserProjectionRepositoryInPostgresql) bind UserProjectionRepository::class +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt new file mode 100644 index 0000000..6d18a4b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt @@ -0,0 +1,19 @@ +package eventDemo.contexts.auth.infrastructure.configure + +import eventDemo.configuration.configuration +import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.rest.createUserRoute +import eventDemo.contexts.auth.infrastructure.rest.loginRoute +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import org.koin.ktor.ext.get + +fun Application.configureAuthRoutes() { + val userRepository = get() + val userProjectionRepository = get() + routing { + createUserRoute(userRepository) + loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt similarity index 54% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt rename to src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt index 88a7fd2..af9159c 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt @@ -1,24 +1,21 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.auth.infrastructure.configure import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import eventDemo.domain.entity.Player +import eventDemo.configuration.configuration +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection +import eventDemo.sharedKernel.UserId import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.auth.authentication import io.ktor.server.auth.jwt.JWTPrincipal import io.ktor.server.auth.jwt.jwt import io.ktor.server.response.respond -import io.ktor.server.routing.post -import io.ktor.server.routing.routing -import kotlinx.serialization.json.Json import java.util.Date -private const val JWT_ISSUER = "PlayCardGame" - -fun Application.configureSecurity() { - val jwtSecret = environment.config.propertyOrNull("jwt.secret")?.getString() ?: error("You must set a jwt secret") - +fun Application.configureKtorAuth() { + val jwtSecret = environment.config.configuration.jwtSecret authentication { jwt { realm = "Play card game" @@ -29,7 +26,11 @@ fun Application.configureSecurity() { .build(), ) validate { credential -> - if (credential.payload.getClaim("username").asString() != "") { + if (credential.payload + .getClaim("username") + .asString() + .isNotEmpty() + ) { JWTPrincipal(credential.payload) } else { null @@ -40,22 +41,25 @@ fun Application.configureSecurity() { } } } - - routing { - post("login/{username}") { - val username = call.parameters["username"]!! - val player = Player(name = username) - - call.respond(hashMapOf("token" to player.makeJwt(jwtSecret))) - } - } } -fun Player.makeJwt(jwtSecret: String): String = +private const val JWT_ISSUER = "PlayCardGame" + +fun UserProjection.makeJwt(jwtSecret: String): String = + makeJwt(jwtSecret, id, username) + +fun User.makeJwt(jwtSecret: String): String = + makeJwt(jwtSecret, id, username) + +fun makeJwt( + jwtSecret: String, + id: UserId, + username: String, +): String = JWT .create() .withIssuer(JWT_ISSUER) - .withClaim("username", name) - .withPayload(Json.encodeToString(this)) + .withClaim("username", username) + .withClaim("userid", id.toString()) .withExpiresAt(Date(System.currentTimeMillis() + 60000)) .sign(Algorithm.HMAC256(jwtSecret)) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt new file mode 100644 index 0000000..f0248c0 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.auth.infrastructure.persistence.eventStore + +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInMemory +import eventDemo.sharedKernel.UserId + +/** + * A stream to publish and read the user events. + */ +class UserEventStoreInMemory : + UserEventStore, + EventStore by EventStoreInMemory() diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt new file mode 100644 index 0000000..a82f19f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt @@ -0,0 +1,22 @@ +package eventDemo.contexts.auth.infrastructure.persistence.eventStore + +import eventDemo.contexts.auth.application.ports.UserEventStore +import eventDemo.contexts.auth.domain.events.UserEvent +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.json.Json +import javax.sql.DataSource + +/** + * A stream to publish and read the user events. + */ +class UserEventStoreInPostgresql( + dataSource: DataSource, +) : UserEventStore, + EventStore by EventStoreInPostgresql( + dataSource, + { Json.encodeToString(it) }, + { Json.decodeFromString(it) }, + "auth.user_event_stream", + ) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt new file mode 100644 index 0000000..310d2f6 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.auth.infrastructure.persistence.projection + +import eventDemo.sharedKernel.UserId + +data class UserProjection( + val id: UserId, + val username: String, + val password: String, +) diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjectionRepositoryInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjectionRepositoryInPostgresql.kt new file mode 100644 index 0000000..0246234 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjectionRepositoryInPostgresql.kt @@ -0,0 +1,61 @@ +package eventDemo.contexts.auth.infrastructure.persistence.projection + +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.checkPassword +import eventDemo.contexts.auth.infrastructure.hashPassword +import eventDemo.sharedKernel.UserId +import java.util.UUID +import javax.sql.DataSource + +class UserProjectionRepositoryInPostgresql( + val dataSource: DataSource, +) : UserProjectionRepository { + override fun getByUsername(username: String): UserProjection? = + dataSource.connection + .prepareStatement( + """ + select id, username + from auth."user" + where id = ?; + """.trimIndent(), + ).use { + it.setObject(1, username) + it.executeQuery() + }.use { resultSet -> + if (resultSet.next()) { + UserProjection( + id = UserId(UUID.fromString(resultSet.getString("id"))), + username = resultSet.getString("username"), + password = resultSet.getString("password"), + ) + } else { + null + } + } + + override fun save(user: UserProjection) { + dataSource.connection.use { connection -> + connection + .prepareStatement( + """ + insert into auth.user (id, username) + values (?, ?) + """.trimIndent(), + ).use { + it.setObject(1, user.id) + it.setString(2, user.username) + it.executeUpdate() + } + } + } + + override fun getUserIfPasswordIsValid( + username: String, + rawPassword: String, + ): UserProjection? { + val user = getByUsername(username) ?: return null + val isValid = checkPassword(rawPassword, hashPassword(user.password)) + if (!isValid) return null + return user + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt new file mode 100644 index 0000000..c994ced --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.auth.infrastructure.rest + +import eventDemo.contexts.auth.application.ports.UserProjectionRepository +import eventDemo.contexts.auth.infrastructure.configure.makeJwt +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post + +fun Route.loginRoute( + jwtSecret: String, + userProjectionRepository: UserProjectionRepository, +) { + post("login/{username}") { + val username = call.parameters["username"]!! + val rawPassword = call.parameters["password"]!! + + val userProjection = + userProjectionRepository.getUserIfPasswordIsValid(username, rawPassword) + ?: return@post call.respond(HttpStatusCode.BadRequest) + + call.respond(hashMapOf("token" to userProjection.makeJwt(jwtSecret))) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt new file mode 100644 index 0000000..abd1fee --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt @@ -0,0 +1,41 @@ +package eventDemo.contexts.auth.infrastructure.rest + +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.auth.infrastructure.hashPassword +import io.ktor.resources.Resource +import io.ktor.server.auth.authenticate +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/users") +class Users { + @Serializable + @Resource("/create") + class Create( + val username: String, + val password: String, + ) +} + +/** + * API routes to show all games. + */ +fun Route.createUserRoute(userRepository: UserRepository) { + authenticate { + // Create a new User, and return there ID + post { + val passwordHash = hashPassword(it.password) + val user = User.createNewUser(it.username, passwordHash.result) + userRepository.save(user) + call.respond( + object { + val id = user.id.toString() + }, + ) + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt b/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt new file mode 100644 index 0000000..2c205ff --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt @@ -0,0 +1,37 @@ +package eventDemo.contexts.game.application.channels + +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.notification.CommandSubscriber +import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.sharedKernel.UserId +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel + +class GameChannelsSubscriber( + private val eventToNotificationSubscriber: EventToNotificationSubscriber, + private val commandSubscriber: CommandSubscriber, +) { + @DelicateCoroutinesApi + fun subscribePlayerToGameChannels( + gameId: GameId, + userId: UserId, + incomingCommandChannel: ReceiveChannel, + sendNotificationChannel: SendChannel, + ) { + val sub = + eventToNotificationSubscriber.subscribeToEventsAndSendNotification( + gameId = gameId, + currentUserId = userId, + outgoingFrameChannel = sendNotificationChannel, + ) + + commandSubscriber + .subscribe( + currentUserId = userId, + incomingFrameChannel = incomingCommandChannel, + ).invokeOnCompletion { sub.close() } + } +} diff --git a/src/main/kotlin/eventDemo/domain/command/CommandException.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt similarity index 56% rename from src/main/kotlin/eventDemo/domain/command/CommandException.kt rename to src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt index 187d59f..bd8feea 100644 --- a/src/main/kotlin/eventDemo/domain/command/CommandException.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt @@ -1,4 +1,4 @@ -package eventDemo.domain.command +package eventDemo.contexts.game.application.command.handlers class CommandException( override val message: String, diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt new file mode 100644 index 0000000..2fc3068 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand +import eventDemo.contexts.game.domain.game.GameId +import java.util.Collections + +class GameCommandHandlerDispatcher( + private val playCardHandler: PlayCardHandler, + private val readyToPlayHandler: ReadyToPlayHandler, + private val joinTheGameHandler: JoinTheGameHandler, + private val takeCartFromDrawPileHandler: TakeCartFromDrawPileHandler, +) { + companion object { + val lock: MutableMap = Collections.synchronizedMap(mutableMapOf()) + } + + fun dispatch(command: GameCommand) { + synchronized(lock.getOrPut(command.payload.aggregateId) { command.payload.aggregateId.toString() }) { + when (command) { + is JoinTheGameCommand -> joinTheGameHandler.handle(command) + is ReadyToPlayCommand -> readyToPlayHandler.handle(command) + is PlayCardCommand -> playCardHandler.handle(command) + is TakeCartFromDrawPileCommand -> takeCartFromDrawPileHandler.handle(command) + } + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt new file mode 100644 index 0000000..7e9b4bd --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt @@ -0,0 +1,66 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.libs.command.Command +import eventDemo.libs.eventSource.eventStore.VersionConflictException +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlin.reflect.KClass + +sealed interface CommandHandler { + fun handle(command: C) +} + +abstract class GameEventManager( + private val gameRepository: GameRepository, + private val gameEventBus: GameEventBus, +) { + private val logger = KotlinLogging.logger {} + + fun GameCommand.getGame(): Game = + gameRepository.get(payload.aggregateId) ?: error("Game not found") + + fun GameEvent.getGame(): Game = + gameRepository.get(aggregateId) ?: error("Game not found") + + @Throws(VersionConflictException::class) + protected fun Game.saveEvents(): Game { + gameRepository.save(this) + return this + } + + protected fun Game.publishEvents(): Game { + gameEventBus.publish(recordedEvents) + return this + } + + protected fun Game.isStatusOrFail( + kClass: KClass, + message: String, + ): G { + if (kClass.isInstance(this)) { + return this as G + } else { + throw CommandException(message) + } + } + + protected fun retry( + mapAttempts: Int = 5, + block: () -> T, + ): T = + try { + block() + } catch (e: VersionConflictException) { + if (mapAttempts > 0) { + logger.warn { "retry after version conflict (attempts left: $mapAttempts)" } + retry(mapAttempts - 1, block) + } else { + logger.error { "Version conflict retry failed" } + throw e + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt new file mode 100644 index 0000000..c006f22 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameCreated + +/** + * A command to perform an action to play a new card + */ +class JoinTheGameHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, + private val userRepository: UserRepository, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: JoinTheGameCommand) { + val user = userRepository.get(command.userId) ?: error("User with id ${command.userId} doesn't exist") + retry { + command + .getGame() + .isStatusOrFail(GameCreated::class, "The game is started") + .userJoinTheGame( + userId = command.userId, + name = user.username, + ).saveEvents() + .publishEvents() + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt new file mode 100644 index 0000000..c047722 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameStarted + +/** + * A command to perform an action to play a new card + */ +class PlayCardHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: PlayCardCommand) { + command + .getGame() + .isStatusOrFail(GameStarted::class, "The game is not started") + .playTheCard( + card = command.payload.card, + playerId = command.payload.playerId, + chosenColor = command.payload.chosenColor, + ).saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt new file mode 100644 index 0000000..28c21e8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameCreated + +/** + * A command to set as ready to play + */ +class ReadyToPlayHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: ReadyToPlayCommand) { + command + .getGame() + .isStatusOrFail(GameCreated::class, "The game is started") + .setReadyPlayer(command.payload.playerId) + .saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt new file mode 100644 index 0000000..2b7f92f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.application.command.handlers + +import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.GameStarted + +/** + * A command to draw card on draw pile. + * + * Is can be triggered when you cannot play any card in your hand. + */ +class TakeCartFromDrawPileHandler( + gameRepository: GameRepository, + gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus), + CommandHandler { + override fun handle(command: TakeCartFromDrawPileCommand) { + command + .getGame() + .isStatusOrFail(GameStarted::class, "The game is not started") + .playerTakeCartFromDrawPile(command.payload.playerId, 1) + .saveEvents() + .publishEvents() + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt new file mode 100644 index 0000000..3b7ed9b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt @@ -0,0 +1,19 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.libs.command.Command +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GameCommand : Command { + val userId: UserId + val payload: Payload + + @Serializable + sealed interface Payload { + @Serializable(with = GameIdSerializer::class) + val aggregateId: GameId + } +} diff --git a/src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt similarity index 50% rename from src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt rename to src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt index b70d8a6..fe94917 100644 --- a/src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt @@ -1,22 +1,24 @@ -package eventDemo.domain.command.command +package eventDemo.contexts.game.application.command.models -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId import kotlinx.serialization.Serializable /** * A command to perform an action to play a new card */ @Serializable -data class ICantPlayCommand( +data class JoinTheGameCommand( + override val userId: UserId, override val payload: Payload, ) : GameCommand { override val id: CommandId = CommandId() @Serializable data class Payload( + @Serializable(with = GameIdSerializer::class) override val aggregateId: GameId, - override val player: Player, ) : GameCommand.Payload } diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt new file mode 100644 index 0000000..4fc874b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +/** + * A command to perform an action to play a new card + */ +@Serializable +data class PlayCardCommand( + override val userId: UserId, + override val payload: Payload, +) : GameCommand { + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val playerId: Player.PlayerId, + val card: Card, + val chosenColor: Card.Color?, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt new file mode 100644 index 0000000..143bda9 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt @@ -0,0 +1,28 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +/** + * A command to set as ready to play + */ +@Serializable +data class ReadyToPlayCommand( + override val userId: UserId, + override val payload: Payload, +) : GameCommand { + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val playerId: Player.PlayerId, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt new file mode 100644 index 0000000..ed5b3a5 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt @@ -0,0 +1,28 @@ +package eventDemo.contexts.game.application.command.models + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.command.CommandId +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable + +/** + * A command to perform an action to play a new card + */ +@Serializable +data class TakeCartFromDrawPileCommand( + override val userId: UserId, + override val payload: Payload, +) : GameCommand { + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val playerId: Player.PlayerId, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt new file mode 100644 index 0000000..ac52915 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.application.eventStores + +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.libs.eventSource.eventStore.VersionConflictException + +class GameEventStoreRepository( + val eventStore: GameEventStore, +) : GameRepository { + override fun get(id: GameId): Game? { + val events = + eventStore + .getStream(id) + .readAll() + if (events.isEmpty()) { + return null + } + return events.let { Game.loadFromHistory(it) } + } + + @Throws(VersionConflictException::class) + override fun save(game: Game) { + eventStore.append(game.recordedEvents) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt new file mode 100644 index 0000000..66127e3 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt @@ -0,0 +1,20 @@ +package eventDemo.contexts.game.application.eventStores + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameCreated +import eventDemo.contexts.game.domain.game.gameState.GameInit +import eventDemo.libs.eventSource.eventStore.VersionConflictException + +interface GameRepository { + fun get(id: GameId): Game? + + @Throws(VersionConflictException::class) + fun save(game: Game) + + fun getOrCreate(gameId: GameId): Game = + get(gameId) ?: create(gameId) + + fun create(gameId: GameId = GameId()): GameCreated = + GameInit.createNewGame(gameId).also { save(it) } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt new file mode 100644 index 0000000..656ea52 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt @@ -0,0 +1,29 @@ +package eventDemo.contexts.game.application.logging + +import io.github.oshai.kotlinlogging.withLoggingContext + +inline fun withLoggingContext( + vararg pair: Pair, + body: () -> T, +): T = + withLoggingContext( + *pair + .map { + it.first.name to it.second.toString() + }.toTypedArray(), + restorePrevious = true, + body = body, + ) + +// inline fun withLoggingContext( +// vararg pair: Pair, +// body: () -> Unit, +// ) = +// withLoggingContext( +// *pair +// .map { +// it.first.name to it.second.toString() +// }.toTypedArray(), +// restorePrevious = true, +// body = body, +// ) diff --git a/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt new file mode 100644 index 0000000..5a76672 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.application.logging + +enum class LoggingContextKeys { + CurrentUserId, + Notification, + Game, + Event, + Command, +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt new file mode 100644 index 0000000..e1458df --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt @@ -0,0 +1,127 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.notification.models.PilesShuffledNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification +import eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification +import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification +import eventDemo.contexts.game.application.notification.models.PlayerWinNotification +import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification +import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification +import eventDemo.contexts.game.application.notification.models.YourNewCardNotification +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerActionEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import eventDemo.sharedKernel.UserId +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext + +private val logger = KotlinLogging.logger {} + +fun GameEvent.toNotification( + game: Game, + currentUserId: UserId, +): Iterable = + Iterable { + iterator { + context(iterator: SequenceScope) + suspend fun Notification.send() { + withLoggingContext("notification" to (this).toString()) { + logger.info { "Notification sent" } + iterator.yield(this) + } + } + + fun PlayerActionEvent.isFromCurrentUser(): Boolean = + game.players.get(currentUserId).id == playerId + + when (this@toNotification) { + is GameCreatedEvent -> { + // Nothing to send + } + + is DrawFilledWithDiscardEvent -> { + PilesShuffledNotification().send() + } + + is NewPlayerEvent -> { + if (this@toNotification.player.userId != currentUserId) { + PlayerAsJoinTheGameNotification( + player = this@toNotification.player, + ).send() + } else { + WelcomeToTheGameNotification( + players = game.players, + ).send() + } + } + + is CardIsPlayedEvent -> { + PlayerAsPlayACardNotification( + playerId = this@toNotification.playerId, + card = this@toNotification.card, + ).send() + + if (game is GameStarted) { + ItsTheTurnOfNotification( + player = game.nextPlayer, + ).send() + } + } + + is GameStartedEvent -> { + TheGameWasStartedNotification( + hand = + game.players + .get(currentUserId) + .hand.cards, + ).send() + + if (game is GameStarted) { + ItsTheTurnOfNotification(player = game.nextPlayer) + .send() + } + } + + is PlayerHaveDrawCardEvent -> { + if (this@toNotification.isFromCurrentUser()) { + YourNewCardNotification( + cards = this@toNotification.takenCards, + ).send() + } else { + PlayerHavePassNotification( + playerId = this@toNotification.playerId, + ).send() + } + + if (game is GameStarted) { + ItsTheTurnOfNotification(player = game.nextPlayer) + .send() + } + } + + is PlayerReadyEvent -> { + PlayerWasReadyNotification( + playerId = this@toNotification.playerId, + ).send() + } + + is PlayerWinEvent -> { + PlayerWinNotification( + playerId = this@toNotification.playerId, + ).send() + } + } + } + } diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt new file mode 100644 index 0000000..524a455 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt @@ -0,0 +1,72 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Command +import eventDemo.contexts.game.application.logging.LoggingContextKeys.CurrentUserId +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Event +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Game +import eventDemo.contexts.game.application.logging.LoggingContextKeys.Notification +import eventDemo.contexts.game.application.logging.withLoggingContext +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.bus.Bus +import eventDemo.libs.command.CommandUnicityChecker +import eventDemo.sharedKernel.UserId +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.launch + +class EventToNotificationSubscriber( + private val gameEventBus: GameEventBus, + private val gameRepository: GameRepository, +) { + fun subscribeToEventsAndSendNotification( + gameId: GameId, + currentUserId: UserId, + outgoingFrameChannel: SendChannel, + ): Bus.Subscription = + withLoggingContext(CurrentUserId to currentUserId) { + gameEventBus.subscribe { event -> + val game = gameRepository.get(gameId) ?: error("Game not found") + withLoggingContext(Event to event, Game to game) { + event + .toNotification( + game = game, + currentUserId = currentUserId, + ).forEach { notification -> + withLoggingContext(Notification to notification) { + outgoingFrameChannel.trySendBlocking(notification) + } + } + } + } + } +} + +class CommandSubscriber( + private val gameCommandHandlerDispatcher: GameCommandHandlerDispatcher, +) { + private val controller = CommandUnicityChecker() + + @DelicateCoroutinesApi + fun subscribe( + currentUserId: UserId, + incomingFrameChannel: ReceiveChannel, + ): Job = + GlobalScope.launch { + for (command in incomingFrameChannel) { + withLoggingContext(CurrentUserId to currentUserId, Command to command) { + controller.runOnlyOnce(command) { + gameCommandHandlerDispatcher.dispatch(command) + } + } + } + } +} diff --git a/src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt similarity index 60% rename from src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt index 97fb00d..76a9e78 100644 --- a/src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/domain/notification/Notification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt similarity index 60% rename from src/main/kotlin/eventDemo/domain/notification/Notification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt index 258fb8d..b4a4fa0 100644 --- a/src/main/kotlin/eventDemo/domain/notification/Notification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt @@ -1,6 +1,6 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt new file mode 100644 index 0000000..90b5e83 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt @@ -0,0 +1,11 @@ +package eventDemo.contexts.game.application.notification.models + +import eventDemo.libs.serializer.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PilesShuffledNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), +) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt similarity index 61% rename from src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt index c12291b..ade9911 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt similarity index 50% rename from src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt index 20b61e1..0d6782e 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt @@ -1,8 +1,8 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -10,6 +10,6 @@ import java.util.UUID data class PlayerAsPlayACardNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, val card: Card, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt similarity index 53% rename from src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt index 87328e2..ac25fa1 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class PlayerHavePassNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt similarity index 53% rename from src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt index d25b02d..9eb03e4 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class PlayerWasReadyNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt similarity index 53% rename from src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt index bb6f86b..4b04a49 100644 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class PlayerWinNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val player: Player, + val playerId: Player.PlayerId, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt similarity index 55% rename from src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt index bf1508f..34e8a9e 100644 --- a/src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Card -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Card +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class TheGameWasStartedNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val hand: List, + val hand: Set, ) : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt similarity index 61% rename from src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt index e9b1c30..84ad0eb 100644 --- a/src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Player +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt similarity index 54% rename from src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt index 328a4b0..64ad41c 100644 --- a/src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt +++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.notification +package eventDemo.contexts.game.application.notification.models -import eventDemo.domain.entity.Card -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.Card +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,5 +9,5 @@ import java.util.UUID data class YourNewCardNotification( @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), - val card: Card, + val cards: Set, ) : Notification diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt new file mode 100644 index 0000000..5e2c7ff --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt @@ -0,0 +1,6 @@ +package eventDemo.contexts.game.application.ports + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.libs.bus.Bus + +interface GameEventBus : Bus diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt new file mode 100644 index 0000000..4caf8ea --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt @@ -0,0 +1,7 @@ +package eventDemo.contexts.game.application.ports + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.eventStore.EventStore + +interface GameEventStore : EventStore diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt new file mode 100644 index 0000000..23ea606 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt @@ -0,0 +1,14 @@ +package eventDemo.domain.event.projection + +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList + +interface GameListRepository { + fun getList( + limit: Int = 100, + offset: Int = 0, + ): List + + fun save(gameList: GameList) + + fun subscribeToBus() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt new file mode 100644 index 0000000..a5d20bf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt @@ -0,0 +1,6 @@ +package eventDemo.contexts.game.application.ports + +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection +import eventDemo.libs.bus.Bus + +interface GameProjectionBus : Bus diff --git a/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt b/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt new file mode 100644 index 0000000..60d73fc --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt @@ -0,0 +1,55 @@ +package eventDemo.contexts.game.application.projections + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList + +fun GameList.applyEvent(event: GameEvent): GameList = + when (event) { + is GameCreatedEvent -> { + this + } + + is NewPlayerEvent -> { + copy( + players = players + event.player, + status = GameList.Status.OPENING, + ) + } + + is GameStartedEvent -> { + copy( + status = GameList.Status.IS_STARTED, + ) + } + + is PlayerWinEvent -> { + copy( + winners = winners, + status = GameList.Status.FINISH, + ) + } + + is CardIsPlayedEvent -> { + this + } + + is PlayerHaveDrawCardEvent -> { + this + } + + is PlayerReadyEvent -> { + this + } + + is DrawFilledWithDiscardEvent -> { + this + } + } diff --git a/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt b/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt new file mode 100644 index 0000000..3ef905d --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt @@ -0,0 +1,65 @@ +package eventDemo.contexts.game.application.reaction + +import eventDemo.contexts.game.application.command.handlers.GameEventManager +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.logging.LoggingContextKeys +import eventDemo.contexts.game.application.logging.withLoggingContext +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameCreated +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.concurrent.ConcurrentSkipListSet + +class ReactionListener( + gameRepository: GameRepository, + private val gameEventBus: GameEventBus, +) : GameEventManager(gameRepository, gameEventBus) { + private companion object Config { + val registeredListeners = ConcurrentSkipListSet() + } + + private val logger = KotlinLogging.logger { } + + fun subscribeToBus() { + if (registeredListeners.add(gameEventBus)) { + gameEventBus.subscribe { event -> + val game = event.getGame() + withLoggingContext(LoggingContextKeys.Game to game) { + sendStartGameEvent(game) + sendWinnerEvent(game) + } + } + } else { + "${this::class.simpleName} is already init for this bus".let { + logger.error { it } + error(it) + } + } + } + + private fun sendStartGameEvent(game: Game) { + if (game is GameCreated && game.allPlayerIsReady) { + game + .startGame() + .saveEvents() + .publishEvents() + } + } + + private fun sendWinnerEvent(game: Game) { + if (game is GameStarted && game.lastPlayerId != null) { + val lastPlayerWin = + game + .players + .get(game.lastPlayerId) + .hand.size == 0 + if (lastPlayerWin) { + game + .playerWin(game.lastPlayerId) + .saveEvents() + .publishEvents() + } + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt new file mode 100644 index 0000000..323b528 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt @@ -0,0 +1,31 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Card.Color +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * An [GameEvent] to represent a played card. + */ +@Serializable +data class CardIsPlayedEvent( + override val aggregateId: GameId, + val card: Card, + override val playerId: Player.PlayerId, + val chosenColor: Color? = null, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() + + val theColorCard get() = if (card is Card.CardWithColor) card.color else chosenColor +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt new file mode 100644 index 0000000..e4638a4 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * When the Pile are shuffled after the draw pille was empty + */ +@Serializable +class DrawFilledWithDiscardEvent( + override val aggregateId: GameId, + val newDrawPile: DrawPile, + val newDiscardPile: DiscardPile, + override val version: Int, +) : GameEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt new file mode 100644 index 0000000..f3f26bf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt @@ -0,0 +1,22 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when all players are ready. + */ +@Serializable +data class GameCreatedEvent( + override val aggregateId: GameId, + override val version: Int, +) : GameEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt new file mode 100644 index 0000000..d97881a --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.libs.eventSource.Event +import eventDemo.libs.eventSource.EventId +import kotlinx.serialization.Serializable + +/** + * An [Event] of a Game. + */ +@Serializable +sealed interface GameEvent : Event { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId + + @Serializable(with = GameIdSerializer::class) + override val aggregateId: GameId + override val version: Int +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt new file mode 100644 index 0000000..6d3da65 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt @@ -0,0 +1,32 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerHand +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when all players are ready. + */ +@Serializable +data class GameStartedEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + val firstPlayer: Player.PlayerId, + val playersHans: Map, + val drawPile: DrawPile, + val discardPile: DiscardPile, + override val version: Int, +) : GameEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt new file mode 100644 index 0000000..ad39c41 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * An [GameEvent] to represent a new player joining the game. + */ +@Serializable +data class NewPlayerEvent( + override val aggregateId: GameId, + val player: Player, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + override val playerId: Player.PlayerId get() = player.id + + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt new file mode 100644 index 0000000..9068b3f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.Player +import kotlinx.serialization.Serializable + +@Serializable +sealed interface PlayerActionEvent : GameEvent { + val playerId: Player.PlayerId +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt new file mode 100644 index 0000000..df769ab --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt @@ -0,0 +1,29 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when a player can play. + */ +@Serializable +data class PlayerHaveDrawCardEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + override val playerId: Player.PlayerId, + val takenCards: Set, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt new file mode 100644 index 0000000..45f9915 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when a player is ready. + */ +@Serializable +data class PlayerReadyEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + override val playerId: Player.PlayerId, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt new file mode 100644 index 0000000..4d9f3fd --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt @@ -0,0 +1,27 @@ +package eventDemo.contexts.game.domain.events + +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer +import eventDemo.libs.eventSource.EventId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * This [GameEvent] is sent when a player is ready. + */ +@Serializable +data class PlayerWinEvent( + override val aggregateId: GameId, + @Serializable(with = PlayerIdSerializer::class) + override val playerId: Player.PlayerId, + override val version: Int, +) : GameEvent, + PlayerActionEvent { + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(UUID.randomUUID()) + override val createdAt: Instant = Clock.System.now() +} diff --git a/src/main/kotlin/eventDemo/domain/entity/Card.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt similarity index 73% rename from src/main/kotlin/eventDemo/domain/entity/Card.kt rename to src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt index b6048b6..4cd3bf1 100644 --- a/src/main/kotlin/eventDemo/domain/entity/Card.kt +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt @@ -1,6 +1,6 @@ -package eventDemo.domain.entity +package eventDemo.contexts.game.domain.game -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.UUID @@ -23,7 +23,7 @@ sealed interface Card { Green, } - sealed interface ColorCard : Card { + sealed interface CardWithColor : Card { val color: Color } @@ -38,11 +38,14 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Card, - ColorCard { + CardWithColor { init { if (number > 9) error("Card number cannot be greater of 9") if (number < 0) error("Card number cannot be lower of 0") } + + override fun toString(): String = + "Numeric Card $number $color" } sealed interface Special : Card @@ -57,7 +60,10 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - ColorCard + CardWithColor { + override fun toString(): String = + "Revert Card $color" + } sealed interface PassTurnCard : Card @@ -71,8 +77,11 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - ColorCard, - PassTurnCard + CardWithColor, + PassTurnCard { + override fun toString(): String = + "Pass Card $color" + } /** * A play card to force the next player to take 2 card and pass the turn. @@ -84,10 +93,13 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - ColorCard, - PassTurnCard + CardWithColor, + PassTurnCard { + override fun toString(): String = + "Plus2 Card $color" + } - sealed interface AllColorCard : Card + sealed interface CardWith4Color : Card /** * A play card to force the next player to take 4 card and pass the turn. @@ -98,8 +110,11 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - AllColorCard, - PassTurnCard + CardWith4Color, + PassTurnCard { + override fun toString(): String = + "Plus4 Card" + } /** * A play card to change the color. @@ -110,5 +125,8 @@ sealed interface Card { @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : Special, - AllColorCard + CardWith4Color { + override fun toString(): String = + "Change color Card" + } } diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt new file mode 100644 index 0000000..82db786 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.domain.game + +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class DiscardPile( + val cards: Set = emptySet(), +) { + fun withNewCard(card: Card): DiscardPile = + DiscardPile(cards + card) + + val topCard: Card? get() = cards.lastOrNull() + + val topCardColor: Card.Color? get() = topCard?.let { if (it is Card.CardWithColor) it.color else null } + + val size: Int get() = cards.size +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt new file mode 100644 index 0000000..96ca90f --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt @@ -0,0 +1,33 @@ +package eventDemo.contexts.game.domain.game + +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class DrawPile( + val cards: Set = emptySet(), +) { + val size: Int get() = cards.size + + fun take(number: Int): Pair> = + cards.drop(number).toDrawPile() to cards.take(number).toSet() + + val remainingCards + get() = cards.size + + fun shuffled(): DrawPile = + cards.shuffled().toDrawPile() + + val firstCard get() = cards.first() + + fun generateValidDrawPile(): DrawPile = + if (cards.first() is Card.CardWith4Color) { + DrawPile(setOf(cards.first()) + cards.drop(1)) + .generateValidDrawPile() + } else { + this + } +} + +private fun List.toDrawPile(): DrawPile = + DrawPile(this.toSet()) diff --git a/src/main/kotlin/eventDemo/domain/entity/GameId.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt similarity index 56% rename from src/main/kotlin/eventDemo/domain/entity/GameId.kt rename to src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt index 4b364dc..63401b9 100644 --- a/src/main/kotlin/eventDemo/domain/entity/GameId.kt +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt @@ -1,7 +1,7 @@ -package eventDemo.domain.entity +package eventDemo.contexts.game.domain.game -import eventDemo.configuration.serializer.GameIdSerializer -import eventDemo.libs.event.AggregateId +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.serialization.Serializable import java.util.UUID @@ -9,8 +9,9 @@ import java.util.UUID * An [AggregateId] for a game. */ @JvmInline -@Serializable(with = GameIdSerializer::class) +@Serializable value class GameId( + @Serializable(with = UUIDSerializer::class) override val id: UUID = UUID.randomUUID(), ) : AggregateId { override fun toString(): String = diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt new file mode 100644 index 0000000..c99b1d6 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt @@ -0,0 +1,62 @@ +package eventDemo.contexts.game.domain.game + +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.helpers.withReplacedValue +import eventDemo.libs.serializer.UUIDSerializer +import eventDemo.sharedKernel.UserId +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class Player( + val name: String, + val userId: UserId, + val hand: PlayerHand = PlayerHand(), + val id: PlayerId = PlayerId(UUID.randomUUID()), +) { + @JvmInline + @Serializable + value class PlayerId( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + ) : AggregateId { + override fun toString(): String = + id.toString() + } +} + +@Serializable +class PlayerList( + val players: Set = emptySet(), +) : Set by players { + fun get(id: Player.PlayerId): Player = + find { it.id == id } ?: error("no player with id $id") + + fun get(id: UserId): Player = + find { it.userId == id } ?: error("no player with userId $id") + + fun withNewCardOnPlayerHand( + playerId: Player.PlayerId, + cards: Set, + ): PlayerList = + players + .withReplacedValue(get(playerId)) { + it.copy( + hand = it.hand.withNewCards(cards), + ) + }.let { PlayerList(it) } + + fun withDropCardOnPlayerHand( + playerId: Player.PlayerId, + card: Card, + ): PlayerList = + players + .withReplacedValue(get(playerId)) { + it.copy( + hand = it.hand.withoutTheCards(setOf(card)), + ) + }.let { PlayerList(it) } + + operator fun plus(player: Player): PlayerList = + PlayerList(players + player) +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt new file mode 100644 index 0000000..7ddf68b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.domain.game + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class PlayerHand( + val cards: Set = emptySet(), +) { + fun withNewCards(newCards: Set): PlayerHand = + PlayerHand(cards + newCards) + + fun withoutTheCards(newCards: Set): PlayerHand = + PlayerHand(cards - newCards) + + val size: Int get() = + cards.size +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt new file mode 100644 index 0000000..d612630 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt @@ -0,0 +1,68 @@ +package eventDemo.contexts.game.domain.game.errors + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.gameState.Deck +import eventDemo.contexts.game.domain.game.gameState.Game + +abstract class GameException( + message: String, +) : Exception(message) + +abstract class IllegalActionException( + message: String, +) : GameException(message) + +class ItsNotTheTurnException( + val playerId: Player.PlayerId, +) : IllegalActionException("It is not the turn of the player") { + constructor(playerId: Player.PlayerId, event: GameEvent) : this(playerId) +} + +class TheCardIsAColorCardException( + val playerId: Player.PlayerId, +) : IllegalActionException("The card is a color card") + +class TheCardHasNoColorException( + val playerId: Player.PlayerId, +) : IllegalActionException("The card has no color, you must chose a color") + +class ThePlayerHasRemainingCardsException( + val player: Player, +) : IllegalActionException("The player has remaining cards") + +class ThePlayerHasAlreadyWinException( + val playerId: Player.PlayerId, +) : IllegalActionException("The player has already win") + +class ThePlayerIsNotInTheGameException( + val playerId: Player.PlayerId, +) : IllegalActionException("The player is not in the game") + +class ThePlayerMustPlayACardException( + val playerId: Player.PlayerId, + val playableCards: Set, +) : IllegalActionException("The player must be play a card") + +class NeedMorePlayersToStartGameException( + val players: PlayerList, +) : IllegalActionException("You cannot start a game with less than 2 players!") + +class AllPlayerNotReadyException( + val players: PlayerList, +) : IllegalActionException("All players not ready!") + +class DeckMissingCardsException( + val players: PlayerList, + deck: Deck, +) : IllegalActionException("The deck missing cards") + +class InconsistentGameException( + val game: Game, +) : GameException("Inconsistent game state") + +class InconsistentEventVersionException( + val game: Set, +) : GameException("Inconsistent event version") diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt new file mode 100644 index 0000000..9df0565 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt @@ -0,0 +1,81 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.errors.GameException +import eventDemo.contexts.game.domain.game.errors.InconsistentEventVersionException + +sealed interface Game { + val aggregateId: GameId + val players: PlayerList + + /** + * On each modification, an event is put in their + */ + val recordedEvents: Set + val version: Int + + enum class Direction { + CLOCKWISE, + COUNTER_CLOCKWISE, + ; + + fun revert(): Direction = + if (this === CLOCKWISE) { + COUNTER_CLOCKWISE + } else { + CLOCKWISE + } + } + + companion object { + fun loadFromHistory(events: Set): Game = + events + .fold(GameInit(events.first().aggregateId)) { game: Game, event -> + game.run { + when (event) { + is GameCreatedEvent if this is GameInit -> applyEvent(event) + is GameCreatedEvent -> error("Game is already created") + is NewPlayerEvent if this is GameCreated -> applyEvent(event) + is NewPlayerEvent -> error("Game is already stared") + is PlayerReadyEvent if this is GameCreated -> applyEvent(event) + is PlayerReadyEvent -> error("Game is already stared") + is GameStartedEvent if this is GameCreated -> applyEvent(event) + is GameStartedEvent -> error("Game is already started") + is CardIsPlayedEvent if this is GameStarted -> applyEvent(event) + is CardIsPlayedEvent -> error("Game is end") + is PlayerHaveDrawCardEvent if this is GameStarted -> applyEvent(event) + is PlayerHaveDrawCardEvent -> error("Game is end") + is PlayerWinEvent if this is GameStarted -> applyEvent(event) + is PlayerWinEvent -> error("Game is end") + is DrawFilledWithDiscardEvent if this is GameStarted -> applyEvent(event) + is DrawFilledWithDiscardEvent -> error("Game is end") + } + } + }.let { + when (it) { + is GameInit -> it + is GameCreated -> it.copy(recordedEvents = emptySet()) + is GameEnded -> it.copy(recordedEvents = emptySet()) + is GameStarted -> it.copy(recordedEvents = emptySet()) + } + } + } +} + +internal fun T.checkState( + block: (T) -> Boolean, + exception: (T) -> GameException, +): T { + if (!block(this)) throw exception(this) + return this +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt new file mode 100644 index 0000000..49ed688 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt @@ -0,0 +1,179 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerHand +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.errors.AllPlayerNotReadyException +import eventDemo.contexts.game.domain.game.errors.DeckMissingCardsException +import eventDemo.contexts.game.domain.game.errors.NeedMorePlayersToStartGameException +import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException +import eventDemo.sharedKernel.UserId + +data class GameCreated( + override val aggregateId: GameId, + override val players: PlayerList = PlayerList(), + val playersStatus: Map = emptyMap(), + override val recordedEvents: Set, + override val version: Int, +) : Game { + val allPlayerIsReady: Boolean + get() { + return playersStatus.isNotEmpty() && playersStatus.values.all { it == PlayerStatus.Ready } + } + + fun startGame(deck: Deck = newDeck().shuffleDeck()): GameStarted { + val (drawPile, discardPile, playersHands) = + initPiles(deck) + .let { (drawPile, discardPile) -> + createHandsFromDrawPile(drawPile) + .let { (drawPile, playersHands) -> + Triple(drawPile, discardPile, playersHands) + } + } + + return GameStartedEvent( + aggregateId = aggregateId, + firstPlayer = players.randomPlayer().id, + version = version + 1, + drawPile = drawPile, + discardPile = discardPile, + playersHans = playersHands, + ).checkState( + { players.size > 1 }, + { NeedMorePlayersToStartGameException(players) }, + ).checkState( + { allPlayerIsReady }, + { AllPlayerNotReadyException(players) }, + ).checkState( + { deck.size == 108 }, + { DeckMissingCardsException(players, deck) }, + ).also { if (it.drawPile.size + it.discardPile.size + playersHands.values.sumOf { it.size } != 108) error("missing cards!") } + .run(::applyEvent) + } + + private fun initPiles(deck: Set): Pair = + DrawPile(deck) + .generateValidDrawPile() + .take(1) + .let { (draw, cards) -> + draw to DiscardPile(cards) + } + + private fun createHandsFromDrawPile(drawPile: DrawPile) = + players + .map { it.id } + .fold(Pair(drawPile, emptyMap())) { (drawAcc, handsAcc), playerId -> + drawAcc + .take(7) + .let { (draw, hand) -> + Pair( + draw, + handsAcc + (playerId to PlayerHand(hand)), + ) + } + } + + fun userJoinTheGame( + userId: UserId, + name: String, + ): GameCreated { + if (players.map { it.userId }.contains(userId)) { + throw IllegalStateException("User $userId already in party") + } + + val player = Player(name, userId) + + return applyEvent( + NewPlayerEvent( + aggregateId = aggregateId, + player = player, + version = version + 1, + ), + ) + } + + fun setReadyPlayer(playerId: Player.PlayerId): GameCreated { + if (!players.map { it.id }.contains(playerId)) { + throw ThePlayerIsNotInTheGameException(playerId) + } + + return PlayerReadyEvent(aggregateId, playerId, version + 1) + .run(::applyEvent) + } + + internal fun applyEvent(event: NewPlayerEvent): GameCreated = + copy( + players = players + (event.player), + playersStatus = playersStatus + (event.player.id to PlayerStatus.Waiting), + recordedEvents = recordedEvents + event, + version = version + 1, + ) + + internal fun applyEvent(event: PlayerReadyEvent): GameCreated = + copy( + playersStatus = playersStatus + (event.playerId to PlayerStatus.Ready), + recordedEvents = recordedEvents + event, + version = version + 1, + ) + + internal fun applyEvent(event: GameStartedEvent): GameStarted = + GameStarted( + aggregateId = event.aggregateId, + players = + players + .map { + it.copy(hand = event.playersHans[it.id] ?: error("Player ${it.id} not found")) + }.let { PlayerList(it.toSet()) }, + lastPlayerId = null, + nextPlayerId = event.firstPlayer, + drawPile = event.drawPile, + discardPile = event.discardPile, + version = event.version + 1, + recordedEvents = recordedEvents + event, + currentColor = event.discardPile.topCardColor ?: error("The discard pile was not initialized!"), + ) + + enum class PlayerStatus { + Ready, + Waiting, + } +} + +typealias Deck = Set + +fun newDeck(): Deck = + listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green) + .flatMap { color -> + ((0..9) + (1..9)).map { Card.NumericCard(it, color) } + + (1..2).map { Card.Plus2Card(color) } + + (1..2).map { Card.ReverseCard(color) } + + (1..2).map { Card.PassCard(color) } + }.let { + it + (1..4).map { Card.Plus4Card() } + }.let { + it + (1..4).map { Card.ChangeColorCard() } + }.toSet() + +fun Set.shuffleDeck(): Set { + if (isDisabled) return this + return shuffled().toSet() +} + +private fun PlayerList.randomPlayer(): Player { + if (isDisabled) return first() + return random() +} + +private var isDisabled = false + +fun disableRandomForTest() { + isDisabled = true +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt new file mode 100644 index 0000000..1c8361a --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt @@ -0,0 +1,20 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList + +data class GameEnded( + override val aggregateId: GameId, + override val players: PlayerList, + val playerWins: Set = emptySet(), + override val version: Int, + override val recordedEvents: Set, +) : Game { + init { + if (!players.map { it.id }.containsAll(playerWins)) { + throw IllegalArgumentException("Player ${players.map { it.id }} were not in players") + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt new file mode 100644 index 0000000..939d008 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt @@ -0,0 +1,28 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.GameCreatedEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.PlayerList + +data class GameInit( + override val aggregateId: GameId, +) : Game { + override val players: PlayerList = PlayerList() + override var recordedEvents: Set = emptySet() + + // 0 = no events; not persisted; not really exist. + override var version: Int = 0 + + companion object { + fun createNewGame(gameId: GameId = GameId()): GameCreated { + val event = GameCreatedEvent(gameId, 1) + return GameInit(event.aggregateId).run { + event.run(::applyEvent) + } + } + } + + internal fun applyEvent(event: GameCreatedEvent): GameCreated = + GameCreated(aggregateId, recordedEvents = setOf(event), version = event.version) +} diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt new file mode 100644 index 0000000..5e07153 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt @@ -0,0 +1,264 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.events.PlayerActionEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.Card.Color +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.errors.InconsistentGameException +import eventDemo.contexts.game.domain.game.errors.ItsNotTheTurnException +import eventDemo.contexts.game.domain.game.errors.TheCardHasNoColorException +import eventDemo.contexts.game.domain.game.errors.TheCardIsAColorCardException +import eventDemo.contexts.game.domain.game.errors.ThePlayerHasAlreadyWinException +import eventDemo.contexts.game.domain.game.errors.ThePlayerHasRemainingCardsException +import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException +import eventDemo.contexts.game.domain.game.errors.ThePlayerMustPlayACardException +import eventDemo.contexts.game.domain.game.gameState.Game.Direction + +fun PlayerList.nextPlayerTurn( + lastPlayerId: Player.PlayerId, + direction: Direction, +): Player.PlayerId { + val lastPlayer = get(lastPlayerId) + val playersLastTurn = filter { it.hand.cards.isNotEmpty() || it == lastPlayer } + + return playersLastTurn + .indexOf(lastPlayer) + .let { lastPlayerIndex -> + if (direction == Direction.CLOCKWISE) { + if (lastPlayerIndex == playersLastTurn.size - 1) { + 0 + } else { + lastPlayerIndex + 1 + } + } else { + if (lastPlayerIndex == 0) { + playersLastTurn.size - 1 + } else { + lastPlayerIndex - 1 + } + } + }.let { nextPlayerIndex -> elementAt(nextPlayerIndex).id } +} + +data class GameStarted( + override val aggregateId: GameId, + override val players: PlayerList, + val drawPile: DrawPile, + val discardPile: DiscardPile, + val lastPlayerId: Player.PlayerId?, + val nextPlayerId: Player.PlayerId, + val currentColor: Color, + val playedTurnHistory: List = emptyList(), + val direction: Direction = Direction.CLOCKWISE, + val playerWins: Set = emptySet(), + override val version: Int, + override val recordedEvents: Set, +) : Game { + val playersInGame by lazy { players.filter { it.hand.cards.isNotEmpty() } } + + val lastPlayedCard: Card? by lazy { discardPile.topCard } + + data class History( + val playerId: Player.PlayerId, + val event: GameEvent, + val direction: Direction, + ) + + val lastPlayer: Player? by lazy { lastPlayerId?.let { players.get(it) } } + + val nextPlayer: Player by lazy { players.get(nextPlayerId) } + + fun canBePlayThisCard(card: Card): Boolean { + val cardOnBoard = discardPile.topCard ?: return false + return when (cardOnBoard) { + is Card.NumericCard -> { + when (card) { + is Card.CardWith4Color -> true + is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color + is Card.CardWithColor -> card.color == cardOnBoard.color + } + } + + is Card.ReverseCard -> { + when (card) { + is Card.ReverseCard -> true + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == cardOnBoard.color + } + } + + is Card.PassCard -> { + when (card) { + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == cardOnBoard.color + } + } + + is Card.ChangeColorCard -> { + when (card) { + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == currentColor + } + } + + is Card.Plus2Card -> { + when (card) { + is Card.Plus2Card -> true + ::isPlayedLastTurn -> false + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == currentColor + } + } + + is Card.Plus4Card -> { + when (card) { + is Card.Plus4Card -> true + ::isPlayedLastTurn -> false + is Card.CardWith4Color -> true + is Card.CardWithColor -> card.color == currentColor + } + } + } + } + + fun playableCards(playerId: Player.PlayerId): Set = + players + .get(playerId) + .hand + .cards + .filter(::canBePlayThisCard) + .toSet() + + fun playTheCard( + playerId: Player.PlayerId, + card: Card, + chosenColor: Color? = null, + ): GameStarted = + CardIsPlayedEvent(aggregateId, card, playerId, chosenColor, version + 1) + .checkPlayerTurn() + .checkState({ + (card is Card.CardWithColor && chosenColor == null) || card is Card.CardWith4Color + }, { TheCardIsAColorCardException(playerId) }) + .checkState({ + (card is Card.CardWith4Color && chosenColor != null) || card is Card.CardWithColor + }, { TheCardHasNoColorException(playerId) }) + .run(::applyEvent) + + internal fun applyEvent(event: CardIsPlayedEvent): GameStarted = + run { + val nextDirectionAfterPlay = + when (event.card) { + is Card.ReverseCard -> direction.revert() + else -> direction + } + + val color = + when (event.card) { + is Card.CardWithColor -> event.card.color + is Card.CardWith4Color -> event.chosenColor!! + } + + copy( + players = players.withDropCardOnPlayerHand(event.playerId, event.card), + discardPile = discardPile.withNewCard(card = event.card), + currentColor = color, + lastPlayerId = event.playerId, + nextPlayerId = players.nextPlayerTurn(event.playerId, nextDirectionAfterPlay), + playedTurnHistory = playedTurnHistory - History(event.playerId, event, direction), + direction = nextDirectionAfterPlay, + version = event.version, + recordedEvents = recordedEvents + event, + ) + } + + fun playerTakeCartFromDrawPile( + playerId: Player.PlayerId, + number: Int, + ): GameStarted { + val takenCards = drawPile.take(number).second + return PlayerHaveDrawCardEvent(aggregateId, playerId, takenCards, version + 1) + .checkPlayerTurn() + .checkState({ + playableCards(playerId).isEmpty() + }, { + ThePlayerMustPlayACardException(playerId, playableCards(playerId)) + }) + .run(::applyEvent) + .run { + val missingCardsCount = number - takenCards.size + if (missingCardsCount > 0) { + fillDrawWithDiscard() + .playerTakeCartFromDrawPile(playerId, missingCardsCount) + } else { + this + } + } + } + + internal fun applyEvent(event: PlayerHaveDrawCardEvent): GameStarted = + copy( + players = players.withNewCardOnPlayerHand(event.playerId, event.takenCards), + drawPile = drawPile.take(event.takenCards.size).first, + lastPlayerId = event.playerId, + nextPlayerId = players.nextPlayerTurn(event.playerId, direction), + version = event.version, + recordedEvents = recordedEvents + event, + ) + + /** + * Filling the draw pile with the discard pile while excluding the top card + */ + private fun fillDrawWithDiscard(): GameStarted = + run { + val topCard = discardPile.topCard ?: throw InconsistentGameException(this) + DrawPile(discardPile.cards - topCard).shuffled() to DiscardPile(setOf(topCard)) + }.let { (newDrawPile, newDiscardPile) -> + DrawFilledWithDiscardEvent(aggregateId, newDrawPile, newDiscardPile, version + 1) + .run(::applyEvent) + } + + internal fun applyEvent(event: DrawFilledWithDiscardEvent): GameStarted = + copy( + drawPile = event.newDrawPile, + discardPile = event.newDiscardPile, + version = event.version, + recordedEvents = recordedEvents + event, + ) + + fun playerWin(playerId: Player.PlayerId): GameStarted = + PlayerWinEvent(aggregateId, playerId, version + 1) + .checkState({ + players + .get(playerId) + .hand.cards + .isEmpty() + }, { ThePlayerHasRemainingCardsException(players.get(playerId)) }) + .checkState({ playerWins.contains(playerId) }, { ThePlayerHasAlreadyWinException(playerId) }) + .checkState({ !players.map { it.id }.contains(playerId) }, { ThePlayerIsNotInTheGameException(playerId) }) + .run(::applyEvent) + + internal fun applyEvent(event: PlayerWinEvent): GameStarted = + copy( + playerWins = playerWins + event.playerId, + version = event.version, + recordedEvents = recordedEvents + event, + ) + + private fun T.checkPlayerTurn(): T = + checkState( + { nextPlayer.id == playerId }, + { ItsNotTheTurnException(playerId, this) }, + ) + + private fun isPlayedLastTurn(card: Card): Boolean = + (playedTurnHistory.last().event as? CardIsPlayedEvent)?.card == card +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt new file mode 100644 index 0000000..4bb8d0b --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt @@ -0,0 +1,17 @@ +package eventDemo.contexts.game.infrastructure.configuration.injections.application + +import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber +import eventDemo.contexts.game.application.reaction.ReactionListener +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind + +fun Module.configureGameDIApplication() { + configureDICommandHandlers() + + singleOf(::ReactionListener) + singleOf(::EventToNotificationSubscriber) + singleOf(::GameEventStoreRepository) bind GameRepository::class +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt new file mode 100644 index 0000000..047b4bc --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt @@ -0,0 +1,18 @@ +package eventDemo.contexts.game.infrastructure.configuration.injections.application + +import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler +import eventDemo.contexts.game.application.command.handlers.PlayCardHandler +import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler +import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf + +/** + * Configure all actions + */ +fun Module.configureDICommandHandlers() { + singleOf(::PlayCardHandler) + singleOf(::ReadyToPlayHandler) + singleOf(::JoinTheGameHandler) + singleOf(::TakeCartFromDrawPileHandler) +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt new file mode 100644 index 0000000..e3438da --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure + +import eventDemo.contexts.game.application.channels.GameChannelsSubscriber +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.notification.CommandSubscriber +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ +import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInPostgresql +import eventDemo.contexts.game.infrastructure.persistence.projections.GameListRepositoryInMemory +import eventDemo.contexts.game.infrastructure.persistence.projections.bus.GameProjectionBusInRabbitMQ +import eventDemo.domain.event.projection.GameListRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind + +fun Module.configureGameDIInfrastructure() { + singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class + singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class + singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class + singleOf(::CommandSubscriber) + singleOf(::GameChannelsSubscriber) + singleOf(::GameCommandHandlerDispatcher) + singleOf(::GameListRepositoryInMemory) bind GameListRepository::class +} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt similarity index 96% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt index 91c3c12..7620756 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.game.infrastructure.configuration.ktor import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt similarity index 55% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt index 363996f..89da12e 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt @@ -1,12 +1,14 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.game.infrastructure.configuration.ktor -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.CommandIdSerializer -import eventDemo.configuration.serializer.GameIdSerializer -import eventDemo.configuration.serializer.PlayerIdSerializer -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer import eventDemo.libs.command.CommandId +import eventDemo.libs.eventSource.EventId +import eventDemo.libs.serializer.UUIDSerializer import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install @@ -29,6 +31,7 @@ fun defaultJsonSerializer(): Json = SerializersModule { contextual(UUID::class) { UUIDSerializer } contextual(GameId::class) { GameIdSerializer } + contextual(EventId::class) { EventIdSerializer } contextual(CommandId::class) { CommandIdSerializer } contextual(Player.PlayerId::class) { PlayerIdSerializer } } diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt new file mode 100644 index 0000000..1c7af65 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.infrastructure.configuration.ktor + +import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener +import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener +import io.ktor.server.application.Application +import org.koin.ktor.plugin.koin + +fun Application.configureUno() { + configureSerialization() + + configureWebSockets() + declareWebSocketsRoute() + + configureHttpRouting() + declareHttpGameRoute() + + koin().run { + configureProjectionListener() + configureReactionListener() + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt similarity index 86% rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt index e97f11a..422e759 100644 --- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.ktor +package eventDemo.contexts.game.infrastructure.configuration.ktor import io.ktor.server.application.Application import io.ktor.server.application.install diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt new file mode 100644 index 0000000..1fdcabf --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.game.infrastructure.configuration.ktor + +import eventDemo.contexts.game.infrastructure.rest.gamesListRoute +import eventDemo.contexts.game.infrastructure.rest.getFullNotificationsRoute +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import org.koin.ktor.ext.get as getDi + +fun Application.declareHttpGameRoute() { + routing { + gamesListRoute(getDi()) + getFullNotificationsRoute(getDi(), getDi()) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt new file mode 100644 index 0000000..7932bc4 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt @@ -0,0 +1,16 @@ +package eventDemo.contexts.game.infrastructure.configuration.ktor + +import eventDemo.contexts.game.infrastructure.websocket.gameWebSocket +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import kotlinx.coroutines.DelicateCoroutinesApi +import org.koin.ktor.ext.get as getDi + +@OptIn(DelicateCoroutinesApi::class) +fun Application.declareWebSocketsRoute() { + routing { + gameWebSocket( + getDi(), + ) + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt new file mode 100644 index 0000000..5f39ed8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.infrastructure.configuration.listener + +import eventDemo.domain.event.projection.GameListRepository +import org.koin.core.Koin + +fun Koin.configureProjectionListener() { + get() + .subscribeToBus() +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt new file mode 100644 index 0000000..549214d --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt @@ -0,0 +1,9 @@ +package eventDemo.contexts.game.infrastructure.configuration.listener + +import eventDemo.contexts.game.application.reaction.ReactionListener +import org.koin.core.Koin + +fun Koin.configureReactionListener() { + get() + .subscribeToBus() +} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt similarity index 68% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt index d72487d..c202cc9 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt @@ -1,7 +1,7 @@ -package eventDemo.adapter.infrastructure.event +package eventDemo.contexts.game.infrastructure.persistence.eventBus -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.event.GameEvent +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInMemory import java.util.UUID diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt similarity index 77% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt index 03baba9..7c25a0a 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt @@ -1,8 +1,8 @@ -package eventDemo.adapter.infrastructure.event +package eventDemo.contexts.game.infrastructure.persistence.eventBus import com.rabbitmq.client.ConnectionFactory -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.event.GameEvent +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInRabbitMQ import kotlinx.serialization.json.Json diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt new file mode 100644 index 0000000..ea44440 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt @@ -0,0 +1,14 @@ +package eventDemo.contexts.game.infrastructure.persistence.eventStore + +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInMemory + +/** + * A stream to publish and read the played card event. + */ +class GameEventStoreInMemory : + GameEventStore, + EventStore by EventStoreInMemory() diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt new file mode 100644 index 0000000..a25e219 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt @@ -0,0 +1,22 @@ +package eventDemo.contexts.game.infrastructure.persistence.eventStore + +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.events.GameEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.eventSource.eventStore.EventStore +import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql +import kotlinx.serialization.json.Json +import javax.sql.DataSource + +/** + * A stream to publish and read the played card event. + */ +class GameEventStoreInPostgresql( + dataSource: DataSource, +) : GameEventStore, + EventStore by EventStoreInPostgresql( + dataSource, + { Json.encodeToString(it) }, + { Json.decodeFromString(it) }, + "game.game_event_stream", + ) diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt new file mode 100644 index 0000000..19ff6ae --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt @@ -0,0 +1,49 @@ +package eventDemo.contexts.game.infrastructure.persistence.projections + +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.application.projections.applyEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList +import eventDemo.domain.event.projection.GameListRepository +import io.github.oshai.kotlinlogging.withLoggingContext + +/** + * Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus]. + */ +class GameListRepositoryInMemory( + val gameEventStore: GameEventStore, + val projectionBus: GameProjectionBus, + val eventBus: GameEventBus, +) : GameListRepository { + val projections: MutableMap = mutableMapOf() + + override fun getList( + limit: Int, + offset: Int, + ): List = + projections + .values + .drop(offset) + .take(limit) + + override fun save(gameList: GameList) { + projections[gameList.aggregateId] = gameList + } + + override fun subscribeToBus() { + // On new event was received, build projection and publish it to the projection bus + eventBus.subscribe { event -> + withLoggingContext("event" to event.toString()) { + gameEventStore + .getStream(event.aggregateId) + .readAll() + .fold(GameList(event.aggregateId)) { acc, event -> + acc.applyEvent(event) + }.also { save(it) } + .also { projectionBus.publish(it) } + } + } + } +} diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt similarity index 64% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt index 02f46d4..6dad40a 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt @@ -1,7 +1,7 @@ -package eventDemo.adapter.infrastructure.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections.bus -import eventDemo.domain.event.projection.GameProjection -import eventDemo.domain.event.projection.GameProjectionBus +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInMemory import java.util.UUID diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt similarity index 74% rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt index d067a61..4f20398 100644 --- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt @@ -1,8 +1,8 @@ -package eventDemo.adapter.infrastructure.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections.bus import com.rabbitmq.client.ConnectionFactory -import eventDemo.domain.event.projection.GameProjection -import eventDemo.domain.event.projection.GameProjectionBus +import eventDemo.contexts.game.application.ports.GameProjectionBus +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.libs.bus.Bus import eventDemo.libs.bus.BusInRabbitMQ import kotlinx.serialization.json.Json diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt similarity index 54% rename from src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt index 1b31a2f..8993b0d 100644 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt @@ -1,8 +1,7 @@ -package eventDemo.domain.event.projection +package eventDemo.contexts.game.infrastructure.persistence.projections.models -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.event.projection.Projection +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player import kotlinx.serialization.Serializable /** @@ -10,11 +9,10 @@ import kotlinx.serialization.Serializable */ @Serializable data class GameList( - override val aggregateId: GameId, - override val lastEventVersion: Int = 0, + val aggregateId: GameId, val status: Status = Status.OPENING, val players: Set = emptySet(), - val winners: Set = emptySet(), + val winners: Set = emptySet(), ) : GameProjection { enum class Status { OPENING, diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt new file mode 100644 index 0000000..99c4f72 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt @@ -0,0 +1,6 @@ +package eventDemo.contexts.game.infrastructure.persistence.projections.models + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GameProjection diff --git a/src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt similarity index 91% rename from src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt index ebad2dd..cbe82e9 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.serializer +package eventDemo.contexts.game.infrastructure.persistence.serializers import eventDemo.libs.command.CommandId import kotlinx.serialization.KSerializer diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt new file mode 100644 index 0000000..87096c8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt @@ -0,0 +1,24 @@ +package eventDemo.contexts.game.infrastructure.persistence.serializers + +import eventDemo.libs.eventSource.EventId +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.UUID + +object EventIdSerializer : KSerializer { + override fun deserialize(decoder: Decoder): EventId = + EventId(UUID.fromString(decoder.decodeString())) + + override fun serialize( + encoder: Encoder, + value: EventId, + ) { + encoder.encodeString(value.id.toString()) + } + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("EventId", PrimitiveKind.STRING) +} diff --git a/src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt similarity index 85% rename from src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt index d425738..9027285 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt @@ -1,6 +1,6 @@ -package eventDemo.configuration.serializer +package eventDemo.contexts.game.infrastructure.persistence.serializers -import eventDemo.domain.entity.GameId +import eventDemo.contexts.game.domain.game.GameId import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor diff --git a/src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt similarity index 86% rename from src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt index 91559d4..977a25e 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt @@ -1,6 +1,6 @@ -package eventDemo.configuration.serializer +package eventDemo.contexts.game.infrastructure.persistence.serializers -import eventDemo.domain.entity.Player +import eventDemo.contexts.game.domain.game.Player import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt similarity index 82% rename from src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt index c8718ff..73cc341 100644 --- a/src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt @@ -1,4 +1,4 @@ -package eventDemo.adapter.presenter.query +package eventDemo.contexts.game.infrastructure.rest import eventDemo.domain.event.projection.GameListRepository import io.ktor.resources.Resource @@ -15,7 +15,7 @@ class Games /** * API routes to show all games. */ -fun Route.readGamesList(gameListRepository: GameListRepository) { +fun Route.gamesListRoute(gameListRepository: GameListRepository) { authenticate { // Read the last played card on the game. get { diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt new file mode 100644 index 0000000..d1fd887 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt @@ -0,0 +1,45 @@ +package eventDemo.contexts.game.infrastructure.rest + +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.notification.toNotification +import eventDemo.contexts.game.application.ports.GameEventStore +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer +import eventDemo.sharedKernel.currentUserId +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.auth.authenticate +import io.ktor.server.resources.get +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/games/{id}") +class Game( + @Serializable(with = GameIdSerializer::class) + val id: GameId, +) + +/** + * API routes to read the game state. + */ +fun Route.getFullNotificationsRoute( + gameRepository: GameRepository, + gameEventStore: GameEventStore, +) { + authenticate { + get { body -> + val game = + gameRepository.get(body.id) + ?: return@get call.respond(HttpStatusCode.NotFound) + val notifications = + gameEventStore + .getStream(body.id) + .readAll() + .flatMap { it.toNotification(game, call.currentUserId) } + + call.respond(notifications) + } + } +} diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt new file mode 100644 index 0000000..81d66a8 --- /dev/null +++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt @@ -0,0 +1,26 @@ +package eventDemo.contexts.game.infrastructure.websocket + +import eventDemo.contexts.game.application.channels.GameChannelsSubscriber +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.libs.helpers.fromFrameChannel +import eventDemo.libs.helpers.toObjectChannel +import eventDemo.sharedKernel.currentUserId +import io.ktor.server.auth.authenticate +import io.ktor.server.routing.Route +import io.ktor.server.websocket.webSocket +import kotlinx.coroutines.DelicateCoroutinesApi +import java.util.UUID + +@DelicateCoroutinesApi +fun Route.gameWebSocket(channelSubscriber: GameChannelsSubscriber) { + authenticate { + webSocket("/games/{id}") { + channelSubscriber.subscribePlayerToGameChannels( + gameId = GameId(UUID.fromString(call.parameters["id"]!!)), + userId = call.currentUserId, + incomingCommandChannel = toObjectChannel(incoming), + sendNotificationChannel = fromFrameChannel(outgoing), + ) + } + } +} diff --git a/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt b/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt deleted file mode 100644 index d5d421d..0000000 --- a/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eventDemo.domain.command - -import eventDemo.domain.command.action.ICantPlay -import eventDemo.domain.command.action.IWantToJoinTheGame -import eventDemo.domain.command.action.IWantToPlayCard -import eventDemo.domain.command.action.IamReadyToPlay -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.command.command.ICantPlayCommand -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.command.command.IWantToPlayCardCommand -import eventDemo.domain.command.command.IamReadyToPlayCommand -import eventDemo.domain.event.event.GameEvent - -class GameCommandActionRunner( - private val iWantToPlayCard: IWantToPlayCard, - private val iamReadyToPlay: IamReadyToPlay, - private val iWantToJoinTheGame: IWantToJoinTheGame, - private val iCantPlay: ICantPlay, -) { - fun run(command: GameCommand): (version: Int) -> GameEvent = - when (command) { - is IWantToPlayCardCommand -> iWantToPlayCard.run(command) - is IamReadyToPlayCommand -> iamReadyToPlay.run(command) - is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command) - is ICantPlayCommand -> iCantPlay.run(command) - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt b/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt deleted file mode 100644 index 534af5c..0000000 --- a/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt +++ /dev/null @@ -1,139 +0,0 @@ -package eventDemo.domain.command - -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.GameEventStore -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.notification.CommandErrorNotification -import eventDemo.domain.notification.CommandSuccessNotification -import eventDemo.domain.notification.Notification -import eventDemo.libs.command.CommandHandler -import eventDemo.libs.command.CommandRunnerController -import eventDemo.libs.event.EventHandlerImpl -import eventDemo.libs.event.VersionBuilder -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel - -/** - * Listen [GameCommand] on [GameEventBus], check the validity and execute an action. - * - * This action can be executing an action and produce a new [GameEvent] after verification. - */ -class GameCommandHandler( - eventBus: GameEventBus, - eventStore: GameEventStore, - versionBuilder: VersionBuilder, - runner: GameCommandActionRunner, -) { - private val logger = KotlinLogging.logger { } - - private val eventHandler = - EventHandlerImpl( - eventBus, - eventStore, - versionBuilder, - ) - private val commandHandler = - CommandHandler( - CommandRunnerController(), - eventHandler, - ) { - runner.run(it) - } - - /** - * Subscribe to the [event bus][GameEventBus] - * to send success [notification][Notification] after save the [event][GameEvent]. - */ - fun subscribeToBus(eventBus: GameEventBus) = - commandHandler.subscribeToBus(eventBus) - - /** - * Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel], - * run the command and publish the generated [event][GameEvent] to the bus. - * - * It restricts to run only once a command. - * - * If the command fail, send an [error notification][CommandErrorNotification], - * if success, send a [success notification][CommandSuccessNotification] - */ - suspend fun handleIncomingPlayerCommands( - player: Player, - gameId: GameId, - incomingCommandChannel: ReceiveChannel, - channelNotification: SendChannel, - ) { - for (command in incomingCommandChannel) { - handle( - player, - gameId, - command, - channelNotification.sendSuccess(command), - channelNotification.sendError(command), - ) - } - } - - /** - * Run the [command] and publish the generated [event][GameEvent] to the bus. - * - * It restricts to run only once a command. - * - * If the command fail, send an [error notification][CommandErrorNotification], - * if success, send a [success notification][CommandSuccessNotification] - */ - fun handle( - player: Player, - gameId: GameId, - command: GameCommand, - sendSuccess: () -> Unit, - sendError: (message: String) -> Unit, - ) { - if (command.payload.aggregateId.id != gameId.id) { - logger.warn { "Handle command Refuse, the gameId of the command is not the same" } - sendError("The gameId in the command does not match with your game") - return - } - if (command.payload.player.id != player.id) { - logger.warn { "Handle command Refuse, the player of the command is not the same" } - sendError("You are not the author of this command") - return - } - - commandHandler.handle(gameId, command) { _, error -> - if (error != null) { - sendError(error.message) // Business - } else { - sendSuccess() - } - } - } -} - -private fun SendChannel.sendSuccess(command: GameCommand): () -> Unit = - { - val logger = KotlinLogging.logger { } - CommandSuccessNotification(commandId = command.id) - .also { notification -> - withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) { - logger.debug { "Notification SUCCESS sent" } - trySend(notification) - } - } - } - -private fun SendChannel.sendError(command: GameCommand): (message: String) -> Unit = - { - val logger = KotlinLogging.logger { } - CommandErrorNotification(message = it, command = command) - .also { notification -> - withLoggingContext("notification" to notification.toString(), "command" to command.toString()) { - logger.warn { "Notification ERROR sent: ${notification.message}" } - trySend(notification) - } - } - } diff --git a/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt b/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt deleted file mode 100644 index 35fee72..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.libs.command.Command -import eventDemo.libs.event.Event - -sealed interface CommandAction> { - fun run(command: C): (version: Int) -> E -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt b/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt deleted file mode 100644 index 0a9b284..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.ICantPlayCommand -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to perform an action to play a new card - */ -data class ICantPlay( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - - if (state.currentPlayerTurn != command.payload.player) { - throw CommandException("Its not your turn!") - } - - val playableCards = state.playableCards(command.payload.player) - if (playableCards.isNotEmpty()) { - throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}") - } - - val takenCard = state.deck.stack.first() - return { version -> - PlayerHavePassEvent( - aggregateId = command.payload.aggregateId, - player = command.payload.player, - takenCard = takenCard, - version = version, - ) - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt b/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt deleted file mode 100644 index b0b99c4..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to perform an action to play a new card - */ -data class IWantToJoinTheGame( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - if (!state.isStarted) { - return { - NewPlayerEvent( - aggregateId = command.payload.aggregateId, - player = command.payload.player, - version = it, - ) - } - } else { - throw CommandException("The game is already started") - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt b/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt deleted file mode 100644 index 25b27f5..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.IWantToPlayCardCommand -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to perform an action to play a new card - */ -data class IWantToPlayCard( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - - if (!state.isStarted) { - throw CommandException("The game is Not started") - } - if (state.currentPlayerTurn != command.payload.player) { - throw CommandException("Its not your turn!") - } - if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) { - throw CommandException("You cannot play this card") - } - - return { version -> - CardIsPlayedEvent( - aggregateId = command.payload.aggregateId, - card = command.payload.card, - player = command.payload.player, - version = version, - ) - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt b/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt deleted file mode 100644 index 5bc4c9d..0000000 --- a/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eventDemo.domain.command.action - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.IamReadyToPlayCommand -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.projection.GameStateRepository - -/** - * A command to set as ready to play - */ -class IamReadyToPlay( - private val gameStateRepository: GameStateRepository, -) : CommandAction { - @Throws(CommandException::class) - override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent { - val state = gameStateRepository.get(command.payload.aggregateId) - val playerExist: Boolean = state.players.contains(command.payload.player) - val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player) - - if (state.isStarted) { - throw CommandException("The game is already started") - } else if (!playerExist) { - throw CommandException("You are not in the game") - } else if (playerIsAlreadyReady) { - throw CommandException("You are already ready") - } else { - return { version: Int -> - PlayerReadyEvent( - aggregateId = command.payload.aggregateId, - player = command.payload.player, - version = version, - ) - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt deleted file mode 100644 index a919392..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt +++ /dev/null @@ -1,17 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.Command -import kotlinx.serialization.Serializable - -@Serializable -sealed interface GameCommand : Command { - val payload: Payload - - @Serializable - sealed interface Payload { - val aggregateId: GameId - val player: Player - } -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt deleted file mode 100644 index cbf49d0..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt +++ /dev/null @@ -1,22 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable - -/** - * A command to perform an action to play a new card - */ -@Serializable -data class IWantToJoinTheGameCommand( - override val payload: Payload, -) : GameCommand { - override val id: CommandId = CommandId() - - @Serializable - data class Payload( - override val aggregateId: GameId, - override val player: Player, - ) : GameCommand.Payload -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt deleted file mode 100644 index 5f92f27..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable - -/** - * A command to perform an action to play a new card - */ -@Serializable -data class IWantToPlayCardCommand( - override val payload: Payload, -) : GameCommand { - override val id: CommandId = CommandId() - - @Serializable - data class Payload( - override val aggregateId: GameId, - override val player: Player, - val card: Card, - ) : GameCommand.Payload -} diff --git a/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt deleted file mode 100644 index dbc1b75..0000000 --- a/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt +++ /dev/null @@ -1,22 +0,0 @@ -package eventDemo.domain.command.command - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable - -/** - * A command to set as ready to play - */ -@Serializable -data class IamReadyToPlayCommand( - override val payload: Payload, -) : GameCommand { - override val id: CommandId = CommandId() - - @Serializable - data class Payload( - override val aggregateId: GameId, - override val player: Player, - ) : GameCommand.Payload -} diff --git a/src/main/kotlin/eventDemo/domain/entity/Deck.kt b/src/main/kotlin/eventDemo/domain/entity/Deck.kt deleted file mode 100644 index b0dfe17..0000000 --- a/src/main/kotlin/eventDemo/domain/entity/Deck.kt +++ /dev/null @@ -1,133 +0,0 @@ -package eventDemo.domain.entity - -import kotlinx.serialization.Serializable - -@Serializable -data class Deck( - val stack: Stack = Stack(), - val discard: Discard = Discard(), - val playersHands: PlayersHands = PlayersHands(), -) { - constructor(players: Set) : - this(playersHands = PlayersHands(players)) - - fun shuffle(): Deck = - copy(stack = stack.shuffle()) - - fun placeFirstCardOnDiscard(): Deck { - val takenCard = stack.first() - return copy( - stack = stack - takenCard, - discard = discard + takenCard, - ) - } - - fun takeOneCardFromStackTo(player: Player): Deck = - takeOne().let { (deck, newPlayerCard) -> - deck.copy( - playersHands = deck.playersHands.addCard(player, newPlayerCard), - ) - } - - fun putOneCardFromHand( - player: Player, - card: Card, - ): Deck = - run { - // Validate parameters - val playerHand = - playersHands.getHand(player) - ?: error("No player on this game") - if (playerHand.none { it == card }) { - error("No card exist on the player hand") - } - }.let { - copy( - discard = discard + card, - playersHands = playersHands.removeCard(player, card), - ) - } - - fun playerHasNoCardLeft(): List = - playersHands - .filter { (playerId, hand) -> hand.isEmpty() } - .map { (playerId, hand) -> playerId } - - private fun take(n: Int): Pair> { - val takenCards = stack.take(n) - val newStack = stack.filterNot { takenCards.contains(it) }.toStack() - return Pair(copy(stack = newStack), takenCards) - } - - private fun takeOne(): Pair = - take(1).let { (deck, cards) -> Pair(deck, cards.first()) } - - companion object { - fun newWithoutPlayers(): Deck = - listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green) - .flatMap { color -> - ((0..9) + (1..9)).map { Card.NumericCard(it, color) } + - (1..2).map { Card.Plus2Card(color) } + - (1..2).map { Card.ReverseCard(color) } + - (1..2).map { Card.PassCard(color) } - }.let { - it + (1..4).map { Card.Plus4Card() } - }.toStack() - .let { Deck(it) } - } -} - -fun Deck.initHands( - players: Set, - handSize: Int = 7, -): Deck { - // Copy cards from stack to the player hands - val deckWithEmptyHands = copy(playersHands = PlayersHands(players)) - return players.fold(deckWithEmptyHands) { acc: Deck, player: Player -> - val hand = acc.stack.take(handSize) - val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack() - copy( - stack = newStack, - playersHands = acc.playersHands.addCards(player, hand), - ) - } -} - -@JvmInline -@Serializable -value class Stack( - private val cards: Set = emptySet(), -) : Set by cards { - operator fun plus(card: Card): Stack = - cards.plus(card).toStack() - - operator fun minus(card: Card): Stack = - cards.minus(card).toStack() - - fun shuffle(): Stack = - shuffled().toStack() -} - -fun List.toStack(): Stack = - Stack(this.toSet()) - -fun Set.toStack(): Stack = - Stack(this) - -@JvmInline -@Serializable -value class Discard( - private val cards: Set = emptySet(), -) : Set by cards { - operator fun plus(card: Card): Discard = - cards.plus(card).toDiscard() - - operator fun minus(card: Card): Discard = - cards.minus(card).toDiscard() -} - -fun List.toDiscard(): Discard = - Discard(this.toSet()) - -fun Set.toDiscard(): Discard = - Discard(this) diff --git a/src/main/kotlin/eventDemo/domain/entity/Player.kt b/src/main/kotlin/eventDemo/domain/entity/Player.kt deleted file mode 100644 index 6f1c3ed..0000000 --- a/src/main/kotlin/eventDemo/domain/entity/Player.kt +++ /dev/null @@ -1,29 +0,0 @@ -package eventDemo.domain.entity - -import eventDemo.configuration.serializer.PlayerIdSerializer -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.event.AggregateId -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class Player( - val name: String, - @Serializable(with = PlayerIdSerializer::class) - val id: PlayerId = PlayerId(UUID.randomUUID()), -) { - constructor(id: String, name: String) : this( - name, - PlayerId(UUID.fromString(id)), - ) - - @Serializable - @JvmInline - value class PlayerId( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - ) : AggregateId { - override fun toString(): String = - id.toString() - } -} diff --git a/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt b/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt deleted file mode 100644 index 9884627..0000000 --- a/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt +++ /dev/null @@ -1,50 +0,0 @@ -package eventDemo.domain.entity - -import kotlinx.serialization.Serializable - -@Serializable -@JvmInline -value class PlayersHands( - private val map: Map> = emptyMap(), -) : Map> by map { - constructor(players: Set) : - this(players.map { it.id }.associateWith { emptyList() }.toPlayersHands()) - - fun getHand(player: Player): List? = - this[player.id] - - fun removeCard( - player: Player, - card: Card, - ): PlayersHands = - mapValues { (playerId, cards) -> - if (playerId == player.id) { - if (!cards.contains(card)) error("The hand no contain the card") - cards - card - } else { - cards - } - }.toPlayersHands() - - fun addCard( - player: Player, - newCard: Card, - ): PlayersHands = - addCards(player, listOf(newCard)) - - fun addCards( - player: Player, - newCards: List, - ): PlayersHands = - mapValues { (p, cards) -> - if (p == player.id) { - if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card") - cards + newCards - } else { - cards - } - }.toPlayersHands() -} - -fun Map>.toPlayersHands(): PlayersHands = - PlayersHands(this) diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt b/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt deleted file mode 100644 index 995ad14..0000000 --- a/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.bus.Bus - -interface GameEventBus : Bus diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt b/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt deleted file mode 100644 index 3f46a67..0000000 --- a/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventHandler -import eventDemo.libs.event.EventHandlerImpl -import eventDemo.libs.event.VersionBuilder - -/** - * Handle the event to dispatch it to store, bus and projections builders - */ -class GameEventHandler( - private val eventBus: GameEventBus, - private val eventStore: GameEventStore, - private val versionBuilder: VersionBuilder, -) : EventHandler by EventHandlerImpl(eventBus, eventStore, versionBuilder) diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt b/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt deleted file mode 100644 index fbef71f..0000000 --- a/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.EventStore - -interface GameEventStore : EventStore diff --git a/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt deleted file mode 100644 index d50df11..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * An [GameEvent] to represent a played card. - */ -@Serializable -data class CardIsPlayedEvent( - override val aggregateId: GameId, - val card: Card, - override val player: Player, - override val version: Int, -) : GameEvent, - PlayerActionEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt deleted file mode 100644 index 58fe98c..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.libs.event.Event -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * An [Event] of a Game. - */ -@Serializable -sealed interface GameEvent : Event { - override val eventId: UUID - override val aggregateId: GameId - override val version: Int -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt deleted file mode 100644 index 5bdcb04..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt +++ /dev/null @@ -1,52 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Deck -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.entity.initHands -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when all players are ready. - */ -@Serializable -data class GameStartedEvent( - override val aggregateId: GameId, - val firstPlayer: Player, - val deck: Deck, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() - - companion object { - fun new( - id: GameId, - players: Set, - version: Int, - shuffleIsDisabled: Boolean = isDisabled, - ): GameStartedEvent = - GameStartedEvent( - aggregateId = id, - firstPlayer = if (shuffleIsDisabled) players.first() else players.random(), - deck = - Deck - .newWithoutPlayers() - .let { if (shuffleIsDisabled) it else it.shuffle() } - .initHands(players) - .placeFirstCardOnDiscard(), - version = version, - ) - } -} - -private var isDisabled = false - -internal fun disableShuffleDeck() { - isDisabled = true -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt deleted file mode 100644 index 5affe01..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * An [GameEvent] to represent a new player joining the game. - */ -@Serializable -data class NewPlayerEvent( - override val aggregateId: GameId, - val player: Player, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt deleted file mode 100644 index 2f7eda8..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Player -import kotlinx.serialization.Serializable - -@Serializable -sealed interface PlayerActionEvent : GameEvent { - val player: Player -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt deleted file mode 100644 index 4477e2a..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player chose a color. - */ -@Serializable -data class PlayerChoseColorEvent( - override val aggregateId: GameId, - override val player: Player, - val color: Card.Color, - override val version: Int, -) : GameEvent, - PlayerActionEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt deleted file mode 100644 index e646128..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player can play. - */ -@Serializable -data class PlayerHavePassEvent( - override val aggregateId: GameId, - override val player: Player, - val takenCard: Card, - override val version: Int, -) : GameEvent, - PlayerActionEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt deleted file mode 100644 index 8f0afed..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player is ready. - */ -@Serializable -data class PlayerReadyEvent( - override val aggregateId: GameId, - val player: Player, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt deleted file mode 100644 index 96d73f7..0000000 --- a/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.domain.event.event - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import java.util.UUID - -/** - * This [GameEvent] is sent when a player is ready. - */ -@Serializable -data class PlayerWinEvent( - override val aggregateId: GameId, - val player: Player, - override val version: Int, -) : GameEvent { - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID() - override val createdAt: Instant = Clock.System.now() -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt b/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt deleted file mode 100644 index 636aa54..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.GameId -import eventDemo.libs.event.projection.Projection -import kotlinx.serialization.Serializable - -@Serializable -sealed interface GameProjection : Projection diff --git a/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt b/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt deleted file mode 100644 index e8bbe80..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt +++ /dev/null @@ -1,5 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.libs.bus.Bus - -interface GameProjectionBus : Bus diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt deleted file mode 100644 index 0ae729b..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt +++ /dev/null @@ -1,51 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerChoseColorEvent -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.PlayerWinEvent - -fun GameList.apply(event: GameEvent): GameList = - when (event) { - is NewPlayerEvent -> { - copy( - players = players + event.player, - status = GameList.Status.OPENING, - ) - } - - is GameStartedEvent -> { - copy( - status = GameList.Status.IS_STARTED, - ) - } - - is PlayerWinEvent -> { - copy( - winners = winners + event.player, - status = GameList.Status.FINISH, - ) - } - - is CardIsPlayedEvent -> { - this - } - - is PlayerChoseColorEvent -> { - this - } - - is PlayerHavePassEvent -> { - this - } - - is PlayerReadyEvent -> { - this - } - }.copy( - lastEventVersion = event.version, - ) diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt deleted file mode 100644 index 89aef32..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt +++ /dev/null @@ -1,5 +0,0 @@ -package eventDemo.domain.event.projection - -interface GameListRepository { - fun getList(): List -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt deleted file mode 100644 index 914ee43..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt +++ /dev/null @@ -1,182 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Deck -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.event.projection.Projection -import kotlinx.serialization.Serializable - -/** - * This [projection][Projection] is used for manage a game and theirs [card][Card] - */ -@Serializable -data class GameState( - override val aggregateId: GameId, - override val lastEventVersion: Int = 0, - val players: Set = emptySet(), - val currentPlayerTurn: Player? = null, - val lastCardPlayer: Player? = null, - val colorOnCurrentStack: Card.Color? = null, - val direction: Direction = Direction.CLOCKWISE, - val readyPlayers: Set = emptySet(), - val deck: Deck = Deck(players), - val isStarted: Boolean = false, - val playerWins: Set = emptySet(), - val lastEvent: GameEvent? = null, -) : GameProjection { - enum class Direction { - CLOCKWISE, - COUNTER_CLOCKWISE, - ; - - fun revert(): Direction = - if (this === CLOCKWISE) { - COUNTER_CLOCKWISE - } else { - CLOCKWISE - } - } - - val cardOnCurrentStack: Card? = deck.discard.lastOrNull() - - val isReady: Boolean get() { - return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) } - } - - private val currentPlayerIndex: Int? get() { - val i = players.indexOf(currentPlayerTurn) - return if (i == -1) { - null - } else { - i - } - } - - private fun nextPlayerIndex(direction: Direction): Int { - if (players.isEmpty()) return 0 - - return if (direction == Direction.CLOCKWISE) { - sidePlayerIndexClockwise - } else { - sidePlayerIndexCounterClockwise - } - } - - fun nextPlayer(direction: Direction): Player = - players.elementAt(nextPlayerIndex(direction)) - - private val sidePlayerIndexClockwise: Int by lazy { - if (players.isEmpty()) { - 0 - } else { - ((currentPlayerIndex ?: 0) + 1) % players.size - } - } - private val sidePlayerIndexCounterClockwise: Int by lazy { - if (players.isEmpty()) { - 0 - } else { - ((currentPlayerIndex ?: 0) - 1) % players.size - } - } - - val nextPlayerTurn: Player? by lazy { - if (players.isEmpty()) { - null - } else { - nextPlayer(direction) - } - } - - private val Player.currentIndex: Int get() = players.indexOf(this) - - fun Player.playerDiffIndex(nextPlayer: Player): Int = - if (direction == Direction.CLOCKWISE) { - nextPlayer.currentIndex + this.currentIndex - } else { - nextPlayer.currentIndex - this.currentIndex - }.let { it % players.size } - - val Player.cardOnBoardIsForYou: Boolean get() { - if (lastCardPlayer == null) error("No card") - return this.playerDiffIndex(lastCardPlayer) == 1 - } - - fun playableCards(player: Player): List = - deck - .playersHands - .getHand(player) - ?.filter { canBePlayThisCard(player, it) } - ?: emptyList() - - fun playerHasNoCardLeft(): List = - deck.playerHasNoCardLeft().map { playerId -> - players.find { it.id == playerId } ?: error("inconsistency detected between players") - } - - fun canBePlayThisCard( - player: Player, - card: Card, - ): Boolean { - val cardOnBoard = cardOnCurrentStack ?: return false - return when (cardOnBoard) { - is Card.NumericCard -> { - when (card) { - is Card.AllColorCard -> true - is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color - is Card.ColorCard -> card.color == cardOnBoard.color - } - } - - is Card.ReverseCard -> { - when (card) { - is Card.ReverseCard -> true - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == cardOnBoard.color - } - } - - is Card.PassCard -> { - if (player.cardOnBoardIsForYou) { - false - } else { - when (card) { - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == cardOnBoard.color - } - } - } - - is Card.ChangeColorCard -> { - when (card) { - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == colorOnCurrentStack - } - } - - is Card.Plus2Card -> { - if (player.cardOnBoardIsForYou && card is Card.Plus2Card) { - true - } else { - when (card) { - is Card.Plus2Card -> true - else -> false - } - } - } - - is Card.Plus4Card -> { - if (player.cardOnBoardIsForYou && card is Card.Plus4Card) { - true - } else { - when (card) { - is Card.AllColorCard -> true - is Card.ColorCard -> card.color == colorOnCurrentStack - } - } - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt deleted file mode 100644 index 3b29dda..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt +++ /dev/null @@ -1,116 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerActionEvent -import eventDemo.domain.event.event.PlayerChoseColorEvent -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.PlayerWinEvent -import io.github.oshai.kotlinlogging.KotlinLogging - -fun GameState.apply(event: GameEvent): GameState = - this.let { state -> - val logger = KotlinLogging.logger { } - if (event is PlayerActionEvent) { - if (state.currentPlayerTurn != event.player) { - logger.atError { - message = "Inconsistent player turn" - payload = - mapOf( - "CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"), - "Player" to event.player, - ) - } - } - } - - when (event) { - is CardIsPlayedEvent -> { - val nextDirectionAfterPlay = - when (event.card) { - is Card.ReverseCard -> state.direction.revert() - else -> state.direction - } - - val color = - when (event.card) { - is Card.ColorCard -> event.card.color - is Card.AllColorCard -> null - } - - val currentPlayerAfterThePlay = - if (event.card is Card.AllColorCard) { - state.currentPlayerTurn - } else { - state.nextPlayer(nextDirectionAfterPlay) - } - - state.copy( - currentPlayerTurn = currentPlayerAfterThePlay, - direction = nextDirectionAfterPlay, - colorOnCurrentStack = color, - lastCardPlayer = event.player, - deck = state.deck.putOneCardFromHand(event.player, event.card), - ) - } - - is NewPlayerEvent -> { - if (state.isStarted) { - logger.error { "The game is already started" } - } - - state.copy( - players = state.players + event.player, - ) - } - - is PlayerReadyEvent -> { - if (state.isStarted) { - logger.error { "The game is already started" } - } - state.copy( - readyPlayers = state.readyPlayers + event.player, - ) - } - - is PlayerHavePassEvent -> { - if (event.takenCard != state.deck.stack.first()) { - logger.error { "taken card is not ot top of the stack: ${event.takenCard}" } - } - state.copy( - currentPlayerTurn = state.nextPlayerTurn, - deck = state.deck.takeOneCardFromStackTo(event.player), - ) - } - - is PlayerChoseColorEvent -> { - state.copy( - currentPlayerTurn = state.nextPlayerTurn, - colorOnCurrentStack = event.color, - ) - } - - is GameStartedEvent -> { - state.copy( - colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack, - lastCardPlayer = null, - currentPlayerTurn = event.firstPlayer, - deck = event.deck, - isStarted = true, - ) - } - - is PlayerWinEvent -> { - state.copy( - playerWins = state.playerWins + event.player, - ) - } - }.copy( - lastEventVersion = event.version, - lastEvent = event, - ) - } diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt deleted file mode 100644 index 1d1d8c6..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.GameId - -interface GameStateRepository { - fun get(gameId: GameId): GameState -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt b/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt deleted file mode 100644 index bc13f91..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt +++ /dev/null @@ -1,149 +0,0 @@ -package eventDemo.domain.event.projection.projectionListener - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerChoseColorEvent -import eventDemo.domain.event.event.PlayerHavePassEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.PlayerWinEvent -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.notification.ItsTheTurnOfNotification -import eventDemo.domain.notification.Notification -import eventDemo.domain.notification.PlayerAsJoinTheGameNotification -import eventDemo.domain.notification.PlayerAsPlayACardNotification -import eventDemo.domain.notification.PlayerHavePassNotification -import eventDemo.domain.notification.PlayerWasChoseTheCardColorNotification -import eventDemo.domain.notification.PlayerWasReadyNotification -import eventDemo.domain.notification.PlayerWinNotification -import eventDemo.domain.notification.TheGameWasStartedNotification -import eventDemo.domain.notification.WelcomeToTheGameNotification -import eventDemo.domain.notification.YourNewCardNotification -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext - -class PlayerNotificationListener( - private val projectionBus: GameProjectionBus, -) { - private val logger = KotlinLogging.logger {} - - /** - * Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification] - */ - fun startListening( - currentPlayer: Player, - gameId: GameId, - outgoingNotification: (Notification) -> Unit, - ): AutoCloseable { - return projectionBus.subscribe { currentState -> - if (currentState !is GameState) return@subscribe - if (currentState.aggregateId != gameId) return@subscribe - withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) { - fun Notification.send() { - withLoggingContext("notification" to this.toString()) { - if (currentState.players.contains(currentPlayer)) { - // Only notify players who have already joined the game. - outgoingNotification(this) - logger.info { "Notification was SEND" } - } else { - // Rare use case, when a connexion is created with the channel, - // but the player was not already join in the game - logger.warn { "Notification was SKIP, no player on the game" } - } - } - } - - fun sendNextTurnNotif() = - ItsTheTurnOfNotification( - player = currentState.currentPlayerTurn ?: error("No player turn defined"), - ).send() - - val event = - currentState.lastEvent - ?: error("No last event in the GameState projection") - - when (event) { - is NewPlayerEvent -> { - if (currentPlayer != event.player) { - PlayerAsJoinTheGameNotification( - player = event.player, - ).send() - } else { - WelcomeToTheGameNotification( - players = currentState.players, - ).send() - } - } - - is CardIsPlayedEvent -> { - if (currentPlayer != event.player) { - PlayerAsPlayACardNotification( - player = event.player, - card = event.card, - ).send() - } - - if (event.card !is Card.AllColorCard) { - ItsTheTurnOfNotification( - player = currentState.currentPlayerTurn ?: error("No player turn defined"), - ).send() - } - } - - is GameStartedEvent -> { - TheGameWasStartedNotification( - hand = - event.deck.playersHands.getHand(currentPlayer) - ?: error("You are not in the game"), - ).send() - - sendNextTurnNotif() - } - - is PlayerChoseColorEvent -> { - if (currentPlayer != event.player) { - PlayerWasChoseTheCardColorNotification( - player = event.player, - color = event.color, - ).send() - } - - sendNextTurnNotif() - } - - is PlayerHavePassEvent -> { - if (currentPlayer == event.player) { - YourNewCardNotification( - card = event.takenCard, - ).send() - } else { - PlayerHavePassNotification( - player = event.player, - ).send() - } - - sendNextTurnNotif() - } - - is PlayerReadyEvent -> { - if (currentPlayer != event.player) { - PlayerWasReadyNotification( - player = event.player, - ).send() - } - } - - is PlayerWinEvent -> { - PlayerWinNotification( - player = event.player, - ).send() - } - } - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt b/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt deleted file mode 100644 index c437132..0000000 --- a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt +++ /dev/null @@ -1,75 +0,0 @@ -package eventDemo.domain.event.projection.projectionListener - -import eventDemo.domain.entity.GameId -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.PlayerWinEvent -import eventDemo.domain.event.projection.GameProjectionBus -import eventDemo.domain.event.projection.GameState -import eventDemo.libs.event.projection.Projection -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import java.util.concurrent.ConcurrentSkipListSet - -class ReactionListener( - private val eventHandler: GameEventHandler, -) { - companion object Config { - val registeredListeners = ConcurrentSkipListSet() - } - - private val logger = KotlinLogging.logger { } - - fun subscribeToBus(projectionBus: GameProjectionBus) { - if (registeredListeners.add(projectionBus)) { - projectionBus.subscribe { projection: Projection -> - if (projection !is GameState) return@subscribe - withLoggingContext("projection" to projection.toString()) { - sendStartGameEvent(projection) - sendWinnerEvent(projection) - } - } - } else { - "${this::class.simpleName} is already init for this bus".let { - logger.error { it } - error(it) - } - } - } - - private fun sendStartGameEvent(state: GameState) { - if (state.isReady && !state.isStarted) { - val reactionEvent = - eventHandler.handle(state.aggregateId) { - GameStartedEvent.new( - id = state.aggregateId, - players = state.players, - version = it, - ) - } - logger.atInfo { - message = "Reaction event was Send" - payload = mapOf("reactionEvent" to reactionEvent) - } - } - } - - private fun sendWinnerEvent(state: GameState) { - val winner = state.playerHasNoCardLeft().firstOrNull() - if (winner != null) { - val reactionEvent = - eventHandler.handle(state.aggregateId) { - PlayerWinEvent( - aggregateId = state.aggregateId, - player = winner, - version = it, - ) - } - - logger.atInfo { - message = "Reaction event was Send" - payload = mapOf("reactionEvent" to reactionEvent) - } - } - } -} diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt deleted file mode 100644 index 3385315..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eventDemo.domain.notification - -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.command.Command -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class CommandErrorNotification( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - val message: String, - val command: Command, -) : Notification, - CommandNotification diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt deleted file mode 100644 index 5d8462f..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt +++ /dev/null @@ -1,3 +0,0 @@ -package eventDemo.domain.notification - -sealed interface CommandNotification : Notification diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt deleted file mode 100644 index 252774a..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eventDemo.domain.notification - -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.command.CommandId -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class CommandSuccessNotification( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - val commandId: CommandId, -) : Notification, - CommandNotification diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt b/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt deleted file mode 100644 index ef5d0b0..0000000 --- a/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eventDemo.domain.notification - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Player -import eventDemo.configuration.serializer.UUIDSerializer -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class PlayerWasChoseTheCardColorNotification( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), - val player: Player, - val color: Card.Color, -) : Notification diff --git a/src/main/kotlin/eventDemo/libs/bus/Bus.kt b/src/main/kotlin/eventDemo/libs/bus/Bus.kt index 89381eb..7b08a8c 100644 --- a/src/main/kotlin/eventDemo/libs/bus/Bus.kt +++ b/src/main/kotlin/eventDemo/libs/bus/Bus.kt @@ -6,6 +6,10 @@ interface Bus { */ fun publish(item: T) + fun publish(items: Collection) { + items.forEach { publish(it) } + } + /** * Subscribe a [lambda][block] to the bus. * diff --git a/src/main/kotlin/eventDemo/libs/bus/BusInRabbitMQ.kt b/src/main/kotlin/eventDemo/libs/bus/BusInRabbitMQ.kt index 0efc12f..f76f95b 100644 --- a/src/main/kotlin/eventDemo/libs/bus/BusInRabbitMQ.kt +++ b/src/main/kotlin/eventDemo/libs/bus/BusInRabbitMQ.kt @@ -7,6 +7,7 @@ import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.DefaultConsumer import com.rabbitmq.client.Envelope import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.runBlocking @@ -43,15 +44,17 @@ class BusInRabbitMQ( } override fun publish(item: E) { - connection - .createChannel() - .basicPublish( - exchangeName, - routingKey, - AMQP.BasicProperties(), - objectToString(item).toByteArray(), - ) - logger.info { "Item sent to the bus" } + withLoggingContext("item" to item.toString()) { + connection + .createChannel() + .basicPublish( + exchangeName, + routingKey, + AMQP.BasicProperties(), + objectToString(item).toByteArray(), + ) + logger.info { "Item sent to the bus" } + } } override fun subscribe(block: (E) -> Unit): Bus.Subscription { @@ -75,7 +78,11 @@ class BusInRabbitMQ( body: ByteArray, ) { runBlocking { - block(stringToObject(body.toString(Charsets.UTF_8))) + val obj = stringToObject(body.toString(Charsets.UTF_8)) + withLoggingContext("item" to obj.toString()) { + logger.info { "Received delivery of $exchangeName" } + } + block(obj) } channel.basicAck(envelope.deliveryTag, false) } diff --git a/src/main/kotlin/eventDemo/libs/command/Command.kt b/src/main/kotlin/eventDemo/libs/command/Command.kt index 39b966c..9faba9e 100644 --- a/src/main/kotlin/eventDemo/libs/command/Command.kt +++ b/src/main/kotlin/eventDemo/libs/command/Command.kt @@ -1,6 +1,6 @@ package eventDemo.libs.command -import eventDemo.configuration.serializer.CommandIdSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt b/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt deleted file mode 100644 index d192ea5..0000000 --- a/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt +++ /dev/null @@ -1,108 +0,0 @@ -package eventDemo.libs.command - -import eventDemo.domain.command.CommandException -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.event.event.GameEvent -import eventDemo.libs.bus.Bus -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import eventDemo.libs.event.EventHandler -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes - -/** - * Listen [GameCommand] on [CommandStreamChannel], check the validity and execute an action. - * - * This action can be executing an action and produce a new [GameEvent] after verification. - */ -class CommandHandler, E : Event, ID : AggregateId, C : Command>( - private val controller: CommandRunnerController, - private val eventHandler: EventHandler, - private val runner: (command: C) -> (version: Int) -> E, -) { - private val logger = KotlinLogging.logger { } - private val eventCommandMap = EventCommandMap() - - /** subscribe to the event bus to run callback after event was saved. */ - fun subscribeToBus(eventBus: B) { - eventBus.subscribe { event: E -> - eventCommandMap[event.eventId]?.invoke() - ?: logger.debug { "No Notification for event: $event" } - } - } - - /** - * Run the [command] and publish generated [event][Event]. - * - * The [callback] is call after execute the [command] - * - * It restricts to run only once the [command]. - */ - fun handle( - aggregateId: ID, - command: C, - callback: CommandCallback, - ) { - controller.runOnlyOnce(command) { - withLoggingContext("command" to command.toString()) { - logger.info { "Handle command" } - try { - val eventBuilder = runner(command) - - eventHandler.handle(aggregateId) { version -> - eventBuilder(version) - .also { eventCommandMap.set(callback, it, command) } - } - } catch (e: CommandException) { - logger.warn(e) { e.message } - callback(command, e) - } - } - } - } -} - -/** - * Map to record the command that triggered the event. - */ -private class EventCommandMap>( - val retention: Duration = 10.minutes, -) { - val map = ConcurrentHashMap>() - - fun set( - callback: CommandCallback, - event: E, - command: C, - ) { - map[event.eventId] = Callback(callback, command, event, Clock.System.now()) - - // Remove older - map - .filterValues { it.date < (Clock.System.now() - retention) } - .keys - .forEach(map::remove) - } - - operator fun get(eventId: UUID): Callback? = - map[eventId] - - data class Callback>( - val callback: CommandCallback, - val command: C, - val event: E, - val date: Instant, - ) { - operator fun invoke(error: CommandException? = null) { - callback(command, error) - } - } -} - -typealias CommandCallback = (command: C, error: CommandException?) -> Unit diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt deleted file mode 100644 index f9d18f7..0000000 --- a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt +++ /dev/null @@ -1,50 +0,0 @@ -package eventDemo.libs.command - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import kotlinx.coroutines.channels.ReceiveChannel - -/** - * Manage [Command]'s with kotlin Channel. - * - * Use [CommandRunnerController] to prevent multiple executions. - * - * Add logs when command success or failed - */ -class CommandStreamChannel( - private val controller: CommandRunnerController, -) { - private val logger = KotlinLogging.logger {} - - suspend fun process( - incoming: ReceiveChannel, - action: CommandBlock, - ) { - for (command in incoming) { - withLoggingContext("command" to command.toString()) { - try { - controller.runOnlyOnce(command) { - // Wrap action to add logs - runAndLogStatus(command, action) - } - } catch (e: CommandRunnerController.Exception) { - logger.warn { e.message } - } - } - } - } - - private fun runAndLogStatus( - command: C, - action: CommandBlock, - ) { - val actionResult = runCatching { action(command) } - if (actionResult.isFailure) { - logger.warn(actionResult.exceptionOrNull()) { "Compute command FAILED" } - } else if (actionResult.isSuccess) { - logger.info { "Compute command SUCCESS" } - } - } -} - -typealias CommandBlock = (C) -> Unit diff --git a/src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt b/src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt similarity index 76% rename from src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt rename to src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt index ab36f1b..7d7e6b1 100644 --- a/src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt +++ b/src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt @@ -9,32 +9,32 @@ import kotlin.time.Duration.Companion.minutes /** * Controls the execution of a command to prevent it from being executed more than once. */ -class CommandRunnerController( +class CommandUnicityChecker( private val maxCacheTime: Duration = 10.minutes, ) { private val executedCommand: ConcurrentHashMap> = ConcurrentHashMap() fun runOnlyOnce( command: C, - action: CommandBlock, + action: (C) -> Unit, ) { if (!isAlreadyExecuted(command)) { action(command) setAsExecuted(command) removeOldCache() } else { - throw Exception("Command already executed", command) + throw UnicityException("Command already executed", command) } } private fun setAsExecuted(command: C) { - executedCommand.computeIfAbsent(command.id) { Pair(false, Clock.System.now()) } + executedCommand.computeIfAbsent(command.id) { Pair(true, Clock.System.now()) } } private fun removeOldCache() { executedCommand .filterValues { (_, date) -> - (date + maxCacheTime) > Clock.System.now() + (date + maxCacheTime) < Clock.System.now() }.keys .forEach { executedCommand.remove(it) @@ -44,8 +44,8 @@ class CommandRunnerController( private fun isAlreadyExecuted(command: C): Boolean = executedCommand[command.id]?.first ?: false - class Exception( + class UnicityException( override val message: String, val command: Command, - ) : kotlin.Exception(message) + ) : Exception(message) } diff --git a/src/main/kotlin/eventDemo/libs/event/Event.kt b/src/main/kotlin/eventDemo/libs/event/Event.kt deleted file mode 100644 index 749dc82..0000000 --- a/src/main/kotlin/eventDemo/libs/event/Event.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eventDemo.libs.event - -import kotlinx.datetime.Instant -import java.util.UUID - -/** - * Represent an ID for one aggregate, and it used in events - * @see Event - */ -interface AggregateId { - val id: UUID -} - -/** - * The basic interface for an Event - * @see EventStream - */ -interface Event { - val eventId: UUID - val aggregateId: ID - val createdAt: Instant - val version: Int -} diff --git a/src/main/kotlin/eventDemo/libs/event/EventHandler.kt b/src/main/kotlin/eventDemo/libs/event/EventHandler.kt deleted file mode 100644 index 00eb39b..0000000 --- a/src/main/kotlin/eventDemo/libs/event/EventHandler.kt +++ /dev/null @@ -1,11 +0,0 @@ -package eventDemo.libs.event - -/** - * A stream to publish and read the played card event. - */ -interface EventHandler, ID : AggregateId> { - fun handle( - aggregateId: ID, - buildEvent: (version: Int) -> E, - ): E -} diff --git a/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt b/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt deleted file mode 100644 index d577530..0000000 --- a/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt +++ /dev/null @@ -1,42 +0,0 @@ -package eventDemo.libs.event - -import eventDemo.libs.bus.Bus -import io.github.oshai.kotlinlogging.withLoggingContext -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -/** - * Handle the event to dispatch it to store, bus and projections builders - */ -class EventHandlerImpl, ID : AggregateId>( - private val eventBus: Bus, - private val eventStore: EventStore, - private val versionBuilder: VersionBuilder, -) : EventHandler { - private val locks: ConcurrentHashMap = ConcurrentHashMap() - - /** - * Build Event then send it to the event store and bus. - */ - override fun handle( - aggregateId: ID, - buildEvent: (version: Int) -> E, - ): E = - withLoggingContext("aggregateId" to aggregateId.toString()) { - locks - // Get lock for the aggregate - .computeIfAbsent(aggregateId) { ReentrantLock() } - .withLock { - // Build event with the version - buildEvent(versionBuilder.buildNextVersion(aggregateId)) - // then publish it to the event store - .also { - withLoggingContext("event" to it.toString()) { - eventStore.publish(it) - eventBus.publish(it) - } - } - } - } -} diff --git a/src/main/kotlin/eventDemo/libs/event/EventStore.kt b/src/main/kotlin/eventDemo/libs/event/EventStore.kt deleted file mode 100644 index bc7a75b..0000000 --- a/src/main/kotlin/eventDemo/libs/event/EventStore.kt +++ /dev/null @@ -1,12 +0,0 @@ -package eventDemo.libs.event - -import io.github.oshai.kotlinlogging.withLoggingContext - -interface EventStore, ID : AggregateId> { - fun getStream(aggregateId: ID): EventStream - - fun publish(event: E) = - withLoggingContext("event" to event.toString()) { - getStream(event.aggregateId).publish(event) - } -} diff --git a/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt b/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt deleted file mode 100644 index 38ab05e..0000000 --- a/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eventDemo.libs.event - -interface VersionBuilder { - fun buildNextVersion(aggregateId: AggregateId): Int - - fun getLastVersion(aggregateId: AggregateId): Int -} diff --git a/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt b/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt deleted file mode 100644 index ab356fc..0000000 --- a/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt +++ /dev/null @@ -1,25 +0,0 @@ -package eventDemo.libs.event - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger - -class VersionBuilderLocal : VersionBuilder { - private val logger = KotlinLogging.logger { } - private val versions: ConcurrentHashMap = ConcurrentHashMap() - - override fun buildNextVersion(aggregateId: AggregateId): Int = - withLoggingContext("aggregateId" to aggregateId.toString()) { - versionOfAggregate(aggregateId) - .addAndGet(1) - .also { logger.debug { "New event version $it" } } - } - - override fun getLastVersion(aggregateId: AggregateId): Int = - versionOfAggregate(aggregateId).toInt() - - private fun versionOfAggregate(aggregateId: AggregateId) = - versions - .computeIfAbsent(aggregateId) { AtomicInteger(0) } -} diff --git a/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt b/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt deleted file mode 100644 index 1f969e7..0000000 --- a/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.libs.event.projection - -import eventDemo.libs.event.AggregateId - -interface Projection { - val aggregateId: ID - val lastEventVersion: Int -} diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt b/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt deleted file mode 100644 index ed48797..0000000 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt +++ /dev/null @@ -1,34 +0,0 @@ -package eventDemo.libs.event.projection - -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event - -interface ProjectionRepository, P : Projection, ID : AggregateId> { - /** - * Update projection with the event. - */ - fun apply(event: E): P - - /** - * Update projection with the event, and save it. - */ - fun applyAndSave(event: E): P - - /** - * Save the projection. - */ - fun save(projection: P) - - /** - * Build the list of all [Projections][Projection] - */ - fun getList( - limit: Int = 100, - offset: Int = 0, - ): List

- - /** - * Build the last version of the [Projection] from the cache. - */ - fun get(aggregateId: ID): P -} diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt b/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt deleted file mode 100644 index b349a82..0000000 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt +++ /dev/null @@ -1,63 +0,0 @@ -package eventDemo.libs.event.projection - -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.withLoggingContext - -/** - * Repository abstraction to declare common process - */ -abstract class ProjectionRepositoryAbs, P : Projection, ID : AggregateId>( - private val applyToProjection: P.(event: E) -> P, -) : ProjectionRepository { - private val logger = KotlinLogging.logger {} - - /** - * Update projection with the event. - * - * 1. get the last projection - * 2. apply the new event to the projection - */ - override fun apply(event: E): P = - get(event.aggregateId).applyToProjectionSecure(event) - - /** - * Update projection with the event, and save it. - * - * 1. get the last projection - * 2. apply the new event to projection - * 3. save it - */ - override fun applyAndSave(event: E): P = - apply(event) - .also { - withLoggingContext("projection" to it.toString(), "event" to event.toString()) { - save(it) - } - } - - /** - * Wrap the [applyToProjection] lambda to avoid duplicate apply of the same event. - */ - protected val applyToProjectionSecure: P.(event: E) -> P = { event -> - withLoggingContext("event" to event.toString(), "projection" to this.toString()) { - if (canBeApply(event)) { - applyToProjection(event) - } else if (event.version <= lastEventVersion) { - "Event is already in the Projection, skip apply.".let { - logger.warn { it } - error(it) - } - } else { - "The version of the event must follow directly after the version of the projection.".let { - logger.error { it } - error(it) - } - } - } - } - - private fun P.canBeApply(event: E): Boolean = - event.version == lastEventVersion + 1 -} diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt b/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt deleted file mode 100644 index 3098c48..0000000 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt +++ /dev/null @@ -1,46 +0,0 @@ -package eventDemo.libs.event.projection - -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import java.util.concurrent.ConcurrentHashMap - -class ProjectionRepositoryInMemory, P : Projection, ID : AggregateId>( - private val initialStateBuilder: (aggregateId: ID) -> P, - applyToProjection: P.(event: E) -> P, -) : ProjectionRepositoryAbs(applyToProjection), - ProjectionRepository { - private val projections: ConcurrentHashMap = ConcurrentHashMap() - - /** - * Build the list of all [Projections][Projection] - */ - override fun getList( - limit: Int, - offset: Int, - ): List

= - projections - .values - .drop(offset) - .take(limit) - - /** - * Get the [Projection]. - */ - override fun get(aggregateId: ID): P = - projections[aggregateId] - ?: initialStateBuilder(aggregateId) - - /** - * Save the projection. - */ - override fun save(projection: P) { - projections.compute(projection.aggregateId) { id: ID, proj: P? -> - val currentProjection = proj ?: initialStateBuilder(projection.aggregateId) - if (currentProjection.lastEventVersion < projection.lastEventVersion) { - projection - } else { - currentProjection - } - } - } -} diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt b/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt deleted file mode 100644 index eba9554..0000000 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt +++ /dev/null @@ -1,80 +0,0 @@ -package eventDemo.libs.event.projection - -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import io.github.oshai.kotlinlogging.KotlinLogging -import redis.clients.jedis.UnifiedJedis -import redis.clients.jedis.params.ScanParams -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock -import kotlin.reflect.KClass - -class ProjectionRepositoryInRedis, P : Projection, ID : AggregateId>( - private val jedis: UnifiedJedis, - private val initialStateBuilder: (aggregateId: ID) -> P, - private val projectionClass: KClass

, - private val projectionToJson: (P) -> String, - private val jsonToProjection: (String) -> P, - applyToProjection: P.(event: E) -> P, -) : ProjectionRepositoryAbs(applyToProjection), - ProjectionRepository { - val logger = KotlinLogging.logger { } - private val lock = ReentrantLock() - - /** - * Get the list of all [Projections][Projection] - */ - override fun getList( - limit: Int, - offset: Int, - ): List

= - jedis - .hscan( - projectionClass.redisHKey, - offset.toString(), - ScanParams() - .match("*") - .count(limit), - ).result - .mapNotNull { - jsonToProjection(it.value) - } - - /** - * Get the [Projection]. - */ - override fun get(aggregateId: ID): P = - jedis - .hget( - projectionClass.redisHKey, - aggregateId.id.toString(), - ).let { - if (it == null || it == "nil") { - initialStateBuilder(aggregateId) - } else { - jsonToProjection(it) - } - } - - override fun save(projection: P) { - lock.withLock { - if (get(projection.aggregateId).lastEventVersion < projection.lastEventVersion) { - jedis.hset( - projection.redisHKey, - projection.aggregateId.id.toString(), - projectionToJson(projection), - ) - logger.info { "Projection saved" } - } else { - logger.error { "Projection save SKIP (an early version exists)" } - error("Projection save SKIP (an early version exists)") - } - } - } -} - -private val

> KClass

.redisHKey: String get() = - "projection:$simpleName" - -private val

> P.redisHKey: String get() = - this::class.redisHKey diff --git a/src/main/kotlin/eventDemo/libs/eventSource/Event.kt b/src/main/kotlin/eventDemo/libs/eventSource/Event.kt new file mode 100644 index 0000000..2989a98 --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/eventSource/Event.kt @@ -0,0 +1,32 @@ +package eventDemo.libs.eventSource + +import eventDemo.libs.serializer.UUIDSerializer +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * Represent an ID for one aggregate, and it used in events + * @see Event + */ +interface AggregateId { + val id: UUID +} + +/** + * The basic interface for an Event + * @see eventDemo.libs.eventSource.eventStore.EventStream + */ +interface Event { + val eventId: EventId + val aggregateId: ID + val createdAt: Instant + val version: Int +} + +@JvmInline +@Serializable +value class EventId( + @Serializable(with = UUIDSerializer::class) + val id: UUID = UUID.randomUUID(), +) diff --git a/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt new file mode 100644 index 0000000..b2e3108 --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt @@ -0,0 +1,19 @@ +package eventDemo.libs.eventSource.eventStore + +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event +import io.github.oshai.kotlinlogging.withLoggingContext + +interface EventStore, ID : AggregateId> { + fun getStream(aggregateId: ID): EventStream + + @Throws(VersionConflictException::class) + fun append(event: E) = + withLoggingContext("event" to event.toString()) { + getStream(event.aggregateId).append(event) + } + + @Throws(VersionConflictException::class) + fun append(events: Set) = + events.forEach { append(it) } +} diff --git a/src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt similarity index 75% rename from src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt index 9021a76..ac85b99 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt @@ -1,5 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap diff --git a/src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt similarity index 65% rename from src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt index b15859e..1dc2882 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt @@ -1,12 +1,15 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import javax.sql.DataSource class EventStoreInPostgresql, ID : AggregateId>( private val dataSource: DataSource, private val objectToString: (E) -> String, private val stringToObject: (String) -> E, + private val tableName: String, ) : EventStore { override fun getStream(aggregateId: ID): EventStream = - EventStreamInPostgresql(aggregateId, dataSource, objectToString, stringToObject) + EventStreamInPostgresql(aggregateId, dataSource, objectToString, stringToObject, tableName) } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStream.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt similarity index 65% rename from src/main/kotlin/eventDemo/libs/event/EventStream.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt index 5cf7a82..faf9b2e 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStream.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt @@ -1,6 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore -import eventDemo.libs.event.projection.Projection +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.withLoggingContext /** @@ -10,13 +11,14 @@ interface EventStream, ID : AggregateId> { val aggregateId: ID /** Publishes a single event to the event stream */ - fun publish(event: E) + @Throws(VersionConflictException::class) + fun append(event: E) /** Publishes multiple events to the event stream */ - fun publish(vararg events: E) { + fun append(vararg events: E) { events.forEach { withLoggingContext("event" to it.toString()) { - publish(it) + append(it) } } } @@ -29,12 +31,12 @@ interface EventStream, ID : AggregateId> { fun readVersionBetween(version: IntRange): Set - fun

> readVersionBetween( - projection: P?, - event: E, - ): Set = - readVersionBetween(((projection?.lastEventVersion ?: 0) + 1)..event.version) - fun getByVersion(version: Int): E? = readVersionBetween(version..version).firstOrNull() + + fun exist(): Boolean } + +class VersionConflictException( + event: Event<*>, +) : RuntimeException("Version conflict: ${event.version}") diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt similarity index 81% rename from src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt index 48607ad..9b8428b 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt @@ -1,5 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.KotlinLogging import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue @@ -15,7 +17,7 @@ class EventStreamInMemory, ID : AggregateId>( private val logger = KotlinLogging.logger {} private val events: Queue = ConcurrentLinkedQueue() - override fun publish(event: E) { + override fun append(event: E) { if (event.aggregateId != aggregateId) { throw EventStreamPublishException( "You cannot publish this event in this stream because it has a different aggregateId!", @@ -34,4 +36,7 @@ class EventStreamInMemory, ID : AggregateId>( events .filter { version.contains(it.version) } .toSet() + + override fun exist(): Boolean = + events.isNotEmpty() } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt similarity index 54% rename from src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt index fe81fe5..a41f768 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt @@ -1,7 +1,11 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.eventSource.Event import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext import org.postgresql.util.PGobject +import org.postgresql.util.PSQLException import javax.sql.DataSource /** @@ -14,31 +18,44 @@ class EventStreamInPostgresql, ID : AggregateId>( private val dataSource: DataSource, private val objectToString: (E) -> String, private val stringToObject: (String) -> E, + private val tableName: String, ) : EventStream { private val logger = KotlinLogging.logger {} - override fun publish(event: E) { - if (event.aggregateId != aggregateId) { - throw EventStreamPublishException( - "You cannot publish this event in this stream because it has a different aggregateId!", - ) - } - dataSource.connection.use { connection -> - connection - .prepareStatement( - """ - insert into event_stream(id, aggregate_id, version, data) - values (?, ?, ?, ?) - """.trimIndent(), - ).use { - it.setObject(1, event.eventId) - it.setObject(2, event.aggregateId.id) - it.setInt(3, event.version) - it.setObject(4, PGJsonb(objectToString(event))) - it.executeUpdate() + override fun append(event: E) { + withLoggingContext("event" to event.toString()) { + if (event.aggregateId != aggregateId) { + throw EventStreamPublishException( + "You cannot publish this event in this stream because it has a different aggregateId!", + ) + } + try { + dataSource.connection.use { connection -> + connection + .prepareStatement( + """ + insert into $tableName (id, aggregate_id, version, data) + values (?, ?, ?, ?) + on conflict (id) do nothing + """.trimIndent(), + ).use { + it.setObject(1, event.eventId.id) + it.setObject(2, event.aggregateId.id) + it.setInt(3, event.version) + it.setObject(4, PGJsonb(objectToString(event))) + it.executeUpdate() + } } + } catch (e: PSQLException) { + if (e.serverErrorMessage?.constraint == "game_event_stream_aggregate_id_version_key") { + logger.warn { "duplicate version" } + throw VersionConflictException(event) + } else { + throw e + } + } + logger.info { "Event appended" } } - logger.info { "Event published" } } override fun readAll(): Set = @@ -46,8 +63,8 @@ class EventStreamInPostgresql, ID : AggregateId>( connection .prepareStatement( """ - select data - from event_stream + select data + from $tableName where aggregate_id = ? order by version asc """.trimIndent(), @@ -66,13 +83,32 @@ class EventStreamInPostgresql, ID : AggregateId>( } } + override fun exist(): Boolean = + dataSource.connection.use { connection -> + connection + .prepareStatement( + """ + select 1 + from $tableName + where aggregate_id = ? + limit 1 + order by version asc + """.trimIndent(), + ).use { + it.setObject(1, aggregateId.id) + it.executeQuery().use { resultSet -> + resultSet.next() + } + } + } + override fun readVersionBetween(version: IntRange): Set = dataSource.connection.use { connection -> connection .prepareStatement( """ select data - from event_stream + from $tableName where version between ? and ? and aggregate_id = ? order by version asc diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt similarity index 66% rename from src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt index c2013f7..df58501 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt +++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt @@ -1,4 +1,4 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource.eventStore class EventStreamPublishException( override val message: String, diff --git a/src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt b/src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt similarity index 86% rename from src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt rename to src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt index e51fd3d..902dfcd 100644 --- a/src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt +++ b/src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt @@ -1,4 +1,4 @@ -package eventDemo.libs +package eventDemo.libs.helpers import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.websocket.Frame @@ -25,8 +25,9 @@ inline fun CoroutineScope.toObjectChannel( return produce(capacity = bufferSize) { frames.consumeEach { frame -> if (frame is Frame.Text) { - logger.debug { "Conversion of the Frame: ${frame.readText()}" } - send(Json.decodeFromString(frame.readText())) + val frameText = frame.readText() + logger.debug { "Conversion of the Frame: $frameText to ${T::class.simpleName}" } + send(Json.decodeFromString(frameText)) } else { logger.warn { "The frame is not a text frame" } } diff --git a/src/main/kotlin/eventDemo/libs/ListToRange.kt b/src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt similarity index 89% rename from src/main/kotlin/eventDemo/libs/ListToRange.kt rename to src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt index 4db29c3..bf15b76 100644 --- a/src/main/kotlin/eventDemo/libs/ListToRange.kt +++ b/src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt @@ -1,4 +1,4 @@ -package eventDemo.libs +package eventDemo.libs.helpers fun List.toRanges(): List = fold(listOf()) { acc, i -> diff --git a/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt b/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt new file mode 100644 index 0000000..72dd2ed --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt @@ -0,0 +1,25 @@ +package eventDemo.libs.helpers + +inline fun Map.withReplacedValue( + toReplace: K, + transform: (V) -> V, +): Map = + mapValues { + if (it.key == toReplace) { + transform(it.value) + } else { + it.value + } + } + +inline fun Set.withReplacedValue( + toReplace: V, + transform: (V) -> V, +): Set = + map { + if (it == toReplace) { + transform(it) + } else { + it + } + }.toSet() diff --git a/src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt b/src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt similarity index 94% rename from src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt rename to src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt index 6328f10..92b186d 100644 --- a/src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt +++ b/src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt @@ -1,4 +1,4 @@ -package eventDemo.configuration.serializer +package eventDemo.libs.serializer import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt b/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt new file mode 100644 index 0000000..71098b0 --- /dev/null +++ b/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt @@ -0,0 +1,12 @@ +package eventDemo.sharedKernel + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import java.util.UUID + +internal val ApplicationCall.currentUserId: UserId + get() = + principal()!!.run { + UserId(UUID.fromString(payload.getClaim("userid").asString())) + } diff --git a/src/main/kotlin/eventDemo/sharedKernel/UserId.kt b/src/main/kotlin/eventDemo/sharedKernel/UserId.kt new file mode 100644 index 0000000..7c6a20a --- /dev/null +++ b/src/main/kotlin/eventDemo/sharedKernel/UserId.kt @@ -0,0 +1,13 @@ +package eventDemo.sharedKernel + +import eventDemo.libs.eventSource.AggregateId +import eventDemo.libs.serializer.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +@JvmInline +value class UserId( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), +) : AggregateId diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 69b6a46..66a0f43 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -3,7 +3,7 @@ ktor { port = 8080 } application { - modules = [ eventDemo.configuration.ConfigureKt.configure ] + modules = [ eventDemo.configuration.ConfigureKtorKt.configure ] } } diff --git a/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt b/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt deleted file mode 100644 index 32f2246..0000000 --- a/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package eventDemo.adapter.infrastructure.event - -import com.rabbitmq.client.ConnectionFactory -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventBus -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.testKoinApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.kotest.datatest.withData -import io.kotest.matchers.equals.shouldBeEqual -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import java.util.function.Function -import kotlin.time.Duration.Companion.seconds - -class GameEventBusInRabbitMQTest : - FunSpec({ - context("Pub/sub") { - testKoinApplicationWithConfig { - val busListToTest: Map = - mapOf( - GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(), - GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get()), - ) - - withData(busListToTest) { bus -> - val spy = spyk<() -> Unit>() - val aggregateId = GameId() - val player1 = Player(name = "Tesla") - val player2 = Player(name = "Einstein") - - bus.subscribe { obj -> - spy() - obj.aggregateId shouldBeEqual aggregateId - } - bus.publish(NewPlayerEvent(aggregateId, player1, 1)) - bus.publish(NewPlayerEvent(aggregateId, player2, 2)) - - eventually(1.seconds) { - verify(exactly = 2) { spy() } - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt deleted file mode 100644 index 55d8823..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.Player -import eventDemo.configuration.ktor.makeJwt -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.header - -internal fun HttpRequestBuilder.withAuth(player: Player) { - header("Authorization", "Bearer ${player.makeJwt("secret")}") -} diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt deleted file mode 100644 index 4c50d65..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameListRouteTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.projection.GameList -import eventDemo.testApplicationWithConfig -import io.github.oshai.kotlinlogging.KotlinLogging -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import io.ktor.client.call.body -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -val logger = KotlinLogging.logger {} - -class GameListRouteTest : - FunSpec({ - test("/games with no game started") { - - testApplicationWithConfig { - val player1 = Player(name = "Nikola") - logger.info { "Starting player1" } - httpClient() - .get("/games") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - val list = call.body>() - assertTrue(list.isEmpty()) - } - } - } - - test("/games return a game with status OPENING") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - testApplicationWithConfig( - { - get() - .handle(gameId) { - NewPlayerEvent(gameId, player1, it) - } - }, - ) { - // Wait until the projection is created - eventually(10.seconds) { - httpClient() - .get("/games") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - call.body>().first().let { - it.status shouldBeEqual GameList.Status.OPENING - it.players shouldHaveSize 1 - it.players shouldContain player1 - it.winners shouldHaveSize 0 - } - } - } - } - } - - test("/games return a game with status IS_STARTED") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - testApplicationWithConfig({ - val eventHandler = get() - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } - eventHandler.handle(gameId) { - GameStartedEvent.new( - gameId, - setOf(player1, player2), - it, - shuffleIsDisabled = true, - ) - } - }) { - eventually(3.seconds) { - httpClient() - .get("/games") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - call.body>().first().let { - it.status shouldBeEqual GameList.Status.IS_STARTED - it.players shouldHaveSize 2 - it.players shouldContain player1 - it.players shouldContain player2 - it.winners shouldHaveSize 0 - } - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt deleted file mode 100644 index 6efdcfe..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt +++ /dev/null @@ -1,196 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.Tag -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.command.command.IWantToPlayCardCommand -import eventDemo.domain.command.command.IamReadyToPlayCommand -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.disableShuffleDeck -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.notification.CommandSuccessNotification -import eventDemo.domain.notification.ItsTheTurnOfNotification -import eventDemo.domain.notification.Notification -import eventDemo.domain.notification.PlayerAsJoinTheGameNotification -import eventDemo.domain.notification.PlayerAsPlayACardNotification -import eventDemo.domain.notification.PlayerWasReadyNotification -import eventDemo.domain.notification.TheGameWasStartedNotification -import eventDemo.domain.notification.WelcomeToTheGameNotification -import eventDemo.testKoinApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.nondeterministic.until -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -@DelicateCoroutinesApi -class GameSimulationTest : - FunSpec({ - tags(Tag.Postgresql) - - test("Simulation of a game") { - withTimeout(10.seconds) { - disableShuffleDeck() - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - val channelCommand1 = Channel(Channel.BUFFERED) - val channelCommand2 = Channel(Channel.BUFFERED) - val channelNotification1 = Channel(Channel.BUFFERED) - val channelNotification2 = Channel(Channel.BUFFERED) - - var playedCard1: Card? = null - var playedCard2: Card? = null - - var player1HasJoin = false - - testKoinApplicationWithConfig { - val commandHandler = get() - val playerNotificationListener = get() - val gameStateRepository = get() - - // Run command handler - // In the normal process, these handlers is invoque players connect to the websocket - run { - GlobalScope.launch(Dispatchers.IO) { - commandHandler.handleIncomingPlayerCommands(player1, gameId, channelCommand1, channelNotification1) - } - GlobalScope.launch(Dispatchers.IO) { - commandHandler.handleIncomingPlayerCommands(player2, gameId, channelCommand2, channelNotification2) - } - } - - // Consume etch notification of players, and put theses in a list. - // Is used later to control when other players can execute the next action - val player1Notifications = mutableListOf() - val player2Notifications = mutableListOf() - run { - GlobalScope.launch { - for (notification in channelNotification1) { - player1Notifications.add(notification) - } - } - - GlobalScope.launch { - for (notification in channelNotification2) { - player2Notifications.add(notification) - } - } - } - - // Player 1 actions - val player1Job = - launch { - playerNotificationListener.startListening(player1, gameId) { - channelNotification1.trySendBlocking(it) - } - IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player1)).also { sendCommand -> - channelCommand1.send(sendCommand) - player1Notifications.waitNotification { commandId == sendCommand.id } - } - - player1HasJoin = true - - player1Notifications.waitNotification { players == setOf(player1) } - player1Notifications.waitNotification { player == player2 } - - IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(gameId, player1)).also { sendCommand -> - channelCommand1.send(sendCommand) - player1Notifications.waitNotification { commandId == sendCommand.id } - } - player1Notifications.waitNotification { player == player2 } - val player1Hand = player1Notifications.waitNotification { hand.size == 7 }.hand - - playedCard1 = player1Hand.first() - player1Notifications.waitNotification { player == player1 } - - IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(gameId, player1, player1Hand.first())).also { sendCommand -> - channelCommand1.send(sendCommand) - player1Notifications.waitNotification { commandId == sendCommand.id } - } - - player1Notifications.waitNotification { player == player2 } - - player1Notifications.waitNotification { - player == player2 && card == playedCard2 - } - } - - // Player 2 actions - val player2Job = - launch { - // wait player 1 has joined the game - until(3.seconds) { player1HasJoin } - - playerNotificationListener.startListening(player2, gameId) { - channelNotification2.trySendBlocking(it) - } - - IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player2)).also { sendCommand -> - channelCommand2.send(sendCommand) - player2Notifications.waitNotification { commandId == sendCommand.id } - } - - player2Notifications.waitNotification { players == setOf(player1, player2) } - player2Notifications.waitNotification { player == player1 } - - IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(gameId, player2)).also { sendCommand -> - channelCommand2.send(sendCommand) - player2Notifications.waitNotification { commandId == sendCommand.id } - } - - val player2Hand = - player2Notifications.waitNotification { hand.size == 7 }.hand - - player2Notifications.waitNotification { player == player1 } - player2Notifications.waitNotification { - player == player1 && card == playedCard1 - } - playedCard2 = player2Hand.first() - - player2Notifications.waitNotification { player == player2 } - - IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(gameId, player2, player2Hand.first())).also { sendCommand -> - channelCommand2.send(sendCommand) - player2Notifications.waitNotification { commandId == sendCommand.id } - } - } - - // Wait the end of the game - joinAll(player1Job, player2Job) - - // Build the last state from the event store - val state = gameStateRepository.get(gameId) - - // Check if the state is correct - state.aggregateId shouldBeEqual gameId - assertTrue(state.isStarted) - state.players shouldBeEqual setOf(player1, player2) - state.readyPlayers shouldBeEqual setOf(player1, player2) - state.direction shouldBeEqual GameState.Direction.CLOCKWISE - assertNotNull(state.lastCardPlayer) shouldBeEqual player2 - assertNotNull(state.cardOnCurrentStack) shouldBeEqual assertNotNull(playedCard2) - } - } - } - }) - -private suspend inline fun MutableList.waitNotification(crossinline block: T.() -> Boolean): T = - eventually(3.seconds) { - filterIsInstance().first { block(it) } - } diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt deleted file mode 100644 index 60298ab..0000000 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameStateRouteTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package eventDemo.adapter.presenter.query - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.disableShuffleDeck -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.event.projection.GameStateRepository -import eventDemo.testApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.nondeterministic.until -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import io.ktor.client.call.body -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.runBlocking -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.seconds - -class GameStateRouteTest : - FunSpec({ - test("""The route "/games/{id}/state" should returns a non started game when is not exist""") { - testApplicationWithConfig { - val gameId = GameId() - val player1 = Player(name = "Nikola") - - httpClient() - .get("/games/$gameId/state") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - call.body().apply { - aggregateId shouldBeEqual gameId - players shouldHaveSize 0 - isStarted shouldBeEqual false - } - } - } - } - - test("""The route "/games/{id}/state" should returns the state with all informations""") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - testApplicationWithConfig({ - disableShuffleDeck() - val eventHandler = get() - val stateRepo = get() - - runBlocking { - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } - val lastPlayedCard = eventually(3.seconds) { stateRepo.get(gameId).playableCards(player1).first() } - assertIs(lastPlayedCard) - .let { - it.number shouldBeEqual 0 - it.color shouldBeEqual Card.Color.Red - } - eventHandler.handle(gameId) { - CardIsPlayedEvent( - gameId, - lastPlayedCard, - player1, - it, - ) - } - until(3.seconds) { - stateRepo - .get(gameId) - .deck.discard - .last() == lastPlayedCard - } - } - }) { - httpClient() - .get("/games/$gameId/state") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - call.body().apply { - aggregateId shouldBeEqual gameId - players shouldHaveSize 2 - isStarted shouldBeEqual true - assertIs(lastEvent) - readyPlayers shouldBeEqual setOf(player1, player2) - direction shouldBeEqual GameState.Direction.CLOCKWISE - assertNotNull(lastCardPlayer) shouldBeEqual player1 - assertNotNull(colorOnCurrentStack) shouldBeEqual Card.Color.Red - } - } - } - } - - test("""The route "/games/{id}/card/last" should return the last card played of the game""") { - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - var lastPlayedCard: Card? = null - testApplicationWithConfig({ - disableShuffleDeck() - val eventHandler = get() - val stateRepo = get() - - runBlocking { - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } - eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } - lastPlayedCard = eventually(3.seconds) { stateRepo.get(gameId).playableCards(player1).first() } - assertIs(lastPlayedCard) - .let { - it.number shouldBeEqual 0 - it.color shouldBeEqual Card.Color.Red - } - eventHandler.handle(gameId) { - CardIsPlayedEvent( - gameId, - assertNotNull(lastPlayedCard), - player1, - it, - ) - } - - until(3.seconds) { - stateRepo - .get(gameId) - .deck.discard - .last() == lastPlayedCard - } - } - }) { - httpClient() - .get("/games/$gameId/card/last") { - withAuth(player1) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - assertEquals(assertNotNull(lastPlayedCard), call.body()) - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt b/src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt new file mode 100644 index 0000000..ed9eb98 --- /dev/null +++ b/src/test/kotlin/eventDemo/architecture/HexagonalArchitectureTest.kt @@ -0,0 +1,42 @@ +package eventDemo.architecture + +import com.tngtech.archunit.core.importer.ClassFileImporter +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.library.Architectures.layeredArchitecture +import org.junit.jupiter.api.Test + +/** + * Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters). + * + * Convention attendue : + * eventDemo.contexts.uno.domain + * eventDemo.contexts.uno.application + * eventDemo.contexts.uno.infrastructure + * + * Règles imposées : + * – domain → ne dépend d'aucune autre couche (ni application, ni infrastructure) + * – application → ne dépend que de domain (jamais d'infrastructure) + * – infrastructure → ne dépend que de domain et application + */ +class HexagonalArchitectureTest { + private val basePackage = "eventDemo.contexts.uno" + + private val classes = + ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(basePackage) + + @Test + fun `respecte les couches de l'architecture hexagonale`() { + @Suppress("ktlint:standard:chain-method-continuation") + layeredArchitecture() + .consideringAllDependencies() + .layer("Domain").definedBy("$basePackage.domain..") + .layer("Application").definedBy("$basePackage.application..") + .layer("Infrastructure").definedBy("$basePackage.infrastructure..") + .whereLayer("Domain").mayNotAccessAnyLayer() + .whereLayer("Application").mayOnlyAccessLayers("Domain") + .whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application") + .check(classes) + } +} diff --git a/src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt b/src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt new file mode 100644 index 0000000..6da3519 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/auth/domain/UserTest.kt @@ -0,0 +1,34 @@ +package eventDemo.contexts.auth.domain + +import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertNotNull + +class UserTest : + FunSpec({ + + test("Create User") { + User.createNewUser("Bob", "changeit").run { + username shouldBe "Bob" + password shouldBe "changeit" + } + } + + test("Create User With event") { + User + .loadFromHistory( + setOf( + NewUserCreatedEvent( + "Bob", + "changeit", + version = 1, + ), + ), + ).run { + assertNotNull(this) + username shouldBe "Bob" + password shouldBe "changeit" + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt new file mode 100644 index 0000000..231fe1d --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt @@ -0,0 +1,282 @@ +package eventDemo.contexts.game.application + +import eventDemo.Tag +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.game.application.channels.GameChannelsSubscriber +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification +import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification +import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification +import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.gameState.Game +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import eventDemo.contexts.game.domain.game.gameState.disableRandomForTest +import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.createGameWithCommandsInChannels +import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.joinTheGame +import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.playCard +import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.readyToPlay +import eventDemo.testHelpers.createNewUser +import eventDemo.testHelpers.testKoinApplicationWithConfig +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.nondeterministic.until +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.equals.shouldEqual +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.assertInstanceOf +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@DelicateCoroutinesApi +class GameSimulationTest : + FunSpec({ + tags(Tag.Postgresql) + + test("Simulation of a game") { + withTimeout(10.seconds) { + disableRandomForTest() + val gameId = GameId() + val user1 = createNewUser("user1") + val user2 = createNewUser("user2") + + val channelCommand1 = Channel(Channel.BUFFERED) + val channelCommand2 = Channel(Channel.BUFFERED) + val channelNotification1 = Channel(Channel.BUFFERED) + val channelNotification2 = Channel(Channel.BUFFERED) + + var playedCard1: Card? = null + var playedCard2: Card? = null + + var player1HasJoin = false + + testKoinApplicationWithConfig { + val gameRepository = get() + val userRepository = get() + userRepository.run { + save(user1) + save(user2) + } + + gameRepository.create(gameId) + + // Run command/notification subscriber + // In the normal process, these subscriber is invoque on players connect to the websocket + GlobalScope.launch(Dispatchers.IO) { + get().subscribePlayerToGameChannels( + gameId, + user1.id, + channelCommand1, + channelNotification1, + ) + } + GlobalScope.launch(Dispatchers.IO) { + get().subscribePlayerToGameChannels( + gameId, + user2.id, + channelCommand2, + channelNotification2, + ) + } + + // Consume etch notification of players, and put theses in a list. + // Is used later to control when other players can execute the next action + val player1Notifications = mutableListOf() + val player2Notifications = mutableListOf() + run { + GlobalScope.launch { + for (notification in channelNotification1) { + player1Notifications.add(notification) + } + } + + GlobalScope.launch { + for (notification in channelNotification2) { + player2Notifications.add(notification) + } + } + } + + // Player 1 actions + val player1Job = + launch { + createGameWithCommandsInChannels(channelCommand1, gameId, user1) { + + joinTheGame() + player1Notifications.waitNotification { + players.map { it.userId }.contains(user1.id) + } + + player1HasJoin = true + + player1Notifications.waitNotification { + player.userId == user2.id + } + + readyToPlay() + player1Notifications.waitNotification { + playerId == getPlayer(user2).id + } + + playedCard1 = + player1Notifications + .waitNotification { hand.size == 7 } + .hand + .first() + .apply { + this.shouldBeInstanceOf() + number shouldEqual 1 + color shouldEqual Card.Color.Red + } + + player1Notifications.waitNotification { + if (player.userId == user2.id) error("WRONG PLAYER TURN") + player.userId == user1.id + } + + game + .shouldBeInstanceOf() + .discardPile + .topCard + .shouldNotBeNull() + .shouldBeInstanceOf { + it.number shouldEqual 0 + it.color shouldEqual Card.Color.Red + } + + playCard(playedCard1!!) + + player1Notifications.waitNotification { + player == getPlayer(user2) + } + + player1Notifications.waitNotification { + playerId == getPlayer(user2).id && card == playedCard2 + } + + playedCard1 = + assertInstanceOf(game) + .playableCards(currentPlayer.id) + .first() + + playedCard1.run { + this.shouldBeInstanceOf() + number shouldEqual 2 + color shouldEqual Card.Color.Red + } + + playCard(playedCard1) + + player1Notifications.waitNotification { + 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 { + players.map { it.userId }.contains(user1.id) && + players.map { it.userId }.contains(user2.id) + } + player2Notifications.waitNotification { playerId == getPlayer(user1).id } + + readyToPlay() + + playedCard2 = + player2Notifications + .waitNotification { hand.size == 7 } + .hand + .first() + .apply { + this.shouldBeInstanceOf() + number shouldEqual 8 + color shouldEqual Card.Color.Red + } + + player2Notifications.waitNotification { + if (player.userId == user2.id) error("WRONG PLAYER TURN") + player.userId == user1.id + } + player2Notifications.waitNotification { + playerId == getPlayer(user1).id && card == playedCard1 + } + + player2Notifications.waitNotification { + player == currentPlayer + } + + game + .shouldBeInstanceOf() + .discardPile + .topCard + .shouldNotBeNull() + .shouldBeInstanceOf { + it.number shouldEqual 1 + it.color shouldEqual Card.Color.Red + } + + playCard(playedCard2) + + player2Notifications.waitNotification { + player.userId == user1.id + } + player2Notifications.waitNotification { + 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(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) + } + } + } + }) + +context(user: User) +private suspend inline fun MutableList.waitNotification(crossinline block: T.() -> Boolean): T { + println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}") + return eventually(3.seconds) { + filterIsInstance() + .first { block(it) } + .also { remove(it) } + }.also { println("NOTIFICATION RECEIVED: ${T::class.simpleName} for user: ${user.username}") } +} diff --git a/src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt new file mode 100644 index 0000000..f516afd --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/eventStore/GameEventStoreRepositoryTest.kt @@ -0,0 +1,121 @@ +package eventDemo.contexts.game.application.eventStore + +import ch.qos.logback.classic.Level +import com.rabbitmq.client.impl.ForgivingExceptionHandler +import eventDemo.Tag +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.testHelpers.CreateGameWithCommandsHelpers +import eventDemo.testHelpers.CreateGameWithCommandsHelpers.joinTheGame +import eventDemo.testHelpers.createNewUser +import eventDemo.testHelpers.testKoinApplicationWithConfig +import eventDemo.testHelpers.withLogLevel +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.nondeterministic.eventuallyConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import org.slf4j.Logger +import java.util.UUID +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateCoroutinesApi::class) +class GameEventStoreRepositoryTest : + FunSpec({ + tags(Tag.Postgresql) + + val user1 = createNewUser("user1") + val user2 = createNewUser("user2") + + test("GameRepository should build return the game after dispatch commands") { + testKoinApplicationWithConfig { + get().run { + save(user1) + } + CreateGameWithCommandsHelpers.createGameWithCommands { + user1.joinTheGame() + getPlayer(user1).userId shouldBeEqual user1.id + } + } + } + + test("get should build the last version of the state") { + withLogLevel( + ForgivingExceptionHandler::class.java.name to Level.OFF, + ) { + testKoinApplicationWithConfig { + val repo = get() + get().run { + save(user1) + save(user2) + } + + CreateGameWithCommandsHelpers.createGameWithCommands { + user1.joinTheGame() + assertNotNull(repo.get(gameId)).run { + players.isNotEmpty() shouldBeEqual true + players.get(user1.id).userId shouldBeEqual user1.id + } + user2.joinTheGame() + assertNotNull(repo.get(gameId)).run { + players.isNotEmpty() shouldBeEqual true + players.size shouldBeEqual 2 + players.get(user1.id).userId shouldBeEqual user1.id + players.get(user2.id).userId shouldBeEqual user2.id + } + } + } + } + } + + test("get should be concurrently secure").config(tags = setOf(Tag.Concurrence)) { + withLogLevel( + Logger.ROOT_LOGGER_NAME to Level.ERROR, + ForgivingExceptionHandler::class.java.name to Level.OFF, + ) { + var aggregateIds: MutableList = mutableListOf() + testKoinApplicationWithConfig { + val repo = get() + + val gameName = "testGame${UUID.randomUUID()}" + (1..2) + .map { treadN -> + GlobalScope + .launch { + CreateGameWithCommandsHelpers.createGameWithCommands("testGame $treadN") { + repeat(3) { userN -> + val userX = createNewUser("userX $treadN:$userN") + get().save(userX) + userX.joinTheGame() + } + aggregateIds.add(gameId) + } + } + }.joinAll() + + eventually( + eventuallyConfig { + duration = 5.seconds + interval = 1.seconds + includeFirst = false + }, + ) { + aggregateIds shouldHaveSize 2 + aggregateIds.forEach { + assertNotNull(repo.get(it)).run { + version shouldBeEqual 4 + players shouldHaveSize 3 + } + } + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt new file mode 100644 index 0000000..8cd6bce --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriberTest.kt @@ -0,0 +1,85 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInMemory +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler +import eventDemo.contexts.game.application.command.handlers.PlayCardHandler +import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler +import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository +import eventDemo.contexts.game.application.notification.models.Notification +import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory +import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInMemory +import eventDemo.sharedKernel.UserId +import eventDemo.testHelpers.createNewUser +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.equals.shouldEqual +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import org.junit.jupiter.api.assertInstanceOf +import kotlin.time.Duration.Companion.seconds + +class EventToNotificationSubscriberTest : + FunSpec({ + + test("When event when send to the bus, a notification should be published") { + val bus = GameEventBusInMemory() + val gameRepository = GameEventStoreRepository(GameEventStoreInMemory()) + val userRepository = UserEventStoreRepository(UserEventStoreInMemory()) + val subscriber = + EventToNotificationSubscriber( + bus, + gameRepository, + ) + + val commentDispatcher = + GameCommandHandlerDispatcher( + PlayCardHandler(gameRepository, bus), + ReadyToPlayHandler(gameRepository, bus), + JoinTheGameHandler(gameRepository, bus, userRepository), + TakeCartFromDrawPileHandler(gameRepository, bus), + ) + val notificationChannel = Channel(Channel.BUFFERED) + + val game = gameRepository.create() + + val user1 = createNewUser("user1") + val user2 = createNewUser("user2") + userRepository.run { + save(user1) + save(user2) + } + + val player1Notifications = mutableListOf() + GlobalScope.launch { + for (notification in notificationChannel) { + player1Notifications.add(notification) + } + } + + commentDispatcher.dispatch(JoinTheGameCommand(user1.id, JoinTheGameCommand.Payload(game.aggregateId))) + subscriber + .subscribeToEventsAndSendNotification( + game.aggregateId, + user2.id, + notificationChannel, + ).use { + commentDispatcher.dispatch(JoinTheGameCommand(user2.id, JoinTheGameCommand.Payload(game.aggregateId))) + } + + eventually(duration = 1.seconds) { + player1Notifications.size shouldEqual 1 + } + player1Notifications.first().let { notification -> + assertInstanceOf(notification) + notification.players.map { it.userId } shouldContain user2.id + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/application/notification/ToNotificationTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/notification/ToNotificationTest.kt new file mode 100644 index 0000000..be1f9a7 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/application/notification/ToNotificationTest.kt @@ -0,0 +1,301 @@ +package eventDemo.contexts.game.application.notification + +import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification +import eventDemo.contexts.game.application.notification.models.PilesShuffledNotification +import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification +import eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification +import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification +import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification +import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification +import eventDemo.contexts.game.application.notification.models.YourNewCardNotification +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.GameStartedEvent +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerReadyEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerHand +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.gameState.GameCreated +import eventDemo.contexts.game.domain.game.gameState.GameStarted +import eventDemo.sharedKernel.UserId +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertInstanceOf + +class ToNotificationTest : + FunSpec({ + val player1 = + Player( + name = "Bob", + userId = UserId(), + hand = PlayerHand(setOf(Card.NumericCard(1, Card.Color.Red))), + id = Player.PlayerId(), + ) + val player2 = + Player( + name = "John", + userId = UserId(), + hand = PlayerHand(setOf(Card.NumericCard(1, Card.Color.Red))), + id = Player.PlayerId(), + ) + + test("NewPlayerEvent") { + val game = + GameCreated( + aggregateId = GameId(), + version = 1, + players = PlayerList(setOf(player1)), + recordedEvents = setOf(), + ) + NewPlayerEvent( + game.aggregateId, + version = 2, + player = player1, + ).toNotification( + game = game, + currentUserId = player1.userId, + ).let { + it.toList().size shouldBe 1 + // Check if the user is + assertInstanceOf(it.first()).run { + players.size shouldBe 1 + players.first().name shouldBe "Bob" + } + } + } + + test("PlayerReadyEvent") { + val game = + GameCreated( + aggregateId = GameId(), + version = 1, + players = PlayerList(setOf(player1)), + recordedEvents = setOf(), + ) + PlayerReadyEvent( + game.aggregateId, + version = 2, + playerId = player1.id, + ).toNotification( + game = game, + currentUserId = player1.userId, + ).let { + it.toList().size shouldBe 1 + assertInstanceOf(it.first()).let { + it.playerId shouldBe player1.id + } + } + } + + test("PlayerHaveDrawCardEvent on current player") { + val game = + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + drawPile = DrawPile(), + discardPile = DiscardPile(), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + currentColor = Card.Color.Red, + version = 1, + recordedEvents = setOf(), + ) + val card = Card.NumericCard(1, Card.Color.Blue) + PlayerHaveDrawCardEvent( + game.aggregateId, + version = 2, + playerId = player1.id, + takenCards = setOf(card), + ).toNotification( + game = game, + currentUserId = player1.userId, + ).let { + it.toList().size shouldBe 2 + it.toList().let { notifications -> + assertInstanceOf(notifications.first()).let { + it.cards.first() shouldBe card + } + assertInstanceOf(notifications[1]).let { + it.player.id shouldBe player2.id + } + } + } + } + test("PlayerHaveDrawCardEvent on other player") { + val game = + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + drawPile = DrawPile(), + discardPile = DiscardPile(), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + currentColor = Card.Color.Red, + version = 1, + recordedEvents = setOf(), + ) + val card = Card.NumericCard(1, Card.Color.Blue) + PlayerHaveDrawCardEvent( + game.aggregateId, + version = 2, + playerId = player1.id, + takenCards = setOf(card), + ).toNotification( + game = game, + currentUserId = player2.userId, + ).let { + it.toList().size shouldBe 2 + it.toList().let { notifications -> + assertInstanceOf(notifications.first()).let { + it.playerId shouldBe player1.id + } + assertInstanceOf(notifications[1]).let { + it.player.id shouldBe player2.id + } + } + } + } + + test("CardIsPlayedEvent on current player") { + val game = + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + drawPile = DrawPile(), + discardPile = DiscardPile(), + lastPlayerId = player2.id, + nextPlayerId = player1.id, + currentColor = Card.Color.Red, + version = 1, + recordedEvents = setOf(), + ) + val card = Card.NumericCard(1, Card.Color.Blue) + CardIsPlayedEvent( + game.aggregateId, + version = 2, + playerId = player1.id, + card = card, + ).toNotification( + game = game, + currentUserId = player1.userId, + ).toList() + .let { notifications -> + notifications.size shouldBe 2 + assertInstanceOf(notifications.first()).let { + it.playerId shouldBe player1.id + it.card shouldBe card + } + assertInstanceOf(notifications[1]).let { + it.player.id shouldBe player1.id + } + } + } + test("CardIsPlayedEvent on other player") { + val game = + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + drawPile = DrawPile(), + discardPile = DiscardPile(), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + currentColor = Card.Color.Red, + version = 1, + recordedEvents = setOf(), + ) + val card = Card.NumericCard(1, Card.Color.Blue) + CardIsPlayedEvent( + game.aggregateId, + version = 2, + playerId = player2.id, + card = card, + ).toNotification( + game = game, + currentUserId = player1.userId, + ).let { + it.toList().size shouldBe 2 + it.toList().let { notifications -> + assertInstanceOf(notifications.first()).let { + it.playerId shouldBe player2.id + it.card shouldBe card + } + assertInstanceOf(notifications[1]).let { + it.player.id shouldBe player2.id + } + } + } + } + + test("DrawFilledWithDiscardEvent") { + val game = + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + drawPile = DrawPile(), + discardPile = DiscardPile(), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + currentColor = Card.Color.Red, + version = 1, + recordedEvents = setOf(), + ) + DrawFilledWithDiscardEvent( + game.aggregateId, + version = 2, + newDrawPile = DrawPile(), + newDiscardPile = DiscardPile(), + ).toNotification( + game = game, + currentUserId = player1.userId, + ).let { + it.toList().size shouldBe 1 + assertInstanceOf(it.first()) + } + } + + test("GameStartedEvent") { + val game = + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + drawPile = DrawPile(), + discardPile = DiscardPile(), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + currentColor = Card.Color.Red, + version = 1, + recordedEvents = setOf(), + ) + GameStartedEvent( + game.aggregateId, + version = 2, + firstPlayer = player1.id, + playersHans = + mapOf( + player1.id to player1.hand, + player2.id to player2.hand, + ), + drawPile = DrawPile(), + discardPile = DiscardPile(), + ).toNotification( + game = game, + currentUserId = player1.userId, + ).toList() + .let { notifications -> + notifications.size shouldBe 2 + assertInstanceOf(notifications.first()).let { + it.hand.size shouldBe 1 + it.hand.first() shouldBe player1.hand.cards.first() + } + assertInstanceOf(notifications[1]).let { + it.player.id shouldBe player2.id + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt b/src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt new file mode 100644 index 0000000..f47d400 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/domain/game/PlayerHandTest.kt @@ -0,0 +1,46 @@ +package eventDemo.contexts.game.domain.game + +import eventDemo.testHelpers.act +import eventDemo.testHelpers.arrange +import eventDemo.testHelpers.assert +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.ints.shouldBeExactly +import org.junit.jupiter.api.assertInstanceOf + +class PlayerHandTest : + FunSpec({ + test("can be add new card to the hand") { + arrange { + PlayerHand( + setOf( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(1, Card.Color.Red), + Card.NumericCard(2, Card.Color.Red), + ), + ) + }.act { hand -> + hand.withNewCards(setOf(Card.NumericCard(3, Card.Color.Red))) + }.assert { hand -> + hand.size shouldBeExactly 4 + } + } + + test("can be remove card to the hand") { + arrange { + PlayerHand( + setOf( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(1, Card.Color.Red), + Card.NumericCard(2, Card.Color.Red), + ), + ) + }.act { hand -> + hand.withoutTheCards(setOf(hand.cards.elementAt(0))) + }.assert { hand -> + hand.size shouldBeExactly 2 + assertInstanceOf>(hand.cards) + hand.cards.elementAt(0).number shouldBeExactly 1 + hand.cards.elementAt(1).number shouldBeExactly 2 + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt b/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt new file mode 100644 index 0000000..c7fb713 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStartedTest.kt @@ -0,0 +1,389 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.events.CardIsPlayedEvent +import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent +import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent +import eventDemo.contexts.game.domain.events.PlayerWinEvent +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.DiscardPile +import eventDemo.contexts.game.domain.game.DrawPile +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.PlayerHand +import eventDemo.contexts.game.domain.game.PlayerList +import eventDemo.contexts.game.domain.game.gameState.Game.Direction +import eventDemo.sharedKernel.UserId +import eventDemo.testHelpers.act +import eventDemo.testHelpers.assert +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertInstanceOf + +class GameStartedTest : + FunSpec({ + context(GameStarted::canBePlayThisCard.name) { + val dataOk: Map> = + listOf( + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(5, Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(0, Card.Color.Blue), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Green), + Card.NumericCard(0, Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus4Card(), + null, + ), + Triple( + Card.Plus4Card(), + Card.Plus4Card(), + Card.Color.Red, + ), + Triple( + Card.Plus4Card(), + Card.NumericCard(0, Card.Color.Blue), + Card.Color.Blue, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.Plus2Card(Card.Color.Blue), + null, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.Plus4Card(), + null, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.ChangeColorCard(), + null, + ), + Triple( + Card.Plus2Card(Card.Color.Red), + Card.NumericCard(0, Card.Color.Red), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.ChangeColorCard(), + null, + ), + Triple( + Card.ReverseCard(Card.Color.Red), + Card.NumericCard(0, Card.Color.Red), + null, + ), + ).associateBy { "I can play ${it.second} on ${it.first}${if (it.third != null) " when choose color is ${it.third}" else ""}" } + + withData(dataOk) { + canBePlayThisCard( + it.first, + it.second, + it.third, + ) shouldBe true + } + + val dataKo: Map> = + listOf( + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.NumericCard(9, Card.Color.Blue), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Blue), + null, + ), + Triple( + Card.NumericCard(0, Card.Color.Red), + Card.Plus2Card(Card.Color.Blue), + null, + ), + Triple( + Card.Plus4Card(), + Card.NumericCard(0, Card.Color.Blue), + Card.Color.Red, + ), + ).associateBy { "I cannot play ${it.second} on ${it.first}${if (it.third != null) " when choose color is ${it.third}" else ""}" } + + withData(dataKo) { + canBePlayThisCard( + it.first, + it.second, + it.third, + ) shouldBe false + } + } + + context("applyEvent") { + context("${CardIsPlayedEvent::class.simpleName}") { + test("with numeric card") { + val card1 = Card.NumericCard(2, Card.Color.Red) + assert { + gameWithCard( + played1Hand = PlayerHand(cards = setOf(card1)), + onTheDiscardPile = Card.NumericCard(0, Card.Color.Red), + ).apply { nextPlayer shouldBe player1 } + }.act { + it.applyEvent( + CardIsPlayedEvent( + it.aggregateId, + card1, + playerId = it.player1.id, + version = it.version + 1, + ), + ) + }.assert { + it.currentColor shouldBe Card.Color.Red + assertInstanceOf(it.lastPlayedCard).number shouldBe 2 + it.direction shouldBe Game.Direction.CLOCKWISE + it.nextPlayer shouldBe it.player2 + it.player1.hand.size shouldBe 0 + } + } + test("with revert turn card") { + val card1 = Card.ReverseCard(Card.Color.Red) + assert { + gameWithCard( + played1Hand = PlayerHand(cards = setOf(card1)), + onTheDiscardPile = Card.NumericCard(0, Card.Color.Red), + ).apply { nextPlayer shouldBe player1 } + }.act { + it.applyEvent( + CardIsPlayedEvent( + it.aggregateId, + card1, + playerId = it.player1.id, + version = it.version + 1, + ), + ) + }.assert { + it.currentColor shouldBe Card.Color.Red + assertInstanceOf(it.lastPlayedCard).color shouldBe Card.Color.Red + it.direction shouldBe Game.Direction.COUNTER_CLOCKWISE + it.nextPlayer shouldBe it.player3 + it.player1.hand.size shouldBe 0 + } + } + } + + test("${DrawFilledWithDiscardEvent::class.simpleName}") { + val card1 = Card.NumericCard(1, Card.Color.Blue) + val card2 = Card.NumericCard(2, Card.Color.Yellow) + val card3 = Card.NumericCard(3, Card.Color.Red) + assert { + val player1 = Player("Player 1", UserId()) + val player2 = Player("Player 2", UserId()) + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1)), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + drawPile = DrawPile(), + discardPile = + DiscardPile( + setOf( + card1, + card2, + card3, + ), + ), + currentColor = card3.color, + version = 0, + recordedEvents = emptySet(), + ) + }.act { + it.applyEvent( + DrawFilledWithDiscardEvent( + it.aggregateId, + version = it.version + 1, + newDrawPile = + DrawPile( + setOf( + card1, + card2, + ), + ), + newDiscardPile = + DiscardPile( + setOf( + card3, + ), + ), + ), + ) + }.assert { + it.currentColor shouldBe Card.Color.Red + assertInstanceOf(it.lastPlayedCard).color shouldBe Card.Color.Red + } + } + + test("${PlayerHaveDrawCardEvent::class.simpleName}") { + val card1 = Card.NumericCard(1, Card.Color.Blue) + val card2 = Card.NumericCard(2, Card.Color.Red) + val card3 = Card.NumericCard(3, Card.Color.Red) + val card4 = Card.NumericCard(4, Card.Color.Red) + + val player1 = Player("Jo", UserId()) + val player2 = Player("Bob", UserId()) + assert { + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1)), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + drawPile = + DrawPile( + setOf( + card2, + card3, + card4, + ), + ), + discardPile = + DiscardPile( + setOf( + card1, + ), + ), + currentColor = card1.color, + version = 0, + recordedEvents = emptySet(), + ) + }.act { + it.applyEvent( + PlayerHaveDrawCardEvent( + it.aggregateId, + version = it.version + 1, + playerId = it.player1.id, + takenCards = + setOf( + card2, + card3, + ), + ), + ) + }.assert { + it.player1.hand + .run { + cards.elementAt(0) shouldBe card2 + cards.elementAt(1) shouldBe card3 + } + } + } + + test("${PlayerWinEvent::class.simpleName}") { + val player1 = Player("Jo", UserId()) + val player2 = Player("Poo", UserId()) + assert { + GameStarted( + aggregateId = GameId(), + players = PlayerList(setOf(player1, player2)), + lastPlayerId = player1.id, + nextPlayerId = player2.id, + drawPile = DrawPile(), + discardPile = DiscardPile(), + currentColor = Card.Color.Yellow, + version = 0, + recordedEvents = emptySet(), + ) + }.act { + it.applyEvent( + PlayerWinEvent( + it.aggregateId, + version = it.version + 1, + playerId = it.player1.id, + ), + ) + }.assert { + it.playerWins.size shouldBe 1 + it.playersInGame.size shouldBe 0 + it.players.size shouldBe 2 + } + } + } + + test("nextPlayer") { } + + test("players") { } + + test("currentColor") { } + + test("playedTurnHistory") { } + + test("direction") { } + + test("playerWins") { } + + test("version") { } + + test("recordedEvents") { } + }) + +private val GameStarted.player1: Player + get() = players.players.elementAt(0) + +private val GameStarted.player2: Player + get() = players.players.elementAt(1) + +private val GameStarted.player3: Player + get() = players.players.elementAt(2) + +private fun canBePlayThisCard( + onTheDiscardPile: Card, + playedCard: Card, + chosenColor: Card.Color? = null, +): Boolean = + gameWithCard( + played1Hand = PlayerHand(setOf(playedCard)), + onTheDiscardPile = onTheDiscardPile, + chosenColor = chosenColor, + ).run { + canBePlayThisCard(playedCard) + } + +private fun gameWithCard( + played1Hand: PlayerHand, + played2Hand: PlayerHand = PlayerHand(setOf(Card.NumericCard(9, Card.Color.Yellow))), + onTheDiscardPile: Card, + chosenColor: Card.Color? = null, +): GameStarted { + val player1 = Player("Tesla", UserId(), hand = played1Hand) + val player2 = Player("Einstein", UserId(), hand = played2Hand) + val player3 = Player("Curie", UserId(), hand = PlayerHand(setOf(Card.NumericCard(8, Card.Color.Yellow)))) + val players = PlayerList(setOf(player1, player2, player3)) + return GameStarted( + aggregateId = GameId(), + players = players, + lastPlayerId = player3.id, + nextPlayerId = players.nextPlayerTurn(player3.id, Direction.CLOCKWISE), + discardPile = DiscardPile(setOf(onTheDiscardPile)), + drawPile = DrawPile(), + currentColor = (onTheDiscardPile as? Card.CardWithColor)?.color ?: chosenColor ?: error("no color"), + version = 0, + recordedEvents = emptySet(), + ) +} diff --git a/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/NewDeckTest.kt b/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/NewDeckTest.kt new file mode 100644 index 0000000..f017394 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/domain/game/gameState/NewDeckTest.kt @@ -0,0 +1,41 @@ +package eventDemo.contexts.game.domain.game.gameState + +import eventDemo.contexts.game.domain.game.Card +import io.kotest.assertions.retry +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlin.time.Duration.Companion.seconds + +class NewDeckTest : + FunSpec({ + + test("newDeck") { + newDeck().let { + it shouldNotBe null + it.filterIsInstance() shouldHaveSize 76 + it.filterIsInstance() shouldHaveSize 8 + it.filterIsInstance() shouldHaveSize 8 + it.filterIsInstance() shouldHaveSize 8 + it.filterIsInstance() shouldHaveSize 4 + it.filterIsInstance() shouldHaveSize 4 + it shouldHaveSize 108 + } + } + + test("shuffleDeck") { + val deck = (0..9).map { Card.NumericCard(it, Card.Color.Red) } + deck.run { + this[3].number shouldBe 3 + } + should { + retry(maxRetry = 4, timeout = 1.seconds) { + deck.shuffled().run { + this[3].number shouldNotBe 3 + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt new file mode 100644 index 0000000..3f15d11 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/AuthHelper.kt @@ -0,0 +1,10 @@ +package eventDemo.contexts.game.intrastructure + +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.auth.infrastructure.configure.makeJwt +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header + +internal fun HttpRequestBuilder.withAuth(user: User) { + header("Authorization", "Bearer ${user.makeJwt("secret")}") +} diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/TestHttpClient.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/TestHttpClient.kt similarity index 73% rename from src/test/kotlin/eventDemo/adapter/presenter/query/TestHttpClient.kt rename to src/test/kotlin/eventDemo/contexts/game/intrastructure/TestHttpClient.kt index 95ef09d..ec6cb45 100644 --- a/src/test/kotlin/eventDemo/adapter/presenter/query/TestHttpClient.kt +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/TestHttpClient.kt @@ -1,5 +1,6 @@ -package eventDemo.adapter.presenter.query -import eventDemo.configuration.ktor.defaultJsonSerializer +package eventDemo.contexts.game.intrastructure + +import eventDemo.contexts.game.infrastructure.configuration.ktor.defaultJsonSerializer import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json diff --git a/src/test/kotlin/eventDemo/externalServices/PostgresqlTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/PostgresqlTest.kt similarity index 67% rename from src/test/kotlin/eventDemo/externalServices/PostgresqlTest.kt rename to src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/PostgresqlTest.kt index 46c8b3e..b9f2c45 100644 --- a/src/test/kotlin/eventDemo/externalServices/PostgresqlTest.kt +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/PostgresqlTest.kt @@ -1,7 +1,7 @@ -package eventDemo.externalServices +package eventDemo.contexts.game.intrastructure.persistence.connectors import eventDemo.Tag -import eventDemo.testKoinApplicationWithConfig +import eventDemo.testHelpers.testKoinApplicationWithConfig import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.equals.shouldBeEqual import javax.sql.DataSource @@ -10,10 +10,9 @@ class PostgresqlTest : FunSpec({ tags(Tag.Postgresql) - test("test connection with postgresql") { + test("test connection with PostgreSQL") { testKoinApplicationWithConfig { - val datasource by inject() - datasource.connection.use { connection -> + get().connection.use { connection -> connection .prepareStatement( """ diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt new file mode 100644 index 0000000..7a64fb8 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RabbitMQTest.kt @@ -0,0 +1,60 @@ +package eventDemo.contexts.game.intrastructure.persistence.connectors + +import com.rabbitmq.client.AMQP.BasicProperties +import com.rabbitmq.client.BuiltinExchangeType +import com.rabbitmq.client.ConnectionFactory +import com.rabbitmq.client.DefaultConsumer +import com.rabbitmq.client.Envelope +import eventDemo.Tag +import eventDemo.testHelpers.spyPing +import eventDemo.testHelpers.testKoinApplicationWithConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.string.shouldStartWith +import java.util.UUID +import kotlin.time.Duration.Companion.seconds + +class RabbitMQTest : + FunSpec({ + tags(Tag.RabbitMQ) + + test("test connection with RabbitMQ") { + testKoinApplicationWithConfig { + val exchangeName = "test_" + UUID.randomUUID() + get().newConnection().use { connection -> + connection + .createChannel() + .use { channel -> + channel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT) + val queue = channel.queueDeclare("myQueue", true, false, false, emptyMap()).queue + channel.queueBind(queue, exchangeName, "") + + spyPing(3.seconds, exactly = 2) { ping -> + channel + .basicConsume( + queue, + object : DefaultConsumer(channel) { + override fun handleDelivery( + consumerTag: String, + envelope: Envelope, + properties: BasicProperties, + body: ByteArray, + ) { + val msg = body.toString(Charsets.UTF_8) + msg shouldStartWith "testMessage" + ping() + channel.basicAck(envelope.deliveryTag, false) + } + }, + ) + + channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage1".toByteArray()) + channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage2".toByteArray()) + } + + channel.queueDelete(queue) + channel.exchangeDelete(exchangeName) + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt new file mode 100644 index 0000000..9ac04cb --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/connectors/RedisTest.kt @@ -0,0 +1,21 @@ +package eventDemo.contexts.game.intrastructure.persistence.connectors + +import eventDemo.Tag +import eventDemo.testHelpers.testKoinApplicationWithConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import redis.clients.jedis.UnifiedJedis + +class RedisTest : + FunSpec({ + tags(Tag.Redis) + + test("test connection with jedis") { + testKoinApplicationWithConfig { + get().also { + it.set("test", "test") + it.get("test") shouldBeEqual "test" + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt new file mode 100644 index 0000000..8edb240 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/persistence/eventBus/GameEventBusInRabbitMQTest.kt @@ -0,0 +1,44 @@ +package eventDemo.contexts.game.intrastructure.persistence.eventBus + +import com.rabbitmq.client.ConnectionFactory +import eventDemo.contexts.game.application.ports.GameEventBus +import eventDemo.contexts.game.domain.events.NewPlayerEvent +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory +import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ +import eventDemo.sharedKernel.UserId +import eventDemo.testHelpers.spyPing +import eventDemo.testHelpers.testKoinApplicationWithConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.equals.shouldBeEqual +import kotlin.time.Duration.Companion.seconds + +class GameEventBusInRabbitMQTest : + FunSpec({ + context("Pub/sub") { + testKoinApplicationWithConfig { + val busListToTest: Map = + mapOf( + GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(), + GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get()), + ) + + withData(busListToTest) { bus -> + spyPing(1.seconds, exactly = 2) { ping -> + val aggregateId = GameId() + val player1 = Player(name = "Tesla", UserId()) + val player2 = Player(name = "Einstein", UserId()) + + bus.subscribe { obj -> + ping() + obj.aggregateId shouldBeEqual aggregateId + } + bus.publish(NewPlayerEvent(aggregateId, player1, 1)) + bus.publish(NewPlayerEvent(aggregateId, player2, 2)) + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt b/src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt new file mode 100644 index 0000000..0a1cdf0 --- /dev/null +++ b/src/test/kotlin/eventDemo/contexts/game/intrastructure/rest/GameListRouteTest.kt @@ -0,0 +1,128 @@ +package eventDemo.contexts.game.intrastructure.rest + +import eventDemo.contexts.auth.application.eventStores.UserRepository +import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList +import eventDemo.contexts.game.intrastructure.httpClient +import eventDemo.contexts.game.intrastructure.withAuth +import eventDemo.testHelpers.CreateGameWithCommandsHelpers +import eventDemo.testHelpers.CreateGameWithCommandsHelpers.joinTheGame +import eventDemo.testHelpers.CreateGameWithCommandsHelpers.readyToPlay +import eventDemo.testHelpers.createNewUser +import eventDemo.testHelpers.testApplicationWithConfig +import io.github.oshai.kotlinlogging.KotlinLogging +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.nondeterministic.eventuallyConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +val logger = KotlinLogging.logger {} + +class GameListRouteTest : + FunSpec({ + test("/games with no game started") { + val user1 = createNewUser("user1") + testApplicationWithConfig({ + get().save(user1) + }) { + logger.info { "Starting player1" } + httpClient() + .get("/games") { + withAuth(user1) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + val list = call.body>() + assertTrue(list.isEmpty()) + } + } + } + + test("/games return a game with status OPENING") { + val user1 = createNewUser("user1") + testApplicationWithConfig({ + get().save(user1) + CreateGameWithCommandsHelpers.createGameWithCommands { + user1.joinTheGame() + } + }) { + // Wait until the projection is created + eventually( + eventuallyConfig { + initialDelay = 1.seconds + interval = 1.seconds + duration = 3.seconds + }, + ) { + httpClient() + .get("/games") { + withAuth(user1) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + call.body>().let { + assertNotNull(it) + assertTrue { it.isNotEmpty() } + it.firstOrNull()?.run { + status shouldBeEqual GameList.Status.OPENING + players shouldHaveSize 1 + players.map { it.userId } shouldContain user1.id + winners shouldHaveSize 0 + } + } + } + } + } + } + + test("/games return a game with status IS_STARTED") { + val user1 = createNewUser("user1") + val user2 = createNewUser("user2") + testApplicationWithConfig({ + CreateGameWithCommandsHelpers.createGameWithCommands { + get().run { + save(user1) + save(user2) + } + user1.joinTheGame() + user2.joinTheGame() + getPlayer(user1).readyToPlay() + getPlayer(user2).readyToPlay() + } + }) { + eventually( + eventuallyConfig { + initialDelay = 1.seconds + interval = 1.seconds + duration = 3.seconds + }, + ) { + httpClient() + .get("/games") { + withAuth(user1) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + call.body>().first().let { + it.status shouldBeEqual GameList.Status.IS_STARTED + it.players shouldHaveSize 2 + it.players.map { it.userId } shouldContain user1.id + it.players.map { it.userId } shouldContain user2.id + it.winners shouldHaveSize 0 + } + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt b/src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt deleted file mode 100644 index 0e4085b..0000000 --- a/src/test/kotlin/eventDemo/domain/command/GameCommandHandlerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package eventDemo.domain.command - -import eventDemo.Tag -import eventDemo.domain.command.command.GameCommand -import eventDemo.domain.command.command.IWantToJoinTheGameCommand -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener -import eventDemo.domain.notification.CommandSuccessNotification -import eventDemo.domain.notification.Notification -import eventDemo.domain.notification.WelcomeToTheGameNotification -import eventDemo.testKoinApplicationWithConfig -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlin.test.assertIs -import kotlin.time.Duration.Companion.seconds - -@OptIn(DelicateCoroutinesApi::class) -class GameCommandHandlerTest : - FunSpec({ - tags(Tag.Postgresql) - - test("handle a command should execute the command") { - withTimeout(5.seconds) { - testKoinApplicationWithConfig { - val commandHandler = get() - val notificationListener = get() - val gameId = GameId() - val player = Player("Tesla") - val channelCommand = Channel(Channel.BUFFERED) - val channelNotification = Channel(Channel.BUFFERED) - notificationListener.startListening( - player, - gameId, - ) { channelNotification.trySendBlocking(it) } - - GlobalScope.launch { - commandHandler.handleIncomingPlayerCommands( - player, - gameId, - channelCommand, - channelNotification, - ) - } - - IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)).also { sendCommand -> - channelCommand.send(sendCommand) - channelNotification.receive().let { - assertIs(it).commandId shouldBeEqual sendCommand.id - } - } - assertIs(channelNotification.receive()).let { - it.players shouldContain player - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt b/src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt deleted file mode 100644 index 3e77fd2..0000000 --- a/src/test/kotlin/eventDemo/domain/command/GameCommandRunnerTest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eventDemo.domain.command - -import io.kotest.core.spec.style.FunSpec - -class GameCommandRunnerTest : - FunSpec({ - test("run should run the correct command") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt deleted file mode 100644 index 6764e95..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/ICantPlayCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class ICantPlayCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt deleted file mode 100644 index 6f9647f..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class IWantToJoinTheGameCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt deleted file mode 100644 index 7c79599..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class IWantToPlayCardCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt b/src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt deleted file mode 100644 index 3c10ab9..0000000 --- a/src/test/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommandTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eventDemo.domain.command.command - -import io.kotest.core.spec.style.FunSpec - -class IamReadyToPlayCommandTest : - FunSpec({ - - xtest("run should publish the event") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/entity/DeckTest.kt b/src/test/kotlin/eventDemo/domain/entity/DeckTest.kt deleted file mode 100644 index 134e083..0000000 --- a/src/test/kotlin/eventDemo/domain/entity/DeckTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package eventDemo.domain.entity - -import eventDemo.allCardCount -import eventDemo.allCards -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeUnique -import io.kotest.matchers.ints.shouldBeExactly -import kotlin.test.assertNotNull - -class DeckTest : - FunSpec({ - val totalCardsNumber = 104 - test("newWithoutPlayers") { - // When - val deck = Deck.newWithoutPlayers() - - // Then - deck.stack.size shouldBeExactly totalCardsNumber - deck.discard.size shouldBeExactly 0 - deck.playersHands.size shouldBeExactly 0 - - deck.allCardCount() shouldBeExactly totalCardsNumber - deck.allCards().shouldBeUnique() - deck.allCards().map { it.id }.shouldBeUnique() - } - - test("initHands should be generate the hands of all players from the stack") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers() - - // When - val initDeck = deck.initHands(players) - - // Then - initDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - initDeck.discard.size shouldBeExactly 0 - initDeck.playersHands.size shouldBeExactly playerNumbers - initDeck.playersHands.forEach { (_, cards) -> cards.size shouldBeExactly 7 } - initDeck.allCardCount() shouldBeExactly totalCardsNumber - } - - test("takeOneCardFromStackTo player") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers().initHands(players) - val firstPlayer = players.first() - - // When - val modifiedDeck = deck.takeOneCardFromStackTo(firstPlayer) - - // Then - modifiedDeck.discard.size shouldBeExactly 0 - modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1 - modifiedDeck.playersHands.size shouldBeExactly playerNumbers - assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 7 + 1 - modifiedDeck.playersHands - .filterKeys { it != firstPlayer.id } - .forEach { (_, cards) -> cards.size shouldBeExactly 7 } - modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber - } - - test("putOneCardFromHand") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers().initHands(players) - val firstPlayer = players.first() - - // When - val card = deck.playersHands.getHand(firstPlayer)!!.first() - val modifiedDeck = deck.putOneCardFromHand(firstPlayer, card) - - // Then - modifiedDeck.discard.size shouldBeExactly 1 - modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - modifiedDeck.playersHands.size shouldBeExactly playerNumbers - assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 6 - modifiedDeck.playersHands - .filterKeys { it != firstPlayer.id } - .forEach { (_, cards) -> cards.size shouldBeExactly 7 } - modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber - } - - test("placeFirstCardOnDiscard") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val deck = Deck.newWithoutPlayers().initHands(players) - - // When - val modifiedDeck = deck.placeFirstCardOnDiscard() - - // Then - modifiedDeck.discard.size shouldBeExactly 1 - modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1 - modifiedDeck.playersHands.size shouldBeExactly playerNumbers - modifiedDeck.playersHands - .forEach { (_, cards) -> cards.size shouldBeExactly 7 } - modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber - } - }) diff --git a/src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt b/src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt deleted file mode 100644 index c5febed..0000000 --- a/src/test/kotlin/eventDemo/domain/entity/PlayerHandKtTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eventDemo.domain.entity - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.ints.shouldBeExactly -import kotlin.test.assertNotNull - -class PlayerHandKtTest : - FunSpec({ - test("addCards") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val firstPlayer = players.first() - val playersHands = PlayersHands(players) - val card = Card.NumericCard(0, Card.Color.Red) - - // When - val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card)) - - assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 - assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 - } - - test("removeCard") { - // Given - val playerNumbers = 4 - val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() - val firstPlayer = players.first() - val card1 = Card.NumericCard(1, Card.Color.Red) - val card2 = Card.NumericCard(2, Card.Color.Red) - val playersHands: PlayersHands = - PlayersHands(players) - .addCards(firstPlayer, listOf(card1, card2)) - - // When - val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1) - - assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 - assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 - } - }) diff --git a/src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt b/src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt deleted file mode 100644 index 72b4128..0000000 --- a/src/test/kotlin/eventDemo/domain/entity/PlayersHandsTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eventDemo.domain.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") { } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt b/src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt deleted file mode 100644 index 5c221a5..0000000 --- a/src/test/kotlin/eventDemo/domain/event/GameEventHandlerTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package eventDemo.domain.event - -import eventDemo.adapter.infrastructure.event.GameEventBusInMemory -import eventDemo.adapter.infrastructure.event.GameEventStoreInMemory -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.GameEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.libs.event.VersionBuilderLocal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -class GameEventHandlerTest : - FunSpec({ - test("handle event should publish the event to the stream") { - // Given - val eventBus: GameEventBus = GameEventBusInMemory() - val eventStore: GameEventStore = spyk(GameEventStoreInMemory()) - val handler = - GameEventHandler( - eventBus = eventBus, - eventStore = eventStore, - versionBuilder = VersionBuilderLocal(), - ) - val gameId = GameId() - val player1 = Player("Tesla") - - // When - handler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - - // Then - coVerify(exactly = 1) { eventStore.publish(any()) } - - eventStore.getStream(gameId).readAll().let { events -> - events shouldHaveSize 1 - events.first().let { - assertIs(it) - it.aggregateId shouldBeEqual gameId - it.player.name shouldBeEqual "Tesla" - } - } - } - - test("handle event should publish the event to the bus") { - // Given - val eventBus: GameEventBus = spyk(GameEventBusInMemory()) - val eventStore: GameEventStore = GameEventStoreInMemory() - val handler = - GameEventHandler( - eventBus = eventBus, - eventStore = eventStore, - versionBuilder = VersionBuilderLocal(), - ) - val gameId = GameId() - val player1 = Player("Tesla") - - // When - var event: GameEvent? = null - val spy = spyk<() -> Unit>() - eventBus.subscribe { - spy() - event = it - } - handler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - - // Then - verify(exactly = 1) { spy() } - coVerify(exactly = 1) { eventBus.publish(any()) } - - assertNotNull(event).let { - assertIs(it) - it.aggregateId shouldBeEqual gameId - it.player.name shouldBeEqual "Tesla" - } - } - - test("handle event should call version builder once") { - // Given - val eventBus: GameEventBus = GameEventBusInMemory() - val eventStore: GameEventStore = GameEventStoreInMemory() - val versionBuilder = spyk(VersionBuilderLocal()) - val handler = - GameEventHandler( - eventBus = eventBus, - eventStore = eventStore, - versionBuilder = versionBuilder, - ) - val gameId = GameId() - val player1 = Player("Tesla") - - // When - handler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } - - // Then - verify(exactly = 1) { versionBuilder.buildNextVersion(any()) } - - eventStore - .getStream(gameId) - .readAll() - .first() - .version shouldBeEqual 1 - } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt deleted file mode 100644 index 2dba9ae..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/GameStateBuilderTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.CardIsPlayedEvent -import eventDemo.domain.event.event.GameStartedEvent -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.event.PlayerReadyEvent -import eventDemo.domain.event.event.disableShuffleDeck -import eventDemo.libs.event.VersionBuilderLocal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -class GameStateBuilderTest : - FunSpec({ - test("apply") { - disableShuffleDeck() - val versionBuilder = VersionBuilderLocal() - val gameId = GameId() - val player1 = Player(name = "Nikola") - val player2 = Player(name = "Einstein") - - GameState(gameId) - .run { - val event = - NewPlayerEvent( - aggregateId = gameId, - player = player1, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.isReady shouldBeEqual false - state.isStarted shouldBeEqual false - } - }.run { - val event = - NewPlayerEvent( - aggregateId = gameId, - player = player2, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.players shouldBeEqual setOf(player1, player2) - } - }.run { - val event = - PlayerReadyEvent( - aggregateId = gameId, - player = player1, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.readyPlayers shouldBeEqual setOf(player1) - } - }.run { - val event = - PlayerReadyEvent( - aggregateId = gameId, - player = player2, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.readyPlayers shouldBeEqual setOf(player1, player2) - state.isReady shouldBeEqual true - state.isStarted shouldBeEqual false - } - }.run { - val event = - GameStartedEvent.new( - id = gameId, - players = setOf(player1, player2), - shuffleIsDisabled = true, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - state.isStarted shouldBeEqual true - assertIs(state.deck.stack.first()).let { - it.number shouldBeEqual 6 - it.color shouldBeEqual Card.Color.Red - } - } - }.run { - val playedCard = playableCards(player1)[0] - val event = - CardIsPlayedEvent( - aggregateId = gameId, - card = playedCard, - player = player1, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - assertNotNull(state.cardOnCurrentStack) shouldBeEqual playedCard - assertIs(playedCard).let { - it.number shouldBeEqual 0 - it.color shouldBeEqual Card.Color.Red - } - } - }.run { - val playedCard = playableCards(player2)[0] - val event = - CardIsPlayedEvent( - aggregateId = gameId, - card = playedCard, - player = player2, - version = versionBuilder.buildNextVersion(gameId), - ) - apply(event).also { state -> - state.aggregateId shouldBeEqual gameId - assertNotNull(state.cardOnCurrentStack) shouldBeEqual playedCard - assertIs(playedCard).let { - it.number shouldBeEqual 7 - it.color shouldBeEqual Card.Color.Red - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt deleted file mode 100644 index e2ea216..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/GameStateRepositoryTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package eventDemo.domain.event.projection - -import ch.qos.logback.classic.Level -import com.rabbitmq.client.impl.ForgivingExceptionHandler -import eventDemo.Tag -import eventDemo.domain.command.GameCommandHandler -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.GameEventHandler -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.testKoinApplicationWithConfig -import eventDemo.withLogLevel -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.nondeterministic.eventuallyConfig -import io.kotest.common.KotestInternal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import org.slf4j.Logger -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.seconds - -@OptIn(DelicateCoroutinesApi::class, KotestInternal::class) -class GameStateRepositoryTest : - FunSpec({ - tags(Tag.Postgresql) - - val player1 = Player("Tesla") - val player2 = Player(name = "Einstein") - - test("GameStateRepository should build the projection when a new event occurs") { - val aggregateId = GameId() - testKoinApplicationWithConfig { - val repo = get() - val eventHandler = get() - eventHandler - .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) } - .also { - // Wait until the projection is created - eventually(1.seconds) { - assertNotNull(repo.get(aggregateId)).also { - assertNotNull(it.players) shouldBeEqual setOf(player1) - } - } - } - } - } - - test("get should build the last version of the state") { - withLogLevel( - GameCommandHandler::class.java.name to Level.ERROR, - ForgivingExceptionHandler::class.java.name to Level.OFF, - ) { - val aggregateId = GameId() - testKoinApplicationWithConfig { - val repo = get() - val eventHandler = get() - val projectionBus = get() - - var state: GameState? = null - projectionBus.subscribe { - repo - .get(aggregateId) - .also { state = it } - } - - eventHandler - .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) } - .also { - eventually(1.seconds) { - assertNotNull(state).players.isNotEmpty() shouldBeEqual true - assertNotNull(state).players shouldBeEqual setOf(player1) - } - } - - eventHandler - .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) } - .also { - eventually(1.seconds) { - assertNotNull(repo.get(aggregateId)).also { - assertNotNull(it.players) shouldBeEqual setOf(player1, player2) - } - } - } - } - } - } - - test("get should be concurrently secure").config(tags = setOf(Tag.Concurrence)) { - withLogLevel( - Logger.ROOT_LOGGER_NAME to Level.ERROR, - ForgivingExceptionHandler::class.java.name to Level.OFF, - ) { - val aggregateId = GameId() - testKoinApplicationWithConfig { - val repo = get() - val eventHandler = get() - - (1..10) - .map { r -> - GlobalScope - .launch { - repeat(20) { r2 -> - val playerX = Player("player$r$r2") - eventHandler - .handle(aggregateId) { - NewPlayerEvent( - aggregateId = aggregateId, - player = playerX, - version = it, - ) - } - } - } - }.joinAll() - - eventually( - eventuallyConfig { - duration = 60.seconds - interval = 2.seconds - includeFirst = false - }, - ) { - repo.get(aggregateId).run { - lastEventVersion shouldBeEqual 200 - players shouldHaveSize 200 - } - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt deleted file mode 100644 index 22c30f8..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/GameStateTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package eventDemo.domain.event.projection - -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Deck -import eventDemo.domain.entity.Discard -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.entity.PlayersHands -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual - -class GameStateTest : - FunSpec({ - val player1 = Player("Tesla") - val player2 = Player("Einstein") - - test("isReady return false when all players in the game is not ready") { - GameState( - aggregateId = GameId(), - players = setOf(player1, player2), - readyPlayers = setOf(player1), - ).isReady shouldBeEqual false - } - - test("isReady return true when all players in the game is ready") { - GameState( - aggregateId = GameId(), - players = setOf(player1, player2), - readyPlayers = setOf(player1, player2), - ).isReady shouldBeEqual true - } - - xtest("nextPlayerTurn") { } - xtest("playerDiffIndex") { } - xtest("cardOnBoardIsForYou") { } - xtest("playableCards") { } - xtest("playerHasNoCardLeft") { } - - test("canBePlayThisCard return true when a card can be played on the current game") { - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(5, Card.Color.Red), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Blue), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Green), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Red), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Red), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus4Card(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.Plus4Card(), - playedCard = Card.Plus4Card(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.Plus4Card(), - playedCard = Card.Plus4Card(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Blue), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.ChangeColorCard(), - ) shouldBeEqual true - - canBePlayThisCard( - onTheDeck = Card.ReverseCard(Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Red), - ) shouldBeEqual true - } - - test("canBePlayThisCard return false when a card cannot be played on the current game") { - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.NumericCard(9, Card.Color.Blue), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Blue), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.NumericCard(0, Card.Color.Red), - playedCard = Card.Plus2Card(Card.Color.Blue), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.Plus4Card(), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.ChangeColorCard(), - ) shouldBeEqual false - - canBePlayThisCard( - onTheDeck = Card.Plus2Card(Card.Color.Red), - playedCard = Card.NumericCard(0, Card.Color.Red), - ) shouldBeEqual false - } - }) - -private fun canBePlayThisCard( - onTheDeck: Card, - playedCard: Card, -): Boolean { - val player1 = Player("Tesla") - return gameStateWithCard( - player = player1, - onTheDeck = onTheDeck, - playerHand = listOf(playedCard), - ).canBePlayThisCard(player1, playedCard) -} - -private fun gameStateWithCard( - player: Player, - onTheDeck: Card, - playerHand: List, -): GameState { - val player2 = Player("Einstein") - return GameState( - aggregateId = GameId(), - players = setOf(player, player2), - readyPlayers = setOf(player, player2), - lastCardPlayer = player2, - deck = - Deck( - discard = Discard(setOf(onTheDeck)), - playersHands = PlayersHands(mapOf(player.id to playerHand)), - ), - ) -} diff --git a/src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt b/src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt deleted file mode 100644 index 8d52556..0000000 --- a/src/test/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListenerTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package eventDemo.domain.event.projection.projectionListener - -import eventDemo.adapter.infrastructure.event.projection.GameProjectionBusInMemory -import eventDemo.domain.entity.GameId -import eventDemo.domain.entity.Player -import eventDemo.domain.event.event.NewPlayerEvent -import eventDemo.domain.event.projection.GameState -import eventDemo.domain.notification.WelcomeToTheGameNotification -import io.kotest.core.spec.style.FunSpec -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlin.test.assertIs - -class PlayerNotificationListenerTest : - FunSpec({ - - test("startListening should react when a projection is sent to the bus") { - val player = Player("Tesla") - val gameId = GameId() - val bus = GameProjectionBusInMemory() - val state = - GameState( - aggregateId = gameId, - lastEvent = NewPlayerEvent(gameId, player, 1), - players = setOf(player), - ) - val spy = spyk<() -> Unit>() - PlayerNotificationListener(bus).startListening(player, gameId) { - assertIs(it) - spy() - } - bus.publish(state) - - verify(exactly = 1) { spy() } - } - }) diff --git a/src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt b/src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt deleted file mode 100644 index f833593..0000000 --- a/src/test/kotlin/eventDemo/externalServices/RabbitMQTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package eventDemo.externalServices - -import com.rabbitmq.client.AMQP.BasicProperties -import com.rabbitmq.client.BuiltinExchangeType -import com.rabbitmq.client.ConnectionFactory -import com.rabbitmq.client.DefaultConsumer -import com.rabbitmq.client.Envelope -import eventDemo.Tag -import eventDemo.testKoinApplicationWithConfig -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.string.shouldStartWith -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import java.util.UUID -import kotlin.time.Duration.Companion.seconds - -class RabbitMQTest : - FunSpec({ - tags(Tag.RabbitMQ) - - test("test connection with RabbitMQ") { - testKoinApplicationWithConfig { - val factory = get() - - val exchangeName = "test_" + UUID.randomUUID() - - val spy = spyk<() -> Unit>() - - factory.newConnection().use { connection -> - connection - .createChannel() - .use { channel -> - channel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT) - val queue = channel.queueDeclare("qqq", true, false, false, emptyMap()).queue - channel.queueBind(queue, exchangeName, "") - - channel - .basicConsume( - queue, - object : DefaultConsumer(channel) { - override fun handleDelivery( - consumerTag: String, - envelope: Envelope, - properties: BasicProperties, - body: ByteArray, - ) { - val msg = body.toString(Charsets.UTF_8) - msg shouldStartWith "testMessage" - spy() - channel.basicAck(envelope.deliveryTag, false) - } - }, - ) - - channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage1".toByteArray()) - channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage2".toByteArray()) - - eventually(3.seconds) { - verify(exactly = 2) { spy() } - } - - channel.queueDelete(queue) - channel.exchangeDelete(exchangeName) - } - } - } - } - }) diff --git a/src/test/kotlin/eventDemo/externalServices/RedisTest.kt b/src/test/kotlin/eventDemo/externalServices/RedisTest.kt deleted file mode 100644 index 6f08f20..0000000 --- a/src/test/kotlin/eventDemo/externalServices/RedisTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package eventDemo.externalServices - -import eventDemo.Tag -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import redis.clients.jedis.JedisPooled - -private val redisUrl = "redis://localhost:6379" - -class RedisTest : - FunSpec({ - tags(Tag.Redis) - - test("test connection with jedis") { - JedisPooled(redisUrl).also { - it.set("test", "test") - it.get("test") shouldBeEqual "test" - } - } - }) diff --git a/src/test/kotlin/eventDemo/libs/bus/BusTest.kt b/src/test/kotlin/eventDemo/libs/bus/BusTest.kt index 70392aa..9fbc168 100644 --- a/src/test/kotlin/eventDemo/libs/bus/BusTest.kt +++ b/src/test/kotlin/eventDemo/libs/bus/BusTest.kt @@ -1,13 +1,10 @@ package eventDemo.libs.bus import com.rabbitmq.client.ConnectionFactory -import io.kotest.assertions.nondeterministic.eventually +import eventDemo.testHelpers.spyPing import io.kotest.core.spec.style.FunSpec import io.kotest.datatest.withData import io.kotest.matchers.string.shouldStartWith -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify import kotlin.random.Random import kotlin.time.Duration.Companion.seconds @@ -38,17 +35,13 @@ class BusTest : ) withData(list) { bus -> - val spy = spyk<() -> Unit>() - - bus.subscribe { obj -> - spy() - obj.value shouldStartWith "testMessage" - } - bus.publish(ObjTest("testMessage${Random.nextInt()}")) - bus.publish(ObjTest("testMessage${Random.nextInt()}")) - - eventually(1.seconds) { - verify(exactly = 2) { spy() } + spyPing(exactly = 2, duration = 1.seconds) { ping -> + bus.subscribe { obj -> + ping() + obj.value shouldStartWith "testMessage" + } + bus.publish(ObjTest("testMessage${Random.nextInt()}")) + bus.publish(ObjTest("testMessage${Random.nextInt()}")) } } } diff --git a/src/test/kotlin/eventDemo/libs/command/CommandForTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandForTest.kt new file mode 100644 index 0000000..1e4a0be --- /dev/null +++ b/src/test/kotlin/eventDemo/libs/command/CommandForTest.kt @@ -0,0 +1,8 @@ +package eventDemo.libs.command + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandForTest( + override val id: CommandId, +) : Command diff --git a/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt deleted file mode 100644 index fd1d43f..0000000 --- a/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package eventDemo.libs.command - -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.core.spec.style.FunSpec -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlin.time.Duration.Companion.seconds - -@Serializable -class CommandTest( - override val id: CommandId, -) : Command - -@OptIn(DelicateCoroutinesApi::class) -class CommandStreamChannelTest : - FunSpec({ - - test("send and receive") { - val command = CommandTest(CommandId()) - - val channel = Channel() - val stream = CommandStreamChannel(CommandRunnerController()) - - val spy = spyk<() -> Unit>() - - GlobalScope.launch { - stream.process(channel) { - println("In action ${it.id}") - spy() - } - } - - channel.send(command) - - eventually(3.seconds) { - verify(exactly = 1) { spy() } - } - } - }) diff --git a/src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt new file mode 100644 index 0000000..f86128f --- /dev/null +++ b/src/test/kotlin/eventDemo/libs/command/CommandUnicityCheckerTest.kt @@ -0,0 +1,34 @@ +package eventDemo.libs.command + +import eventDemo.testHelpers.spyPing +import io.kotest.core.spec.style.FunSpec +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration.Companion.seconds + +class CommandUnicityCheckerTest : + FunSpec({ + test("runOnlyOnce must run all commands") { + spyPing(exactly = 2, duration = 3.seconds) { ping -> + val com1 = CommandForTest(CommandId()) + val com2 = CommandForTest(CommandId()) + CommandUnicityChecker().run { + runOnlyOnce(com1) { ping() } + runOnlyOnce(com2) { ping() } + } + } + } + + test("runOnlyOnce") { + spyPing(exactly = 2, duration = 3.seconds) { ping -> + val com1 = CommandForTest(CommandId()) + val com2 = CommandForTest(CommandId()) + CommandUnicityChecker().run { + runOnlyOnce(com1) { ping() } + runOnlyOnce(com2) { ping() } + assertThrows { + runOnlyOnce(com2) { ping() } + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt b/src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt deleted file mode 100644 index fa1c2a3..0000000 --- a/src/test/kotlin/eventDemo/libs/event/EventHandlerTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package eventDemo.libs.event - -import eventDemo.libs.bus.Bus -import eventDemo.libs.bus.BusInMemory -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual - -class EventHandlerTest : - FunSpec({ - test("EventHandler::handle should returns the built event") { - val eventBus: Bus = BusInMemory() - val eventStore: EventStore = EventStoreInMemory() - val versionBuilder: VersionBuilder = VersionBuilderLocal() - val aggregateId: IdTest = IdTest() - val handler = - EventHandlerImpl( - eventBus, - eventStore, - versionBuilder, - ) - - // When - val event = - handler.handle(aggregateId) { - EventXTest(aggregateId = aggregateId, version = it, num = 1) - } - - // Then - event.aggregateId shouldBeEqual aggregateId - event.version shouldBeEqual 1 - } - - xtest("EventHandler::handle should publish the event into the store") - - xtest("EventHandler::handle should publish the event into the bus") - - xtest("EventHandler::handle should publish the event into the bus in incremental order") - }) diff --git a/src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt b/src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt deleted file mode 100644 index fc6908f..0000000 --- a/src/test/kotlin/eventDemo/libs/event/VersionBuilderLocalTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package eventDemo.libs.event - -import eventDemo.Tag -import io.kotest.common.KotestInternal -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch - -@OptIn(DelicateCoroutinesApi::class, KotestInternal::class) -class VersionBuilderLocalTest : - FunSpec({ - - test("buildNextVersion") { - VersionBuilderLocal().run { - val id = IdTest() - buildNextVersion(id) shouldBeEqual 1 - buildNextVersion(id) shouldBeEqual 2 - buildNextVersion(IdTest()) shouldBeEqual 1 - buildNextVersion(id) shouldBeEqual 3 - } - } - - test("buildNextVersion concurrently").config(tags = setOf(Tag.Concurrence)) { - val versionBuilder = VersionBuilderLocal() - val id = IdTest() - (1..20) - .map { - GlobalScope.launch { - (1..1000).map { - versionBuilder.buildNextVersion(id) - } - } - }.joinAll() - versionBuilder.getLastVersion(id) shouldBeEqual 20 * 1000 - } - - test("getLastVersion") { - VersionBuilderLocal().run { - val id = IdTest() - getLastVersion(id) shouldBeEqual 0 - getLastVersion(id) shouldBeEqual 0 - getLastVersion(id) shouldBeEqual 0 - } - } - }) diff --git a/src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt b/src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt deleted file mode 100644 index ad921b1..0000000 --- a/src/test/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryTest.kt +++ /dev/null @@ -1,225 +0,0 @@ -package eventDemo.libs.event.projection - -import eventDemo.cleanProjections -import eventDemo.configuration.serializer.UUIDSerializer -import eventDemo.libs.event.AggregateId -import eventDemo.libs.event.Event -import eventDemo.libs.event.EventStore -import eventDemo.libs.event.EventStoreInMemory -import eventDemo.libs.event.VersionBuilderLocal -import io.kotest.assertions.nondeterministic.continually -import io.kotest.core.spec.style.FunSpec -import io.kotest.datatest.withData -import io.kotest.engine.names.WithDataTestName -import io.kotest.matchers.equals.shouldBeEqual -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import redis.clients.jedis.JedisPooled -import java.util.UUID -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.seconds - -@OptIn(DelicateCoroutinesApi::class) -class ProjectionRepositoryTest : - FunSpec({ - data class TestData( - val store: EventStore, - val repository: ProjectionRepository, - ) : WithDataTestName { - override fun dataTestName(): String = - "${repository::class.simpleName} with ${store::class.simpleName}" - } - - val eventStores = - listOf( - { EventStoreInMemory() }, - ) - val projectionRepo = - listOf( - ::getRepoInMemoryTest, - ::getRepoInRedisTest, - ) - - val list = - eventStores.flatMap { store -> - projectionRepo.map { repo -> - TestData(store(), repo()) - } - } - - context("when call applyAndSave, the projection should be built and save to the repository") { - withData(list) { (eventStore, repo) -> - val aggregateId = IdTest() - - val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = IdTest()) -// eventStore.publish(eventOther) - val p = repo.applyAndSave(eventOther) - println(p) - assertNotNull(repo.get(eventOther.aggregateId)).also { - assertNotNull(it.value) shouldBeEqual "valOther" - } - - val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId) -// eventStore.publish(event1) - repo.applyAndSave(event1) - assertNotNull(repo.get(event1.aggregateId)).also { - assertNotNull(it.value) shouldBeEqual "val1" - } - - val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId) -// eventStore.publish(event2) - repo.applyAndSave(event2) - assertNotNull(repo.get(event2.aggregateId)).also { - assertNotNull(it.value) shouldBeEqual "val1val2" - } - } - } - - context("getList method must be return all inserted events") { - withData(list) { (eventStore, repo) -> - val aggregateId = IdTest() - val otherAggregateId = IdTest() - - val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = otherAggregateId) - eventStore.publish(eventOther) - repo.applyAndSave(eventOther) - assertNotNull(repo.get(eventOther.aggregateId)).also { - assertNotNull(it.value) shouldBeEqual "valOther" - } - - val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId) - eventStore.publish(event1) - repo.applyAndSave(event1) - - val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId) - eventStore.publish(event2) - repo.applyAndSave(event2) - - repo.getList().apply { - any { it.aggregateId == otherAggregateId } shouldBeEqual true - any { it.aggregateId == aggregateId } shouldBeEqual true - any { it.value == "val1val2" } shouldBeEqual true - any { it.value == "valOther" } shouldBeEqual true - any { it.lastEventVersion == 2 } shouldBeEqual true - any { it.lastEventVersion == 1 } shouldBeEqual true - } - } - } - - context("ProjectionRepository should be thread safe") { - continually(1.seconds) { - withData(list) { (eventStore, repo) -> - val aggregateId = IdTest() - val versionBuilder = VersionBuilderLocal() - val lock = ReentrantLock() - (0..9) - .map { - GlobalScope.launch { - repeat(10) { - lock.withLock { - runBlocking { - EventXTest( - num = 1, - version = versionBuilder.buildNextVersion(aggregateId), - aggregateId = aggregateId, - ).also { repo.applyAndSave(it) } - } - } - } - } - }.joinAll() - assertNotNull(repo.get(aggregateId)).lastEventVersion shouldBeEqual 100 - assertNotNull(repo.get(aggregateId)).num shouldBeEqual 100 - } - } - } - }) - -@JvmInline -@Serializable -private value class IdTest( - @Serializable(with = UUIDSerializer::class) - override val id: UUID = UUID.randomUUID(), -) : AggregateId - -@Serializable -private data class ProjectionTest( - override val aggregateId: IdTest, - override val lastEventVersion: Int = 0, - var value: String? = null, - var num: Int = 0, -) : Projection - -private sealed interface TestEvents : Event - -private data class Event1Test( - override val eventId: UUID = UUID.randomUUID(), - override val aggregateId: IdTest, - override val createdAt: Instant = Clock.System.now(), - override val version: Int, - val value1: String, -) : TestEvents - -private data class Event2Test( - override val eventId: UUID = UUID.randomUUID(), - override val aggregateId: IdTest, - override val createdAt: Instant = Clock.System.now(), - override val version: Int, - val value2: String, -) : TestEvents - -private data class EventXTest( - override val eventId: UUID = UUID.randomUUID(), - override val aggregateId: IdTest, - override val createdAt: Instant = Clock.System.now(), - override val version: Int, - val num: Int, -) : TestEvents - -private fun getRepoInMemoryTest(): ProjectionRepository = - ProjectionRepositoryInMemory( - initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) }, - applyToProjection = apply, - ) - -private fun getRepoInRedisTest(): ProjectionRepository { - val jedis = JedisPooled("redis://localhost:6379") - jedis.cleanProjections() - return ProjectionRepositoryInRedis( - jedis = jedis, - initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) }, - projectionClass = ProjectionTest::class, - projectionToJson = { Json.encodeToString(it) }, - jsonToProjection = { Json.decodeFromString(it) }, - applyToProjection = apply, - ) -} - -private val apply: ProjectionTest.(TestEvents) -> ProjectionTest = { event -> - this.let { projection -> - when (event) { - is Event1Test -> { - projection.copy(value = (projection.value.orEmpty()) + event.value1) - } - - is Event2Test -> { - projection.copy(value = (projection.value.orEmpty()) + event.value2) - } - - is EventXTest -> { - projection.copy(num = projection.num + event.num) - } - }.copy( - lastEventVersion = event.version, - ) - } -} diff --git a/src/test/kotlin/eventDemo/libs/event/EventStreamTest.kt b/src/test/kotlin/eventDemo/libs/eventSource/EventStreamTest.kt similarity index 72% rename from src/test/kotlin/eventDemo/libs/event/EventStreamTest.kt rename to src/test/kotlin/eventDemo/libs/eventSource/EventStreamTest.kt index 68db58e..407bea5 100644 --- a/src/test/kotlin/eventDemo/libs/event/EventStreamTest.kt +++ b/src/test/kotlin/eventDemo/libs/eventSource/EventStreamTest.kt @@ -1,8 +1,11 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource import eventDemo.Tag -import eventDemo.testKoinApplicationWithConfig -import io.kotest.common.KotestInternal +import eventDemo.libs.eventSource.eventStore.EventStream +import eventDemo.libs.eventSource.eventStore.EventStreamInMemory +import eventDemo.libs.eventSource.eventStore.EventStreamInPostgresql +import eventDemo.libs.eventSource.eventStore.EventStreamPublishException +import eventDemo.testHelpers.testKoinApplicationWithConfig import io.kotest.core.spec.style.FunSpec import io.kotest.datatest.withData import io.kotest.matchers.collections.shouldHaveSize @@ -17,28 +20,30 @@ import org.junit.jupiter.api.assertThrows import org.koin.core.Koin import kotlin.test.assertNotNull -@OptIn(KotestInternal::class, DelicateCoroutinesApi::class) +@OptIn(DelicateCoroutinesApi::class) class EventStreamTest : FunSpec({ tags(Tag.Postgresql) fun EventStream.with3Events(block: EventStream.(id: IdTest) -> Unit) = also { - publish(EventXTest(aggregateId = aggregateId, version = 1, num = 1)) - publish(EventXTest(aggregateId = aggregateId, version = 2, num = 2)) - publish(EventXTest(aggregateId = aggregateId, version = 3, num = 3)) + append(EventXTest(aggregateId = aggregateId, version = 1, num = 1)) + append(EventXTest(aggregateId = aggregateId, version = 2, num = 2)) + append(EventXTest(aggregateId = aggregateId, version = 3, num = 3)) block(aggregateId) } - fun Koin.eventStreams(): List> = - listOf( - EventStreamInMemory(IdTest()), - EventStreamInPostgresql( - IdTest(), - dataSource = get(), - objectToString = { Json.encodeToString(it) }, - stringToObject = { Json.decodeFromString(it) }, - ), + fun Koin.eventStreams(): Map> = + mapOf( + EventStreamInMemory::class.simpleName.toString() to EventStreamInMemory(IdTest()), + EventStreamInPostgresql::class.simpleName.toString() to + EventStreamInPostgresql( + IdTest(), + dataSource = get(), + objectToString = { Json.encodeToString(it) }, + stringToObject = { Json.decodeFromString(it) }, + "game.game_event_stream", + ), ) context("readVersionBetween should only return the event of aggregate") { @@ -98,7 +103,7 @@ class EventStreamTest : context("publish should be throw error when publish another aggregate event") { testKoinApplicationWithConfig { withData(eventStreams()) { - assertThrows { it.publish(EventXTest(aggregateId = IdTest(), version = 1, num = 1)) } + assertThrows { it.append(EventXTest(aggregateId = IdTest(), version = 1, num = 1)) } } } } @@ -110,7 +115,7 @@ class EventStreamTest : .map { i1 -> GlobalScope.launch { (1..10).forEach { i2 -> - stream.publish( + stream.append( EventXTest( aggregateId = stream.aggregateId, version = (i1 * 10) + i2, diff --git a/src/test/kotlin/eventDemo/libs/event/TestEvents.kt b/src/test/kotlin/eventDemo/libs/eventSource/TestEvents.kt similarity index 66% rename from src/test/kotlin/eventDemo/libs/event/TestEvents.kt rename to src/test/kotlin/eventDemo/libs/eventSource/TestEvents.kt index 19c4e8f..bee5701 100644 --- a/src/test/kotlin/eventDemo/libs/event/TestEvents.kt +++ b/src/test/kotlin/eventDemo/libs/eventSource/TestEvents.kt @@ -1,6 +1,7 @@ -package eventDemo.libs.event +package eventDemo.libs.eventSource -import eventDemo.configuration.serializer.UUIDSerializer +import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer +import eventDemo.libs.serializer.UUIDSerializer import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -18,8 +19,8 @@ sealed interface TestEvents : Event @Serializable data class EventXTest( - @Serializable(with = UUIDSerializer::class) - override val eventId: UUID = UUID.randomUUID(), + @Serializable(with = EventIdSerializer::class) + override val eventId: EventId = EventId(), override val aggregateId: IdTest, override val createdAt: Instant = Clock.System.now(), override val version: Int, diff --git a/src/test/kotlin/eventDemo/libs/FrameChannelConverterTest.kt b/src/test/kotlin/eventDemo/libs/helpers/FrameChannelConverterTest.kt similarity index 76% rename from src/test/kotlin/eventDemo/libs/FrameChannelConverterTest.kt rename to src/test/kotlin/eventDemo/libs/helpers/FrameChannelConverterTest.kt index 9082cbc..56d1635 100644 --- a/src/test/kotlin/eventDemo/libs/FrameChannelConverterTest.kt +++ b/src/test/kotlin/eventDemo/libs/helpers/FrameChannelConverterTest.kt @@ -1,6 +1,6 @@ -package eventDemo.libs +package eventDemo.libs.helpers -import eventDemo.libs.command.Command +import eventDemo.libs.command.CommandForTest import eventDemo.libs.command.CommandId import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.equals.shouldBeEqual @@ -8,15 +8,9 @@ import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable import java.util.UUID import kotlin.test.assertIs -@Serializable -data class CommandTest( - override val id: CommandId, -) : Command - class FrameChannelConverterTest : FunSpec({ @@ -28,7 +22,7 @@ class FrameChannelConverterTest : val channel = Channel() launch { - val commandChannel = toObjectChannel(channel) + val commandChannel = toObjectChannel(channel) commandChannel.receive().id shouldBeEqual id channel.close() } @@ -39,13 +33,13 @@ class FrameChannelConverterTest : test("fromFrameChannel") { val uuid = "d737c631-76af-406e-bc29-f3e5b97226a5" val id = CommandId(UUID.fromString(uuid)) - val command = CommandTest(id) + val command = CommandForTest(id) val jsonCommand = """{"id":"$uuid"}""" val channel = Channel() launch { - val commandChannel = fromFrameChannel(channel) + val commandChannel = fromFrameChannel(channel) commandChannel.send(command) commandChannel.close() } diff --git a/src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsHelpers.kt b/src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsHelpers.kt new file mode 100644 index 0000000..de91377 --- /dev/null +++ b/src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsHelpers.kt @@ -0,0 +1,63 @@ +package eventDemo.testHelpers + +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import org.koin.core.Koin +import java.util.UUID + +object CreateGameWithCommandsHelpers { + class Data( + private val repo: GameRepository, + val gameId: GameId, + ) { + fun getPlayer(user: User): Player = + repo.get(gameId)!!.players.get(user.id) + } + + context(koin: Koin) + fun createGameWithCommands( + gameName: String = "testGame${UUID.randomUUID()}", + block: context(CreateGameWithCommandsHelpers, GameCommandHandlerDispatcher) Data.() -> T, + ): T { + val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray())) + val repo = koin.get() + repo.create(gameId) + + return koin.get().run { + with(CreateGameWithCommandsHelpers) { + Data(repo, gameId).block() + } + } + } + + context(dispatcher: GameCommandHandlerDispatcher, data: Data) + fun User.joinTheGame(): JoinTheGameCommand = + JoinTheGameCommand( + id, + JoinTheGameCommand.Payload(data.gameId), + ).also { dispatcher.dispatch(it) } + + context(dispatcher: GameCommandHandlerDispatcher, data: Data) + fun Player.readyToPlay(): ReadyToPlayCommand = + ReadyToPlayCommand( + userId, + ReadyToPlayCommand.Payload(data.gameId, id), + ).also { dispatcher.dispatch(it) } + + context(dispatcher: GameCommandHandlerDispatcher, data: Data) + fun Player.playCard( + card: Card, + chosenColor: Card.Color? = null, + ): PlayCardCommand = + PlayCardCommand( + userId, + PlayCardCommand.Payload(data.gameId, id, card, chosenColor), + ).also { dispatcher.dispatch(it) } +} diff --git a/src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsInChannelsHelpers.kt b/src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsInChannelsHelpers.kt new file mode 100644 index 0000000..a2be1f3 --- /dev/null +++ b/src/test/kotlin/eventDemo/testHelpers/CreateGameWithCommandsInChannelsHelpers.kt @@ -0,0 +1,78 @@ +package eventDemo.testHelpers + +import eventDemo.contexts.auth.domain.User +import eventDemo.contexts.game.application.command.models.GameCommand +import eventDemo.contexts.game.application.command.models.JoinTheGameCommand +import eventDemo.contexts.game.application.command.models.PlayCardCommand +import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand +import eventDemo.contexts.game.application.eventStores.GameRepository +import eventDemo.contexts.game.domain.game.Card +import eventDemo.contexts.game.domain.game.GameId +import eventDemo.contexts.game.domain.game.Player +import eventDemo.contexts.game.domain.game.gameState.Game +import kotlinx.coroutines.channels.Channel +import org.koin.core.Koin + +object CreateGameWithCommandsInChannelsHelpers { + class Data( + private val repo: GameRepository, + val gameId: GameId, + val currentUser: User, + ) { + fun getPlayer(user: User): Player = + game.players.get(user.id) + + val currentPlayer: Player get() = getPlayer(currentUser) + + val game: Game + get() = repo.get(gameId)!! + } + + context(koin: Koin) + suspend fun createGameWithCommandsInChannels( + channelCommand: Channel, + gameId: GameId, + user: User, + block: + suspend context( + CreateGameWithCommandsInChannelsHelpers, + Channel, + User, + ) Data.() -> T, + ): T { + val repo = koin.get() + repo.getOrCreate(gameId) + + return with(channelCommand) { + with(user) { + with(CreateGameWithCommandsInChannelsHelpers) { + Data(repo, gameId, user).block() + } + } + } + } + + context(channelCommand: Channel, data: Data) + suspend fun joinTheGame(): JoinTheGameCommand = + JoinTheGameCommand( + data.currentUser.id, + JoinTheGameCommand.Payload(data.gameId), + ).also { channelCommand.send(it) } + + context(channelCommand: Channel, data: Data) + suspend fun readyToPlay(): ReadyToPlayCommand = + ReadyToPlayCommand( + data.currentUser.id, + ReadyToPlayCommand.Payload(data.gameId, data.currentPlayer.id), + ).also { channelCommand.send(it) } + + context(channelCommand: Channel, data: Data) + suspend fun playCard( + card: Card, + chosenColor: Card.Color? = null, + ): PlayCardCommand = + PlayCardCommand( + data.currentUser.id, + PlayCardCommand.Payload(data.gameId, data.currentPlayer.id, card, chosenColor), + ).also { channelCommand.send(it) } +} diff --git a/src/test/kotlin/eventDemo/LogHelper.kt b/src/test/kotlin/eventDemo/testHelpers/LogHelper.kt similarity index 95% rename from src/test/kotlin/eventDemo/LogHelper.kt rename to src/test/kotlin/eventDemo/testHelpers/LogHelper.kt index fd9cd51..d7649f9 100644 --- a/src/test/kotlin/eventDemo/LogHelper.kt +++ b/src/test/kotlin/eventDemo/testHelpers/LogHelper.kt @@ -1,4 +1,4 @@ -package eventDemo +package eventDemo.testHelpers import ch.qos.logback.classic.Level import ch.qos.logback.classic.Logger diff --git a/src/test/kotlin/eventDemo/testHelpers/NewUserHelper.kt b/src/test/kotlin/eventDemo/testHelpers/NewUserHelper.kt new file mode 100644 index 0000000..450e915 --- /dev/null +++ b/src/test/kotlin/eventDemo/testHelpers/NewUserHelper.kt @@ -0,0 +1,7 @@ +package eventDemo.testHelpers + +import eventDemo.contexts.auth.domain.User + +fun createNewUser(name: String): User = + User + .createNewUser(name, "changeit") diff --git a/src/test/kotlin/eventDemo/testHelpers/TestAllCardCountHelpers.kt b/src/test/kotlin/eventDemo/testHelpers/TestAllCardCountHelpers.kt new file mode 100644 index 0000000..f204a87 --- /dev/null +++ b/src/test/kotlin/eventDemo/testHelpers/TestAllCardCountHelpers.kt @@ -0,0 +1,6 @@ +package eventDemo.testHelpers + +import eventDemo.contexts.game.domain.game.gameState.GameStarted + +fun GameStarted.allCardCount(): Int = + drawPile.remainingCards + discardPile.size + players.map { it.hand }.sumOf { it.size } diff --git a/src/test/kotlin/eventDemo/Helpers.kt b/src/test/kotlin/eventDemo/testHelpers/TestApplicationHelpers.kt similarity index 53% rename from src/test/kotlin/eventDemo/Helpers.kt rename to src/test/kotlin/eventDemo/testHelpers/TestApplicationHelpers.kt index e9ba836..74a4af1 100644 --- a/src/test/kotlin/eventDemo/Helpers.kt +++ b/src/test/kotlin/eventDemo/testHelpers/TestApplicationHelpers.kt @@ -1,11 +1,10 @@ -package eventDemo +package eventDemo.testHelpers import com.zaxxer.hikari.HikariDataSource -import eventDemo.domain.entity.Card -import eventDemo.domain.entity.Deck -import eventDemo.configuration.domain.configureGameListener -import eventDemo.configuration.injection.appKoinModule -import eventDemo.configuration.ktor.configuration +import eventDemo.configuration.appKoinModule +import eventDemo.configuration.configuration +import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener +import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.server.config.ApplicationConfig import io.ktor.server.testing.ApplicationTestBuilder @@ -15,22 +14,15 @@ import org.koin.core.Koin import org.koin.core.module.KoinApplicationDslMarker import org.koin.dsl.koinApplication import org.koin.ktor.ext.getKoin -import redis.clients.jedis.UnifiedJedis -import javax.sql.DataSource - -fun Deck.allCardCount(): Int = - stack.size + discard.size + playersHands.values.flatten().size - -fun Deck.allCards(): Set = - stack + discard + playersHands.values.flatten() @KoinApplicationDslMarker suspend fun testKoinApplicationWithConfig(block: suspend Koin.() -> T): T = - koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration())) } + koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration)) } .koin .run { cleanDataTest() - configureGameListener() + configureProjectionListener() + configureReactionListener() block() .apply { get().close() } } @@ -51,34 +43,12 @@ fun testApplicationWithConfig( logger.info { "Config App" } val koin = getKoin() koin.cleanDataTest() - runCatching { - logger.info { "Starting A" } - configBuilder(koin) - logger.info { "A finish" } - } + logger.info { "Starting A" } + configBuilder(koin) + logger.info { "A finish" } } logger.info { "Starting B" } this@testApplication.block() logger.info { "B finish" } } } - -fun DataSource.cleanEventSource() { - this.connection.use { - it - .prepareStatement( - """ - truncate event_stream; - """.trimIndent(), - ).execute() - } -} - -fun UnifiedJedis.cleanProjections() { - flushAll() -} - -fun Koin.cleanDataTest() { - get().cleanEventSource() - get().cleanProjections() -} diff --git a/src/test/kotlin/eventDemo/testHelpers/TestDataHelper.kt b/src/test/kotlin/eventDemo/testHelpers/TestDataHelper.kt new file mode 100644 index 0000000..8098560 --- /dev/null +++ b/src/test/kotlin/eventDemo/testHelpers/TestDataHelper.kt @@ -0,0 +1,27 @@ +package eventDemo.testHelpers + +import org.koin.core.Koin +import redis.clients.jedis.UnifiedJedis +import javax.sql.DataSource + +fun DataSource.cleanEventSource() { + this.connection.use { + it + .prepareStatement( + """ + truncate game.game_event_stream; + truncate auth.user_event_stream; + truncate auth.user; + """.trimIndent(), + ).execute() + } +} + +fun UnifiedJedis.cleanProjections() { + flushAll() +} + +fun Koin.cleanDataTest() { + get().cleanEventSource() + get().cleanProjections() +} diff --git a/src/test/kotlin/eventDemo/testHelpers/TestHelper.kt b/src/test/kotlin/eventDemo/testHelpers/TestHelper.kt new file mode 100644 index 0000000..e0b5347 --- /dev/null +++ b/src/test/kotlin/eventDemo/testHelpers/TestHelper.kt @@ -0,0 +1,33 @@ +package eventDemo.testHelpers + +import io.kotest.assertions.nondeterministic.eventually +import io.mockk.spyk +import io.mockk.verify +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +inline fun arrange(block: () -> R): R = + run(block) + +inline fun T.and(block: (T) -> R): R = + this.let(block) + +inline fun T.act(block: (T) -> R): R = + this.let(block) + +inline fun T.assert(block: (T) -> R): R = + this.let(block) + +suspend fun spyPing( + duration: Duration = 0.5.seconds, + exactly: Int = -1, + block: (ping: () -> Unit) -> Unit, +) { + val spy = spyk<() -> Unit>() + + block(spy) + + eventually(duration) { + verify(exactly = exactly) { spy() } + } +}