1 Commits

Author SHA1 Message Date
8743419447 reactive 2025-09-14 13:57:25 +02:00
9 changed files with 520 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
@@ -46,3 +48,7 @@ kotlin {
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<BootJar> {
enabled = false
}

View File

@@ -0,0 +1,10 @@
package ltd.lulz.exception
@Suppress("unused")
class AccountNotFoundException : RuntimeException {
constructor() : super()
constructor(message: String?) : super(message)
constructor(message: String?, cause: Throwable?) : super(message, cause)
constructor(cause: Throwable?) : super(cause)
}

View File

@@ -0,0 +1,10 @@
package ltd.lulz.exception
@Suppress("unused")
class InsufficientFundsException : RuntimeException {
constructor() : super()
constructor(message: String?) : super(message)
constructor(message: String?, cause: Throwable?) : super(message, cause)
constructor(cause: Throwable?) : super(cause)
}

View File

@@ -0,0 +1,14 @@
package ltd.lulz.model
import java.math.BigDecimal
import java.util.UUID
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
@Table("accounts")
data class AccountEntity(
@Id
val id: UUID? = null,
val name: String,
val amount: BigDecimal,
)

View File

@@ -0,0 +1,15 @@
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.reactive.ReactiveCrudRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
@Repository
interface AccountRepository : ReactiveCrudRepository<AccountEntity, UUID> {
@Query("SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT")
fun findByIdForUpdate(id: UUID): Mono<AccountEntity>
}

View File

@@ -0,0 +1,44 @@
package ltd.lulz.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.math.BigDecimal
import java.util.UUID
import ltd.lulz.exception.AccountNotFoundException
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 {}
@Service
class AccountService(
private val accountRepository: AccountRepository,
) {
fun create(
name: String,
amount: BigDecimal
): Mono<AccountEntity> = accountRepository.save(AccountEntity(name = name, amount = amount))
.doOnNext { log.debug { "account created with id: ${it.id}" } }
fun getById(
id: UUID,
): Mono<AccountEntity> = accountRepository.findById(id)
.doOnNext { log.debug { "found account by id: ${it.id}" } }
.switchIfEmpty(Mono.error(AccountNotFoundException()))
@Transactional
fun getForUpdateById(
id: UUID,
): Mono<AccountEntity> = accountRepository.findByIdForUpdate(id)
.doOnNext { log.trace { "account with id: ${it.id} locked for update" } }
.switchIfEmpty(Mono.error(AccountNotFoundException()))
@Transactional
fun save(
entity: AccountEntity,
): Mono<AccountEntity> = accountRepository.save(entity)
.doOnNext { log.trace { "account with id: ${it.id} saved" } }
}

View File

@@ -0,0 +1,48 @@
package ltd.lulz.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.math.BigDecimal
import java.math.BigDecimal.ZERO
import java.util.UUID
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 {}
@Service
class TransactionService(
private val accountService: AccountService,
) {
@Transactional
fun deposit(
id: UUID,
amount: BigDecimal,
): 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
fun withdrawal(
account: UUID,
amount: BigDecimal,
): 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
fun transfer(
account: UUID,
receiver: UUID,
amount: BigDecimal,
): Mono<Void> = withdrawal(account, amount)
.zipWith(deposit(receiver, amount))
.then()
}

View File

@@ -0,0 +1,136 @@
package ltd.lulz.service
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 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 reactor.core.publisher.Mono
import reactor.test.StepVerifier
@Suppress("ReactiveStreamsUnusedPublisher", "MayBeConstant")
class AccountServiceTest {
companion object {
val name: String = "some name"
val amount: BigDecimal = BigDecimal.valueOf(0.01)
val uuid: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
}
private var repository: AccountRepository = mockk()
private lateinit var service: AccountService
@BeforeEach
fun setUp() {
service = AccountService(repository)
}
@Test
fun `create account`() {
// given
val capture = slot<AccountEntity>()
every { repository.save(capture(capture)) } answers { Mono.just(capture.captured.copy(id = uuid)) }
// 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()
verify { repository.save(any()) }
}
@Test
fun `get by id`() {
// given
val capture = slot<UUID>()
every { repository.findById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, name, amount)) }
// 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()
verify { repository.findById(any(UUID::class)) }
}
@Test
fun `get by id - fail`() {
// given
every { repository.findById(any(UUID::class)) } returns Mono.empty()
// when stepped
StepVerifier.create(service.getById(uuid))
.expectError(AccountNotFoundException::class.java)
.verify()
verify { repository.findById(any(UUID::class)) }
}
@Test
fun `get for update by id`() {
// given
val capture = slot<UUID>()
every { repository.findByIdForUpdate(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, name, amount)) }
// 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()
verify { repository.findByIdForUpdate(any(UUID::class)) }
}
@Test
fun `get for update by id - fail`() {
// given
every { repository.findByIdForUpdate(any(UUID::class)) } returns Mono.empty()
// when stepped
StepVerifier.create(service.getForUpdateById(uuid))
.expectError(AccountNotFoundException::class.java)
.verify()
verify { repository.findByIdForUpdate(any(UUID::class)) }
}
@Test
fun `save change`() {
// given
val entity = AccountEntity(name = name, amount = amount)
val capture = slot<AccountEntity>()
every { repository.save(capture(capture)) }
.answers { Mono.just(capture.captured) }
// when stepped
StepVerifier.create(service.save(entity))
.assertNext { result ->
assertThat(result).isEqualTo(entity)
}
.verifyComplete()
verify { repository.save(any()) }
}
}

