From 87434194476a4ac92aa61ac8af3730c2184d0f26 Mon Sep 17 00:00:00 2001 From: Swordsteel Date: Sun, 14 Sep 2025 12:06:55 +0200 Subject: [PATCH] reactive --- build.gradle.kts | 6 + .../exception/AccountNotFoundException.kt | 10 + .../exception/InsufficientFundsException.kt | 10 + .../kotlin/ltd/lulz/model/AccountEntity.kt | 14 ++ .../ltd/lulz/repository/AccountRepository.kt | 15 ++ .../kotlin/ltd/lulz/service/AccountService.kt | 44 ++++ .../ltd/lulz/service/TransactionService.kt | 48 ++++ .../ltd/lulz/service/AccountServiceTest.kt | 136 ++++++++++ .../lulz/service/TransactionServiceTest.kt | 237 ++++++++++++++++++ 9 files changed, 520 insertions(+) create mode 100644 src/main/kotlin/ltd/lulz/exception/AccountNotFoundException.kt create mode 100644 src/main/kotlin/ltd/lulz/exception/InsufficientFundsException.kt create mode 100644 src/main/kotlin/ltd/lulz/model/AccountEntity.kt create mode 100644 src/main/kotlin/ltd/lulz/repository/AccountRepository.kt create mode 100644 src/main/kotlin/ltd/lulz/service/AccountService.kt create mode 100644 src/main/kotlin/ltd/lulz/service/TransactionService.kt create mode 100644 src/test/kotlin/ltd/lulz/service/AccountServiceTest.kt create mode 100644 src/test/kotlin/ltd/lulz/service/TransactionServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index baa0945..d1bfde9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { useJUnitPlatform() } +tasks.withType { + enabled = false +} + diff --git a/src/main/kotlin/ltd/lulz/exception/AccountNotFoundException.kt b/src/main/kotlin/ltd/lulz/exception/AccountNotFoundException.kt new file mode 100644 index 0000000..ed29bfa --- /dev/null +++ b/src/main/kotlin/ltd/lulz/exception/AccountNotFoundException.kt @@ -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) +} diff --git a/src/main/kotlin/ltd/lulz/exception/InsufficientFundsException.kt b/src/main/kotlin/ltd/lulz/exception/InsufficientFundsException.kt new file mode 100644 index 0000000..fefee23 --- /dev/null +++ b/src/main/kotlin/ltd/lulz/exception/InsufficientFundsException.kt @@ -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) +} diff --git a/src/main/kotlin/ltd/lulz/model/AccountEntity.kt b/src/main/kotlin/ltd/lulz/model/AccountEntity.kt new file mode 100644 index 0000000..51518ac --- /dev/null +++ b/src/main/kotlin/ltd/lulz/model/AccountEntity.kt @@ -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, +) diff --git a/src/main/kotlin/ltd/lulz/repository/AccountRepository.kt b/src/main/kotlin/ltd/lulz/repository/AccountRepository.kt new file mode 100644 index 0000000..76af633 --- /dev/null +++ b/src/main/kotlin/ltd/lulz/repository/AccountRepository.kt @@ -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 { + + @Query("SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT") + fun findByIdForUpdate(id: UUID): Mono +} diff --git a/src/main/kotlin/ltd/lulz/service/AccountService.kt b/src/main/kotlin/ltd/lulz/service/AccountService.kt new file mode 100644 index 0000000..3c15b3b --- /dev/null +++ b/src/main/kotlin/ltd/lulz/service/AccountService.kt @@ -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 = accountRepository.save(AccountEntity(name = name, amount = amount)) + .doOnNext { log.debug { "account created with id: ${it.id}" } } + + fun getById( + id: UUID, + ): Mono = accountRepository.findById(id) + .doOnNext { log.debug { "found account by id: ${it.id}" } } + .switchIfEmpty(Mono.error(AccountNotFoundException())) + + @Transactional + fun getForUpdateById( + id: UUID, + ): Mono = accountRepository.findByIdForUpdate(id) + .doOnNext { log.trace { "account with id: ${it.id} locked for update" } } + .switchIfEmpty(Mono.error(AccountNotFoundException())) + + @Transactional + fun save( + entity: AccountEntity, + ): Mono = accountRepository.save(entity) + .doOnNext { log.trace { "account with id: ${it.id} saved" } } +} diff --git a/src/main/kotlin/ltd/lulz/service/TransactionService.kt b/src/main/kotlin/ltd/lulz/service/TransactionService.kt new file mode 100644 index 0000000..9566887 --- /dev/null +++ b/src/main/kotlin/ltd/lulz/service/TransactionService.kt @@ -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 = 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 = 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 = withdrawal(account, amount) + .zipWith(deposit(receiver, amount)) + .then() +} diff --git a/src/test/kotlin/ltd/lulz/service/AccountServiceTest.kt b/src/test/kotlin/ltd/lulz/service/AccountServiceTest.kt new file mode 100644 index 0000000..46b2327 --- /dev/null +++ b/src/test/kotlin/ltd/lulz/service/AccountServiceTest.kt @@ -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() + 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() + 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() + 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() + 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()) } + } +} diff --git a/src/test/kotlin/ltd/lulz/service/TransactionServiceTest.kt b/src/test/kotlin/ltd/lulz/service/TransactionServiceTest.kt new file mode 100644 index 0000000..72464bd --- /dev/null +++ b/src/test/kotlin/ltd/lulz/service/TransactionServiceTest.kt @@ -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() + every { accountService.getForUpdateById(capture(capture)) } + .answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) } + val entity = slot() + 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() + every { accountService.getForUpdateById(capture(capture)) } + .answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) } + val entity = slot() + 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() + 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() + every { accountService.getForUpdateById(capture(capture)) } + .answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) } + .andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) } + val entity = slot() + 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() + every { accountService.getForUpdateById(capture(capture)) } + .answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) } + .andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) } + val entity = slot() + 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() + every { accountService.getForUpdateById(capture(capture)) } + .answers { Mono.error(AccountNotFoundException()) } + .andThenAnswer { Mono.just(AccountEntity(capture.captured, receiverName, receiverAmount)) } + val entity = slot() + 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() + every { accountService.getForUpdateById(capture(capture)) } + .answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) } + .andThenAnswer { Mono.error(AccountNotFoundException()) } + val entity = slot() + 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()) } + } + } +}