1 Commits

Author SHA1 Message Date
bf04fa5077 coroutine 2025-09-14 13:53:12 +02:00
9 changed files with 523 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,6 @@ 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,14 @@
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.stereotype.Repository
@Repository
interface AccountRepository : CoroutineCrudRepository<AccountEntity, UUID> {
@Query("SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT")
suspend fun findByIdForUpdate(id: UUID): AccountEntity?
}

View File

@@ -0,0 +1,43 @@
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
private val log = KotlinLogging.logger {}
@Service
class AccountService(
private val accountRepository: AccountRepository,
) {
suspend fun create(
name: String,
amount: BigDecimal
): AccountEntity = accountRepository.save(AccountEntity(name = name, amount = amount))
.also { log.debug { "account created with id: ${it.id}" } }
suspend fun getById(
id: UUID,
): AccountEntity = accountRepository.findById(id)
?.also { log.debug { "found account by id: ${it.id}" } }
?: throw AccountNotFoundException()
@Transactional
suspend fun getForUpdateById(
id: UUID,
): AccountEntity = accountRepository.findByIdForUpdate(id)
?.also { log.trace { "account with id: ${it.id} locked for update" } }
?: throw AccountNotFoundException()
@Transactional
suspend fun save(
entity: AccountEntity,
): AccountEntity = accountRepository.save(entity)
.also { log.trace { "account with id: ${it.id} saved" } }
}

View File

@@ -0,0 +1,52 @@
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
private val log = KotlinLogging.logger {}
@Service
class TransactionService(
private val accountService: AccountService,
) {
@Transactional
suspend 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))
}
@Transactional
suspend 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)
}
@Transactional
suspend fun transfer(
account: UUID,
receiver: UUID,
amount: BigDecimal,
) {
withdrawal(account, amount)
deposit(receiver, amount)
}
}

View File

@@ -0,0 +1,134 @@
package ltd.lulz.service
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
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
@Suppress("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`() = runTest {
// given
val capture = slot<AccountEntity>()
coEvery { repository.save(capture(capture)) }
.answers { capture.captured.copy(id = uuid) }
// when
val result = service.create(name, amount)
// then
coVerify { repository.save(any()) }
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
@Test
fun `get by id`() = runTest {
// given
val capture = slot<UUID>()
coEvery { repository.findById(capture(capture)) }
.answers { AccountEntity(capture.captured, name, amount) }
// when
val result = service.getById(uuid)
// then
coVerify { repository.findById(any(UUID::class)) }
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
@Test
fun `get by id - fail`() = runTest {
// given
coEvery { repository.findById(any(UUID::class)) } returns null
// when
assertThrows<AccountNotFoundException> {
service.getById(uuid)
}
// then
coVerify { repository.findById(any(UUID::class)) }
}
@Test
fun `get for update by id`() = runTest {
// given
val capture = slot<UUID>()
coEvery { repository.findByIdForUpdate(capture(capture)) }
.answers { AccountEntity(capture.captured, name, amount) }
// when
val result = service.getForUpdateById(uuid)
// then
coVerify { repository.findByIdForUpdate(any(UUID::class)) }
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
@Test
fun `get for update by id - fail`() = runTest {
// given
coEvery { repository.findByIdForUpdate(any(UUID::class)) } returns null
// when
assertThrows<AccountNotFoundException> {
service.getForUpdateById(uuid)
}
// then
coVerify { repository.findByIdForUpdate(any(UUID::class)) }
}
@Test
fun `save change`() = runTest {
// given
val entity = AccountEntity(name = name, amount = amount)
val capture = slot<AccountEntity>()
coEvery { repository.save(capture(capture)) }
.answers { capture.captured }
// when
val result = service.save(entity)
// then
coVerify { repository.save(any()) }
assertThat(result).isEqualTo(entity)
}
}

View File

@@ -0,0 +1,241 @@
package ltd.lulz.service
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
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
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
@Suppress("MayBeConstant")
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`() = runTest {
// given
val deposit = BigDecimal.valueOf(1.10)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
// when
val result = service.deposit(accountUuid, deposit)
// 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)
}
@Test
fun `deposit to account - account not found`() = runTest {
// given
val deposit = BigDecimal.valueOf(1.10)
coEvery { accountService.getForUpdateById(any()) } throws AccountNotFoundException()
// when
assertThrows<AccountNotFoundException> {
service.deposit(accountUuid, deposit)
}
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
}
}
@Nested
inner class Withdrawal {
@Test
fun `withdrawal from account - success`() = runTest {
// given
val deposit = BigDecimal.valueOf(1.01)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
// when
val result = service.withdrawal(accountUuid, deposit)
// 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)
}
@Test
fun `withdrawal from account - insufficient founds`() = runTest {
// given
val deposit = BigDecimal.valueOf(1.10)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
// when
assertThrows<InsufficientFundsException> {
service.withdrawal(accountUuid, deposit)
}
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `withdrawal from account - account not found`() = runTest {
// given
val deposit = BigDecimal.valueOf(1.10)
coEvery { accountService.getForUpdateById(any()) } throws AccountNotFoundException()
// when
assertThrows<AccountNotFoundException> {
service.withdrawal(accountUuid, deposit)
}
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
}
}
@Nested
inner class Transfer {
@Test
fun `transfer from account - success`() = runTest {
// 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) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
// when
service.transfer(accountUuid, receiverUuid, transfer)
// then
coVerify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 2) { accountService.save(any()) }
}
@Test
fun `transfer from account - insufficient founds`() = runTest {
// 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) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
// when
assertThrows<InsufficientFundsException> {
service.transfer(accountUuid, receiverUuid, transfer)
}
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `transfer from account - account not found`() = runTest {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.throws(AccountNotFoundException())
.andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) }
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
// when
assertThrows<AccountNotFoundException> {
service.transfer(accountUuid, receiverUuid, transfer)
}
// then
coVerify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 0) { accountService.save(any()) }
}
@Test
fun `transfer from account - receiver not found`() = runTest {
// given
val transfer = BigDecimal.valueOf(1.00)
val capture = slot<UUID>()
coEvery { accountService.getForUpdateById(capture(capture)) }
.answers { AccountEntity(capture.captured, accountName, accountAmount) }
.andThenThrows(AccountNotFoundException())
val entity = slot<AccountEntity>()
coEvery { accountService.save(capture(entity)) }
.answers { entity.captured }
// when
assertThrows<AccountNotFoundException> {
service.transfer(accountUuid, receiverUuid, transfer)
}
// then
coVerify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) }
coVerify(exactly = 1) { accountService.save(any()) }
}
}
}