diff --git a/gradle.properties b/gradle.properties index 273289e..ee26950 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official version=0.2.0-SNAPSHOT -catalog=0.8.0 +catalog=0.9.0-SNAPSHOT container.port.host=9050 diff --git a/http/account.http b/http/account.http index e8dced4..955bac8 100644 --- a/http/account.http +++ b/http/account.http @@ -1,7 +1,7 @@ ### get user by id GET {{hostname}}/account-00000000-0000-7000-0000-000000000001 -### Get admin information +### add user POST {{hostname}}/account Content-Type: application/json @@ -18,8 +18,33 @@ Content-Type: application/json ### Get accounts GET {{hostname}}/accounts -### Get accounts +### Get accounts by page GET {{hostname}}/accounts/page-1 -### Get accounts +### Get accounts by page and size GET {{hostname}}/accounts/page-1/show-5 + +### update user all information +PUT {{hostname}}/account-00000000-0000-7000-0000-000000000002 +Content-Type: application/json + +{ + "username": "user", + "password": "pass", + "enabled": true, + "roles": [ + "ROLE_TEST" + ] +} + +### update user information +PUT {{hostname}}/account-00000000-0000-7000-0000-000000000002 +Content-Type: application/json + +{ + "username": "user", + "enabled": true, + "roles": [ + "ROLE_TEST" + ] +} diff --git a/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt b/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt index 587c6a6..387ff59 100644 --- a/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt +++ b/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt @@ -1,16 +1,21 @@ package ltd.hlaeja.controller import java.util.UUID +import ltd.hlaeja.entity.AccountEntity import ltd.hlaeja.library.accountRegistry.Account import ltd.hlaeja.service.AccountService import ltd.hlaeja.util.toAccountEntity import ltd.hlaeja.util.toAccountResponse +import ltd.hlaeja.util.updateAccountEntity +import org.springframework.http.HttpStatus.ACCEPTED import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -30,6 +35,18 @@ class AccountController( ): Mono = accountService.getUserById(uuid) .map { it.toAccountResponse() } + @PutMapping("/account-{uuid}") + fun updateAccount( + @PathVariable uuid: UUID, + @RequestBody request: Account.Request, + ): Mono = accountService.getUserById(uuid) + .map { user -> + user.updateAccountEntity(request, passwordEncoder) + .also { if (hasChange(user, it)) throw ResponseStatusException(ACCEPTED) } + } + .flatMap { accountService.updateAccount(it) } + .map { it.toAccountResponse() } + @PostMapping("/account") fun addAccount( @RequestBody request: Account.Request, @@ -55,4 +72,12 @@ class AccountController( size: Int, ): Flux = accountService.getAccounts(page, size) .map { it.toAccountResponse() } + + private fun hasChange( + user: AccountEntity, + update: AccountEntity, + ): Boolean = user.password == update.password && + user.username == update.username && + user.enabled == update.enabled && + user.roles == update.roles } diff --git a/src/main/kotlin/ltd/hlaeja/service/AccountService.kt b/src/main/kotlin/ltd/hlaeja/service/AccountService.kt index e67266c..a18cb53 100644 --- a/src/main/kotlin/ltd/hlaeja/service/AccountService.kt +++ b/src/main/kotlin/ltd/hlaeja/service/AccountService.kt @@ -35,13 +35,7 @@ class AccountService( accountEntity: AccountEntity, ): Mono = accountRepository.save(accountEntity) .doOnNext { log.debug { "Added new type: $it.id" } } - .onErrorResume { - log.debug { it.localizedMessage } - when { - it is DuplicateKeyException -> Mono.error(ResponseStatusException(HttpStatus.CONFLICT)) - else -> Mono.error(ResponseStatusException(HttpStatus.BAD_REQUEST)) - } - } + .onErrorResume(::onSaveError) fun getAccounts(page: Int, size: Int): Flux = try { accountRepository.findAll() @@ -49,6 +43,20 @@ class AccountService( .take(size.toLong()) .doOnNext { log.debug { "Retrieved accounts $page with size $size" } } } catch (e: IllegalArgumentException) { - Flux.error(ResponseStatusException(HttpStatus.BAD_REQUEST)) + Flux.error(ResponseStatusException(HttpStatus.BAD_REQUEST, null, e)) + } + + fun updateAccount( + accountEntity: AccountEntity, + ): Mono = accountRepository.save(accountEntity) + .doOnNext { log.debug { "updated users: $it.id" } } + .onErrorResume(::onSaveError) + + private fun onSaveError(throwable: Throwable): Mono { + log.debug { throwable.localizedMessage } + return when { + throwable is DuplicateKeyException -> Mono.error(ResponseStatusException(HttpStatus.CONFLICT)) + else -> Mono.error(ResponseStatusException(HttpStatus.BAD_REQUEST)) + } } } diff --git a/src/main/kotlin/ltd/hlaeja/util/Mapping.kt b/src/main/kotlin/ltd/hlaeja/util/Mapping.kt index 2c3954c..c55e9cd 100644 --- a/src/main/kotlin/ltd/hlaeja/util/Mapping.kt +++ b/src/main/kotlin/ltd/hlaeja/util/Mapping.kt @@ -4,6 +4,7 @@ import java.time.ZonedDateTime import ltd.hlaeja.entity.AccountEntity import ltd.hlaeja.library.accountRegistry.Account import org.springframework.http.HttpStatus.EXPECTATION_FAILED +import org.springframework.http.HttpStatus.BAD_REQUEST import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.server.ResponseStatusException @@ -23,6 +24,21 @@ fun Account.Request.toAccountEntity( updatedAt = ZonedDateTime.now(), enabled = enabled, username = username, - password = passwordEncoder.encode(password), + password = password + ?.let { passwordEncoder.encode(it) } + ?: throw ResponseStatusException(BAD_REQUEST), roles = roles.joinToString(","), ) + +fun AccountEntity.updateAccountEntity( + request: Account.Request, + passwordEncoder: PasswordEncoder, +): AccountEntity = this.copy( + updatedAt = ZonedDateTime.now(), + enabled = request.enabled, + username = request.username, + password = request.password + ?.let { passwordEncoder.encode(it) } + ?: this.password, + roles = request.roles.joinToString(","), +) diff --git a/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt b/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt index 797ac0d..b358ad3 100644 --- a/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt +++ b/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt @@ -153,11 +153,11 @@ class AccountServiceTest { every { accountRepository.findAll() } returns accounts // when - StepVerifier.create(accountService.getAccounts(1,2)) + StepVerifier.create(accountService.getAccounts(1, 2)) .expectNextMatches { accountEntity -> accountEntity.username == "username1" } - .expectNextMatches {accountEntity -> + .expectNextMatches { accountEntity -> accountEntity.username == "username2" } .verifyComplete() @@ -172,7 +172,7 @@ class AccountServiceTest { every { accountRepository.findAll() } returns accounts // when - StepVerifier.create(accountService.getAccounts(-1,10)) + StepVerifier.create(accountService.getAccounts(-1, 10)) .expectErrorMatches { error -> error is ResponseStatusException && error.statusCode == BAD_REQUEST } @@ -188,7 +188,7 @@ class AccountServiceTest { every { accountRepository.findAll() } returns accounts // when - StepVerifier.create(accountService.getAccounts(1,-10)) + StepVerifier.create(accountService.getAccounts(1, -10)) .expectErrorMatches { error -> error is ResponseStatusException && error.statusCode == BAD_REQUEST } @@ -197,4 +197,50 @@ class AccountServiceTest { // then verify { accountRepository.findAll() } } + + @Test + fun `update account - success`() { + // given + every { accountRepository.save(any()) } returns Mono.just(accountEntity) + + // when + StepVerifier.create(accountService.updateAccount(accountEntity)) + .expectNext(accountEntity) + .verifyComplete() + + // then + verify { accountRepository.save(any()) } + } + + @Test + fun `update account - fail duplicated user`() { + // given + every { accountRepository.save(any()) } returns Mono.error(DuplicateKeyException("Test")) + + // when + StepVerifier.create(accountService.updateAccount(accountEntity)) + .expectErrorMatches { error -> + error is ResponseStatusException && error.statusCode == CONFLICT + } + .verify() + + // then + verify { accountRepository.save(any()) } + } + + @Test + fun `update account - fail`() { + // given + every { accountRepository.save(any()) } returns Mono.error(RuntimeException()) + + // when + StepVerifier.create(accountService.updateAccount(accountEntity)) + .expectErrorMatches { error -> + error is ResponseStatusException && error.statusCode == BAD_REQUEST + } + .verify() + + // then + verify { accountRepository.save(any()) } + } } diff --git a/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt b/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt index cc67a36..dade914 100644 --- a/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt +++ b/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt @@ -1,6 +1,7 @@ package ltd.hlaeja.util import io.mockk.every +import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import java.time.LocalDateTime @@ -9,23 +10,46 @@ import java.time.ZonedDateTime import java.util.UUID import kotlin.test.assertFailsWith import ltd.hlaeja.entity.AccountEntity +import ltd.hlaeja.library.accountRegistry.Account import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.SoftAssertions +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.web.server.ResponseStatusException +@ExtendWith(SoftAssertionsExtension::class) class MappingKtTest { companion object { - val account = UUID.fromString("00000000-0000-0000-0000-000000000002") - val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC")) + val account = UUID.fromString("00000000-0000-0000-0000-000000000000") + val utc = ZoneId.of("UTC") + val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), utc) + val originalTimestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(1000, 1, 1, 0, 0, 0, 1), utc) + val originalUser = AccountEntity( + id = account, + username = "username", + enabled = true, + roles = "ROLE_TEST", + password = "password", + createdAt = originalTimestamp, + updatedAt = originalTimestamp, + ) } + @InjectSoftAssertions + lateinit var softly: SoftAssertions + private val passwordEncoder: BCryptPasswordEncoder = mockk() + @BeforeEach fun setUp() { mockkStatic(ZonedDateTime::class) every { ZonedDateTime.now() } returns timestamp + every { passwordEncoder.encode(any()) } answers { firstArg() } } @AfterEach @@ -38,7 +62,7 @@ class MappingKtTest { @Test fun `test toAccountResponse when id is not null`() { - // Arrange + // given val accountEntity = AccountEntity( id = account, createdAt = timestamp, @@ -49,10 +73,10 @@ class MappingKtTest { roles = "ROLE_ADMIN,ROLE_USER", ) - // Act + // when val result = accountEntity.toAccountResponse() - // Assert + // then assertThat(result.id).isEqualTo(accountEntity.id) assertThat(result.timestamp).isEqualTo(accountEntity.updatedAt) assertThat(result.enabled).isEqualTo(accountEntity.enabled) @@ -62,7 +86,7 @@ class MappingKtTest { @Test fun `test toAccountResponse when id is null`() { - // Arrange + // given val accountEntity = AccountEntity( id = null, createdAt = timestamp, @@ -73,10 +97,126 @@ class MappingKtTest { roles = "ROLE_ADMIN,ROLE_USER", ) - // Act and Assert + // when exception assertFailsWith { accountEntity.toAccountResponse() } } } + + @Nested + inner class CreateAccountMapping { + + @Test + fun `all fields changed`() { + // given + val request = Account.Request( + username = "username", + enabled = false, + roles = listOf("ROLE_TEST"), + password = "password", + ) + + // when + val updatedUser = request.toAccountEntity(passwordEncoder) + + // then + softly.assertThat(updatedUser.id).isNull() + softly.assertThat(updatedUser.createdAt).isEqualTo(timestamp) + softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp) + softly.assertThat(updatedUser.enabled).isEqualTo(request.enabled) + softly.assertThat(updatedUser.username).isEqualTo(request.username) + softly.assertThat(updatedUser.password).isEqualTo(request.password) + softly.assertThat(updatedUser.roles).isEqualTo("ROLE_TEST") + } + + @Test + fun `provided password is null`() { + // Given + val request = Account.Request( + username = "username", + enabled = false, + roles = listOf("ROLE_TEST"), + password = null, + ) + + // when exception + assertFailsWith { + request.toAccountEntity(passwordEncoder) + } + } + } + + @Nested + inner class UpdateAccountMapping { + + @Test + fun `all fields changed`() { + // Given + val request = Account.Request( + username = "new-username", + enabled = false, + roles = listOf("ROLE_MAGIC"), + password = "new-password", + ) + + // When + val updatedUser = originalUser.updateAccountEntity(request, passwordEncoder) + + // Then + softly.assertThat(updatedUser.id).isEqualTo(originalUser.id) + softly.assertThat(updatedUser.createdAt).isEqualTo(originalUser.createdAt) + softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp) + softly.assertThat(updatedUser.enabled).isEqualTo(request.enabled) + softly.assertThat(updatedUser.username).isEqualTo(request.username) + softly.assertThat(updatedUser.password).isEqualTo(request.password) + softly.assertThat(updatedUser.roles).isEqualTo("ROLE_MAGIC") + } + + @Test + fun `provided password is null`() { + // Given + val request = Account.Request( + username = originalUser.username, + enabled = originalUser.enabled, + roles = originalUser.roles.split(","), + password = null, + ) + + // When + val updatedUser = originalUser.updateAccountEntity(request, passwordEncoder) + + // Then + softly.assertThat(updatedUser.id).isEqualTo(account) + softly.assertThat(updatedUser.createdAt).isEqualTo(originalUser.createdAt) + softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp) + softly.assertThat(updatedUser.enabled).isEqualTo(request.enabled) + softly.assertThat(updatedUser.username).isEqualTo(request.username) + softly.assertThat(updatedUser.password).isEqualTo(originalUser.password) + softly.assertThat(updatedUser.roles).isEqualTo(originalUser.roles) + } + + @Test + fun `roles changed from single to multiple`() { + // Given + val request = Account.Request( + username = originalUser.username, + enabled = originalUser.enabled, + roles = listOf("ROLE_MAGIC", "ROLE_TEST"), + password = null, + ) + + // When + val updatedUser = originalUser.updateAccountEntity(request, passwordEncoder) + + // Then + softly.assertThat(updatedUser.id).isEqualTo(originalUser.id) + softly.assertThat(updatedUser.createdAt).isEqualTo(originalUser.createdAt) + softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp) + softly.assertThat(updatedUser.enabled).isEqualTo(originalUser.enabled) + softly.assertThat(updatedUser.username).isEqualTo(originalUser.username) + softly.assertThat(updatedUser.password).isEqualTo(originalUser.password) + softly.assertThat(updatedUser.roles).isEqualTo("ROLE_MAGIC,ROLE_TEST") + } + } }