View File

@@ -0,0 +1,237 @@
package ltd.lulz.service
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 ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.AccountEntity
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 reactor.core.publisher.Mono
import reactor.test.StepVerifier
@Suppress("MayBeConstant", "ReactiveStreamsUnusedPublisher")
class TransactionServiceTest {
companion object {
val accountName: String = "some name"
val accountAmount: BigDecimal = BigDecimal.valueOf(1.01)
val accountUuid: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
val receiverName: String = "different name"
val receiverAmount: BigDecimal = BigDecimal.valueOf(1.01)
val receiverUuid: UUID = UUID.fromString("00000000-0000-0000-0000-000000000001")
}
private val accountService: AccountService = mockk()
private lateinit var service: TransactionService
@BeforeEach
fun setup() {
service = TransactionService(accountService)
}
@Nested
inner class Deposit {
@Test
fun `deposit to account - success`() {
// given
val deposit = BigDecimal.valueOf(1.10)
val capture = slot<UUID>()
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
val entity = slot<AccountEntity>()
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// 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()
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 1) { accountService.save(any()) }
}
@Test
fun `deposit to account - account not found`() {
// given
val deposit = BigDecimal.valueOf(1.10)
every { accountService.getForUpdateById(any()) } returns Mono.error(AccountNotFoundException())
// when stepped
StepVerifier.create(service.deposit(accountUuid, deposit))
.expectError(AccountNotFoundException::class.java)
.verify()
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
}
@Nested
inner class Withdrawal {
@Test
fun `withdrawal from account - success`() {
// given
val deposit = BigDecimal.valueOf(1.01)
val capture = slot<UUID>()
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
val entity = slot<AccountEntity>()
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// 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()
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 1) { accountService.save(any()) }
}
@Test
fun `withdrawal from account - insufficient founds`() {
// given
val deposit = BigDecimal.valueOf(1.10)
val capture = slot<UUID>()
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
// when stepped
StepVerifier.create(service.withdrawal(accountUuid, deposit))
.expectError(InsufficientFundsException::class.java)
.verify()
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `withdrawal from account - account not found`() {
// given
val deposit = BigDecimal.valueOf(1.10)
every { accountService.getForUpdateById(any()) } returns Mono.error(AccountNotFoundException())
// when stepped
StepVerifier.create(service.withdrawal(accountUuid, deposit))
.expectError(AccountNotFoundException::class.java)
.verify()
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
}
@Nested
inner class Transfer {
@Test
fun `transfer from account - success`() {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
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>()
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.verifyComplete()
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 2) { accountService.save(any()) }
}
@Test
fun `transfer from account - insufficient founds`() {
// given
val transfer = BigDecimal.valueOf(5.00)
val capture = slot<UUID>()
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>()
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.expectError(InsufficientFundsException::class.java)
.verify()
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `transfer from account - account not found`() {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.error(AccountNotFoundException()) }
.andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) }
val entity = slot<AccountEntity>()
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.expectError(AccountNotFoundException::class.java)
.verify()
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `transfer from account - receiver not found`() {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
.andThenAnswer { Mono.error(AccountNotFoundException()) }
val entity = slot<AccountEntity>()
every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) }
// when stepped
StepVerifier.create(service.transfer(accountUuid, receiverUuid, transfer))
.expectError(AccountNotFoundException::class.java)
.verify()
verify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 1) { accountService.save(any()) }
}
}
}