1 Commits

Author SHA1 Message Date
8743419447 reactive 2025-09-14 13:57:25 +02:00
6 changed files with 210 additions and 213 deletions

View File

@@ -49,5 +49,6 @@ tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<BootJar> {
enabled = false
enabled = false
}

View File

@@ -3,12 +3,13 @@ package ltd.lulz.repository
import java.util.UUID
import ltd.lulz.model.AccountEntity
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.repository.reactive.ReactiveCrudRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
@Repository
interface AccountRepository : CoroutineCrudRepository<AccountEntity, UUID> {
interface AccountRepository : ReactiveCrudRepository<AccountEntity, UUID> {
@Query("SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT")
suspend fun findByIdForUpdate(id: UUID): AccountEntity?
fun findByIdForUpdate(id: UUID): Mono<AccountEntity>
}

View File

@@ -8,6 +8,7 @@ import ltd.lulz.model.AccountEntity
import ltd.lulz.repository.AccountRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Mono
private val log = KotlinLogging.logger {}
@@ -16,28 +17,28 @@ class AccountService(
private val accountRepository: AccountRepository,
) {
suspend fun create(
fun create(
name: String,
amount: BigDecimal
): AccountEntity = accountRepository.save(AccountEntity(name = name, amount = amount))
.also { log.debug { "account created with id: ${it.id}" } }
): Mono<AccountEntity> = accountRepository.save(AccountEntity(name = name, amount = amount))
.doOnNext { log.debug { "account created with id: ${it.id}" } }
suspend fun getById(
fun getById(
id: UUID,
): AccountEntity = accountRepository.findById(id)
?.also { log.debug { "found account by id: ${it.id}" } }
?: throw AccountNotFoundException()
): Mono<AccountEntity> = accountRepository.findById(id)
.doOnNext { log.debug { "found account by id: ${it.id}" } }
.switchIfEmpty(Mono.error(AccountNotFoundException()))
@Transactional
suspend fun getForUpdateById(
fun getForUpdateById(
id: UUID,
): AccountEntity = accountRepository.findByIdForUpdate(id)
?.also { log.trace { "account with id: ${it.id} locked for update" } }
?: throw AccountNotFoundException()
): Mono<AccountEntity> = accountRepository.findByIdForUpdate(id)
.doOnNext { log.trace { "account with id: ${it.id} locked for update" } }
.switchIfEmpty(Mono.error(AccountNotFoundException()))
@Transactional
suspend fun save(
fun save(
entity: AccountEntity,
): AccountEntity = accountRepository.save(entity)
.also { log.trace { "account with id: ${it.id} saved" } }
): Mono<AccountEntity> = accountRepository.save(entity)
.doOnNext { log.trace { "account with id: ${it.id} saved" } }
}

View File

@@ -8,6 +8,7 @@ import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.AccountEntity
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Mono
private val log = KotlinLogging.logger {}
@@ -17,36 +18,31 @@ class TransactionService(
) {
@Transactional
suspend fun deposit(
fun deposit(
id: UUID,
amount: BigDecimal,
): AccountEntity = accountService.getForUpdateById(id)
.let {
log.trace { "Deposited $amount to account ${it.id}" }
accountService.save(it.copy(amount = it.amount + amount))
}
): Mono<AccountEntity> = accountService.getForUpdateById(id)
.map { it.copy(amount = it.amount + amount) }
.doOnNext { log.trace { "Deposited $amount to account ${it.id}" } }
.flatMap { accountService.save(it) }
@Transactional
suspend fun withdrawal(
fun withdrawal(
account: UUID,
amount: BigDecimal,
): AccountEntity = accountService.getForUpdateById(account)
.let {
val entity = it.copy(amount = it.amount - amount)
if (entity.amount < ZERO) {
throw InsufficientFundsException()
}
log.trace { "withdrawal $amount from account ${it.id}" }
accountService.save(entity)
}
): Mono<AccountEntity> = accountService.getForUpdateById(account)
.map { it.copy(amount = it.amount - amount) }
.filter { it.amount >= ZERO }
.doOnNext { log.trace { "withdrawal $amount from account ${it.id}" } }
.switchIfEmpty(Mono.error(InsufficientFundsException()))
.flatMap { accountService.save(it) }
@Transactional
suspend fun transfer(
fun transfer(
account: UUID,
receiver: UUID,
amount: BigDecimal,
) {
withdrawal(account, amount)
deposit(receiver, amount)
}
): Mono<Void> = withdrawal(account, amount)
.zipWith(deposit(receiver, amount))
.then()
}

View File

@@ -1,21 +1,21 @@
package ltd.lulz.service
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import java.math.BigDecimal
import java.util.UUID
import kotlinx.coroutines.test.runTest
import ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.model.AccountEntity
import ltd.lulz.repository.AccountRepository
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import reactor.core.publisher.Mono
import reactor.test.StepVerifier
@Suppress("MayBeConstant")
@Suppress("ReactiveStreamsUnusedPublisher", "MayBeConstant")
class AccountServiceTest {
companion object {
@@ -33,102 +33,104 @@ class AccountServiceTest {
}
@Test
fun `create account`() = runTest {
fun `create account`() {
// given
val capture = slot<AccountEntity>()
coEvery { repository.save(capture(capture)) }
.answers { capture.captured.copy(id = uuid) }
every { repository.save(capture(capture)) } answers { Mono.just(capture.captured.copy(id = uuid)) }
// when
val result = service.create(name, amount)
// when stepped
StepVerifier.create(service.create(name, amount))
.assertNext { result ->
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
.verifyComplete()
// then
coVerify { repository.save(any()) }
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
verify { repository.save(any()) }
}
@Test
fun `get by id`() = runTest {
fun `get by id`() {
// given
val capture = slot<UUID>()
coEvery { repository.findById(capture(capture)) }
.answers { AccountEntity(capture.captured, name, amount) }
every { repository.findById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, name, amount)) }
// when
val result = service.getById(uuid)
// when stepped
StepVerifier.create(service.getById(uuid))
.assertNext { result ->
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
.verifyComplete()
// then
coVerify { repository.findById(any(UUID::class)) }
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
verify { repository.findById(any(UUID::class)) }
}
@Test
fun `get by id - fail`() = runTest {
fun `get by id - fail`() {
// given
coEvery { repository.findById(any(UUID::class)) } returns null
every { repository.findById(any(UUID::class)) } returns Mono.empty()
// when
assertThrows<AccountNotFoundException> {
service.getById(uuid)
}
// when stepped
StepVerifier.create(service.getById(uuid))
.expectError(AccountNotFoundException::class.java)
.verify()
// then
coVerify { repository.findById(any(UUID::class)) }
verify { repository.findById(any(UUID::class)) }
}
@Test
fun `get for update by id`() = runTest {
fun `get for update by id`() {
// given
val capture = slot<UUID>()
coEvery { repository.findByIdForUpdate(capture(capture)) }
.answers { AccountEntity(capture.captured, name, amount) }
every { repository.findByIdForUpdate(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, name, amount)) }
// when
val result = service.getForUpdateById(uuid)
// when stepped
StepVerifier.create(service.getForUpdateById(uuid))
.assertNext { result ->
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
.verifyComplete()
// then
coVerify { repository.findByIdForUpdate(any(UUID::class)) }
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
verify { repository.findByIdForUpdate(any(UUID::class)) }
}
@Test
fun `get for update by id - fail`() = runTest {
fun `get for update by id - fail`() {
// given
coEvery { repository.findByIdForUpdate(any(UUID::class)) } returns null
// when
assertThrows<AccountNotFoundException> {
service.getForUpdateById(uuid)
}
every { repository.findByIdForUpdate(any(UUID::class)) } returns Mono.empty()
// then
coVerify { repository.findByIdForUpdate(any(UUID::class)) }
// when stepped
StepVerifier.create(service.getForUpdateById(uuid))
.expectError(AccountNotFoundException::class.java)
.verify()
verify { repository.findByIdForUpdate(any(UUID::class)) }
}
@Test
fun `save change`() = runTest {
fun `save change`() {
// given
val entity = AccountEntity(name = name, amount = amount)
val capture = slot<AccountEntity>()
coEvery { repository.save(capture(capture)) }
.answers { capture.captured }
every { repository.save(capture(capture)) }
.answers { Mono.just(capture.captured) }
// when
val result = service.save(entity)
// when stepped
StepVerifier.create(service.save(entity))
.assertNext { result ->
assertThat(result).isEqualTo(entity)
}
.verifyComplete()
// then
coVerify { repository.save(any()) }
assertThat(result).isEqualTo(entity)
verify { repository.save(any()) }
}
}

View File

@@ -1,12 +1,11 @@
package ltd.lulz.service
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import java.math.BigDecimal
import java.util.UUID
import kotlinx.coroutines.test.runTest
import ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.AccountEntity
@@ -14,9 +13,10 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import reactor.core.publisher.Mono
import reactor.test.StepVerifier
@Suppress("MayBeConstant")
@Suppress("MayBeConstant", "ReactiveStreamsUnusedPublisher")
class TransactionServiceTest {
companion object {
@@ -40,44 +40,44 @@ class TransactionServiceTest {
inner class Deposit {
@Test
fun `deposit to account - success`() = runTest {
fun `deposit to account - success`() {
// given
val deposit = BigDecimal.valueOf(1.10)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when
val result = service.deposit(accountUuid, deposit)
// when stepped
StepVerifier.create(service.deposit(accountUuid, deposit))
.assertNext { result ->
assertThat(result.id).isEqualTo(accountUuid)
assertThat(result.name).isEqualTo(accountName)
assertThat(result.amount).isEqualTo(accountAmount + deposit)
}
.verifyComplete()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 1) { accountService.save(any()) }
assertThat(result.id).isEqualTo(accountUuid)
assertThat(result.name).isEqualTo(accountName)
assertThat(result.amount).isEqualTo(accountAmount + deposit)
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 1) { accountService.save(any()) }
}
@Test
fun `deposit to account - account not found`() = runTest {
fun `deposit to account - account not found`() {
// given
val deposit = BigDecimal.valueOf(1.10)
coEvery { accountService.getForUpdateById(any()) } throws AccountNotFoundException()
every { accountService.getForUpdateById(any()) } returns Mono.error(AccountNotFoundException())
// when
assertThrows<AccountNotFoundException> {
service.deposit(accountUuid, deposit)
}
// when stepped
StepVerifier.create(service.deposit(accountUuid, deposit))
.expectError(AccountNotFoundException::class.java)
.verify()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
}
@@ -85,63 +85,62 @@ class TransactionServiceTest {
inner class Withdrawal {
@Test
fun `withdrawal from account - success`() = runTest {
fun `withdrawal from account - success`() {
// given
val deposit = BigDecimal.valueOf(1.01)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when
val result = service.withdrawal(accountUuid, deposit)
// when stepped
StepVerifier.create(service.withdrawal(accountUuid, deposit))
.assertNext { result ->
assertThat(result.id).isEqualTo(accountUuid)
assertThat(result.name).isEqualTo(accountName)
assertThat(result.amount).isEqualTo(accountAmount - deposit)
}
.verifyComplete()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 1) { accountService.save(any()) }
assertThat(result.id).isEqualTo(accountUuid)
assertThat(result.name).isEqualTo(accountName)
assertThat(result.amount).isEqualTo(accountAmount - deposit)
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 1) { accountService.save(any()) }
}
@Test
fun `withdrawal from account - insufficient founds`() = runTest {
fun `withdrawal from account - insufficient founds`() {
// given
val deposit = BigDecimal.valueOf(1.10)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
// when
assertThrows<InsufficientFundsException> {
service.withdrawal(accountUuid, deposit)
}
// when stepped
StepVerifier.create(service.withdrawal(accountUuid, deposit))
.expectError(InsufficientFundsException::class.java)
.verify()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `withdrawal from account - account not found`() = runTest {
fun `withdrawal from account - account not found`() {
// given
val deposit = BigDecimal.valueOf(1.10)
coEvery { accountService.getForUpdateById(any()) } throws AccountNotFoundException()
every { accountService.getForUpdateById(any()) } returns Mono.error(AccountNotFoundException())
// when
assertThrows<AccountNotFoundException> {
service.withdrawal(accountUuid, deposit)
}
// when stepped
StepVerifier.create(service.withdrawal(accountUuid, deposit))
.expectError(AccountNotFoundException::class.java)
.verify()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
}
@@ -149,93 +148,90 @@ class TransactionServiceTest {
inner class Transfer {
@Test
fun `transfer from account - success`() = runTest {
fun `transfer from account - success`() {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
.andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) }
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
.andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when
service.transfer(accountUuid, receiverUuid, transfer)
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.verifyComplete()
// then
coVerify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 2) { accountService.save(any()) }
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 2) { accountService.save(any()) }
}
@Test
fun `transfer from account - insufficient founds`() = runTest {
fun `transfer from account - insufficient founds`() {
// given
val transfer = BigDecimal.valueOf(5.00)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
.andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) }
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
.andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when
assertThrows<InsufficientFundsException> {
service.transfer(accountUuid, receiverUuid, transfer)
}
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.expectError(InsufficientFundsException::class.java)
.verify()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `transfer from account - account not found`() = runTest {
fun `transfer from account - account not found`() {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.throws(AccountNotFoundException())
.andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) }
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.error(AccountNotFoundException()) }
.andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when
assertThrows<AccountNotFoundException> {
service.transfer(accountUuid, receiverUuid, transfer)
}
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.expectError(AccountNotFoundException::class.java)
.verify()
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `transfer from account - receiver not found`() = runTest {
fun `transfer from account - receiver not found`() {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
.andThenThrows(AccountNotFoundException())
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
.andThenAnswer { Mono.error(AccountNotFoundException()) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when
assertThrows<AccountNotFoundException> {
service.transfer(accountUuid, receiverUuid, transfer)
}
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.expectError(AccountNotFoundException::class.java)
.verify()
// then
coVerify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 1) { accountService.save(any()) }
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 1) { accountService.save(any()) }
}
}
}