diff --git a/README.md b/README.md index a656b2c6de75c4d6891b023e298b88a3a7f58ea0..afe3e094387c1875fd39b29ac5e940e18f271bec 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Place yourself at root and start docker compose up -d ``` +// or with cmd + ## Available endpoints ## Technical choices @@ -45,6 +47,16 @@ docker compose up -d - [x] tous les endpoints demandés - [ ] gestion de la sécurité +Dans l'ordre : + +- Amélioration du fonctionnel + - Routes factorisées + - DTO + - Appels à la BD +- Amélioration des tests +- Amélioration de la sécurité +- Déploiement en prod serein + ## Going further - Database migration tool diff --git a/build.gradle.kts b/build.gradle.kts index 29241d06ad503adf9291ccea55da2283200770e9..4bd559ef78b96f41a6eb22d4a3d101a3b2cb6d43 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,9 @@ val dynamo_version: String by project val dynamo_kt_version: String by project val mockk_version: String by project val kotlin_reactive_version: String by project +val koin_test_version: String by project +val assertj_core_version: String by project +val localstack_version: String by project plugins { @@ -53,4 +56,7 @@ dependencies { testImplementation("io.ktor:ktor-server-test-host") testImplementation("io.mockk:mockk:$mockk_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + testImplementation("io.insert-koin:koin-test:$koin_test_version") + testImplementation("org.assertj:assertj-core:$assertj_core_version") + testImplementation("org.testcontainers:localstack:$localstack_version") } diff --git a/gradle.properties b/gradle.properties index 89aec6c45909d73e9f16cddef3437b8807ab3e04..8e7826fecd85f8752afc6a54877dd9756395a236 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,6 @@ ktor_version=2.3.13 logback_version=1.4.14 mockk_version=1.10.0 kotlin_reactive_version=1.9.0 +koin_test_version=3.4.0 +assertj_core_version=3.24.2 +localstack_version=1.20.4 \ No newline at end of file diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt index 6e183effb698210cc51d727ab4bb6451811fe3e6..cca5f445e2e2230e918f5278c039653890e1c809 100644 --- a/src/main/kotlin/Application.kt +++ b/src/main/kotlin/Application.kt @@ -4,6 +4,7 @@ import betclic.test.configuration.configureHTTP import betclic.test.configuration.configureKoin import betclic.test.configuration.configureRouting import betclic.test.configuration.configureSerialization +import betclic.test.configuration.migrateTables import io.ktor.server.application.* import kotlinx.coroutines.runBlocking @@ -11,9 +12,18 @@ fun main(args: Array<String>) { io.ktor.server.netty.EngineMain.main(args) } -fun Application.module() = runBlocking { +fun Application.module() { + configuration() + initialize() +} + +fun Application.configuration() { configureHTTP() configureKoin() configureSerialization() configureRouting() } + +fun Application.initialize() = runBlocking { + migrateTables() +} \ No newline at end of file diff --git a/src/main/kotlin/configuration/DynamoDbConfiguration.kt b/src/main/kotlin/configuration/DynamoDbConfiguration.kt index 1ef94811b659922806b3a066bd706cd99ecc6b47..59107e39056b385b36b9d69fd56982b48834e33c 100644 --- a/src/main/kotlin/configuration/DynamoDbConfiguration.kt +++ b/src/main/kotlin/configuration/DynamoDbConfiguration.kt @@ -1,52 +1,21 @@ package betclic.test.configuration -import betclic.test.player.PlayerEntity -import dev.andrewohara.dynamokt.DataClassTableSchema -import io.ktor.server.application.* -import kotlinx.coroutines.future.await import org.slf4j.LoggerFactory import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient import java.net.URI -import kotlin.reflect.KClass -class DynamoDbConfiguration() { +class DynamoDbConfiguration { private val logger = LoggerFactory.getLogger(DynamoDbConfiguration::class.java) - fun dataSource(): DynamoDbEnhancedAsyncClient { - logger.info("Using dynamo-db config") - return DynamoDbEnhancedAsyncClient.builder() - .dynamoDbClient(createDynamoDbClient()) - .build() - } -} - -private fun createDynamoDbClient(): DynamoDbAsyncClient { - return DynamoDbAsyncClient.builder().endpointOverride(URI("http://localhost:8000")).build() -} -suspend fun createNecessaryTables( - dynamoDbClient: DynamoDbAsyncClient, - dynamoDbEnhancedClient: DynamoDbEnhancedAsyncClient -) { - val existingTables = dynamoDbClient.listTables().await().tableNames().toList() - - listOf(PlayerEntity::class).forEach { - createTableIfNotExists(existingTables, it, dynamoDbEnhancedClient) + fun createDynamoDbClient(): DynamoDbAsyncClient { + return DynamoDbAsyncClient.builder().endpointOverride(URI("http://localhost:8000")).build() } -} - -private val logger = LoggerFactory.getLogger(Application::class.java) -private suspend fun <T : Any> createTableIfNotExists( - existingTables: List<String>, - item: KClass<T>, - dynamoDbEnhancedClient: DynamoDbEnhancedAsyncClient -) { - val tableSchema = DataClassTableSchema(item) - if (existingTables.contains(item.simpleName)) { - logger.info("Table '${item.simpleName}' already exists.") - } else { - dynamoDbEnhancedClient.table(item.simpleName, tableSchema).createTable().await() - logger.info("Table '${item.simpleName}' created successfully.") + fun createDataSource(dynamoDbAsyncClient: DynamoDbAsyncClient): DynamoDbEnhancedAsyncClient { + logger.info("Using dynamo-db config") + return DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(dynamoDbAsyncClient) + .build() } } \ No newline at end of file diff --git a/src/main/kotlin/configuration/DynamoDbMigration.kt b/src/main/kotlin/configuration/DynamoDbMigration.kt new file mode 100644 index 0000000000000000000000000000000000000000..b5e4535eeb4b505fd0dd50f12b18f0c39fde4e78 --- /dev/null +++ b/src/main/kotlin/configuration/DynamoDbMigration.kt @@ -0,0 +1,53 @@ +package betclic.test.configuration + +import betclic.test.player.PlayerEntity +import dev.andrewohara.dynamokt.DataClassTableSchema +import io.ktor.server.application.* +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import org.koin.ktor.ext.inject +import org.slf4j.LoggerFactory +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import kotlin.reflect.KClass + +fun Application.migrateTables() { + val dynamoDbMigrationService: DynamoDbMigrationService by inject() + val dynamoDbClient: DynamoDbAsyncClient by inject() + val dynamoDbEnhancedClient: DynamoDbEnhancedAsyncClient by inject() + + runBlocking { + dynamoDbMigrationService.createNecessaryTables(dynamoDbClient, dynamoDbEnhancedClient) + } +} + +class DynamoDbMigrationService { + private val logger = LoggerFactory.getLogger(Application::class.java) + + suspend fun createNecessaryTables( + dynamoDbClient: DynamoDbAsyncClient, + dynamoDbEnhancedClient: DynamoDbEnhancedAsyncClient + ) { + val existingTables = dynamoDbClient.listTables().await().tableNames().toList() + + listOf(PlayerEntity::class).forEach { + createTableIfNotExists(existingTables, it, dynamoDbEnhancedClient) + } + } + + + private suspend fun <T : Any> createTableIfNotExists( + existingTables: List<String>, + item: KClass<T>, + dynamoDbEnhancedClient: DynamoDbEnhancedAsyncClient + ) { + val tableSchema = DataClassTableSchema(item) + if (existingTables.contains(item.simpleName)) { + logger.info("Table '${item.simpleName}' already exists.") + } else { + dynamoDbEnhancedClient.table(item.simpleName, tableSchema).createTable().await() + logger.info("Table '${item.simpleName}' created successfully.") + } + } +} + diff --git a/src/main/kotlin/configuration/InjectionConfiguration.kt b/src/main/kotlin/configuration/InjectionConfiguration.kt index 4b623254b5cc0c60c770546bce811e5ffcb3a83f..e1e4d5d3677ac90510bd6d76d6b2e581ecf4b9bf 100644 --- a/src/main/kotlin/configuration/InjectionConfiguration.kt +++ b/src/main/kotlin/configuration/InjectionConfiguration.kt @@ -11,12 +11,17 @@ import org.koin.logger.slf4jLogger fun Application.configureKoin() { install(Koin) { slf4jLogger() - modules(playerModule) + modules(databaseModule, playerModule) } } +val databaseModule = module { + single { DynamoDbConfiguration().createDynamoDbClient() } + single { DynamoDbConfiguration().createDataSource(get()) } + single { DynamoDbMigrationService() } +} + val playerModule = module { - single { DynamoDbConfiguration().dataSource() } single<PlayerRepository> { PlayerRepository(get()) } single<PlayerService> { PlayerServiceImpl(get()) } } diff --git a/src/test/kotlin/BaseIntegrationTest.kt b/src/test/kotlin/BaseIntegrationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..693ffe014a67441cf44c2e4bf1da88fe628d26a1 --- /dev/null +++ b/src/test/kotlin/BaseIntegrationTest.kt @@ -0,0 +1,29 @@ +import betclic.test.configuration.testDatabaseModule +import betclic.test.initialize +import betclic.test.module +import io.ktor.server.testing.* +import org.koin.core.context.loadKoinModules +import org.koin.test.KoinTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +abstract class BaseIntegrationTest : KoinTest { + + protected val application = TestApplication { + application { + module() + // override Database module with testContainers + loadKoinModules(testDatabaseModule) + initialize() + } + } + + @BeforeTest + fun setup() { + } + + @AfterTest + fun tearDown() { + TestDynamoDbConfiguration.localStack.stop() + } +} diff --git a/src/test/kotlin/TestDynamoDbConfiguration.kt b/src/test/kotlin/TestDynamoDbConfiguration.kt new file mode 100644 index 0000000000000000000000000000000000000000..c524e9b161931cd5039397f71f93c6423901fc66 --- /dev/null +++ b/src/test/kotlin/TestDynamoDbConfiguration.kt @@ -0,0 +1,39 @@ +import org.slf4j.LoggerFactory +import org.testcontainers.containers.localstack.LocalStackContainer +import org.testcontainers.utility.DockerImageName +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient + + +object TestDynamoDbConfiguration { + private val logger = LoggerFactory.getLogger(TestDynamoDbConfiguration::class.java) + + val localStack: LocalStackContainer = LocalStackContainer(DockerImageName.parse("localstack/localstack:latest")) + .withServices(LocalStackContainer.Service.DYNAMODB) + + init { + logger.info("Starting LocalStack DynamoDB container for tests...") + localStack.start() + } + + fun createDynamoDbClient(): DynamoDbAsyncClient { + val endpoint = localStack.getEndpointOverride(LocalStackContainer.Service.DYNAMODB) + val credentials = AwsBasicCredentials.create(localStack.accessKey, localStack.secretKey) + + return DynamoDbAsyncClient.builder() + .endpointOverride(endpoint) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .region(Region.US_EAST_1) + .build() + } + + fun createDataSource(dynamoDbAsyncClient: DynamoDbAsyncClient): DynamoDbEnhancedAsyncClient { + logger.info("Using test dynamo-db config") + return DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(dynamoDbAsyncClient) + .build() + } +} diff --git a/src/test/kotlin/TestInjectionConfiguration.kt b/src/test/kotlin/TestInjectionConfiguration.kt new file mode 100644 index 0000000000000000000000000000000000000000..94015583afb05a5cf74fae6a84e8d3569a7269bc --- /dev/null +++ b/src/test/kotlin/TestInjectionConfiguration.kt @@ -0,0 +1,10 @@ +package betclic.test.configuration + +import TestDynamoDbConfiguration +import org.koin.dsl.module + +val testDatabaseModule = module { + single { TestDynamoDbConfiguration.createDynamoDbClient() } + single { TestDynamoDbConfiguration.createDataSource(get()) } + single { DynamoDbMigrationService() } +} \ No newline at end of file diff --git a/src/test/kotlin/player/PlayerIntegrationTest.kt b/src/test/kotlin/player/PlayerIntegrationTest.kt index 2aeb2638e07033c08b3837f34a95f4180a9c8960..1fa6f2ab2231ac93d41dfb1be97fb7f7e0e253a8 100644 --- a/src/test/kotlin/player/PlayerIntegrationTest.kt +++ b/src/test/kotlin/player/PlayerIntegrationTest.kt @@ -1,17 +1,33 @@ package player +import BaseIntegrationTest +import betclic.test.player.PlayerRepository import io.ktor.client.request.* import io.ktor.http.* import io.ktor.server.testing.* +import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.koin.test.inject import kotlin.test.assertEquals -class PlayerIntegrationTest { + +private const val PLAYER_NAME = "Clement" + +class PlayerIntegrationTest : BaseIntegrationTest() { + + private val playerRepository: PlayerRepository by inject() + @Test - fun `When calling post player, should return created`() = testApplication { - assertEquals(HttpStatusCode.Created, client.post("/player") { + fun `When calling player creation, a player should be saved in DB`() = testApplication { + val response = application.client.post("/players") { header(HttpHeaders.ContentType, ContentType.Text.Plain) - setBody("Test d'intégration") - }.status) + setBody(PLAYER_NAME) + } + + assertEquals(HttpStatusCode.Created, response.status) + val players = playerRepository.findAll() + assertThat(players).hasSize(1) + assertThat(players.first()).extracting("pseudo", "pointsNumber") + .containsExactly(PLAYER_NAME, 0) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/player/PlayerServiceTest.kt b/src/test/kotlin/player/PlayerServiceTest.kt index 8d630f0fffb42a72a5f2fdeb529df5f9b8cb58b5..9671150b8ac3ad4f52c0ccf7c5454fd355d3981e 100644 --- a/src/test/kotlin/player/PlayerServiceTest.kt +++ b/src/test/kotlin/player/PlayerServiceTest.kt @@ -18,7 +18,7 @@ class PlayerServiceTest { private val player1 = Player(pseudo = john) @Test - fun `should create a new player`() { + fun `should create a new player in database`() { coEvery { playerRepository.createNewPlayer(player1) } just runs runBlocking { playerService.createNewPlayer(john) } coVerify(exactly = 1) { playerRepository.createNewPlayer(player1) }