diff --git a/build.gradle.kts b/build.gradle.kts index baa0945..a6e92df 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,6 @@ 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..6c04e7c --- /dev/null +++ b/src/main/kotlin/ltd/lulz/repository/AccountRepository.kt @@ -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 { + + @Query("SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT") + suspend fun findByIdForUpdate(id: UUID): AccountEntity? +} 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..23cd3ea --- /dev/null +++ b/src/main/kotlin/ltd/lulz/service/AccountService.kt @@ -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" } } +} 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..384543b --- /dev/null +++ b/src/main/kotlin/ltd/lulz/service/TransactionService.kt @@ -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) + } +} 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..1c4bb6f --- /dev/null +++ b/src/test/kotlin/ltd/lulz/service/AccountServiceTest.kt @@ -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() + 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() + 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 { + service.getById(uuid) + } + + // then + coVerify { repository.findById(any(UUID::class)) } + } + + @Test + fun `get for update by id`() = runTest { + // given + val capture = slot() + 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 { + 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() + coEvery { repository.save(capture(capture)) } + .answers { capture.captured } + + // when + val result = service.save(entity) + + // then + coVerify { repository.save(any()) } + + assertThat(result).isEqualTo(entity) + } +} 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..193e726 --- /dev/null +++ b/src/test/kotlin/ltd/lulz/service/TransactionServiceTest.kt @@ -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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .answers { AccountEntity(capture.captured, accountName, accountAmount) } + val entity = slot() + 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 { + 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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .answers { AccountEntity(capture.captured, accountName, accountAmount) } + val entity = slot() + 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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .answers { AccountEntity(capture.captured, accountName, accountAmount) } + + // when + assertThrows { + 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 { + 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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .answers { AccountEntity(capture.captured, accountName, accountAmount) } + .andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) } + val entity = slot() + 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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .answers { AccountEntity(capture.captured, accountName, accountAmount) } + .andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) } + val entity = slot() + coEvery { accountService.save(capture(entity)) } + .answers { entity.captured } + + // when + assertThrows { + 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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .throws(AccountNotFoundException()) + .andThenAnswer { AccountEntity(capture.captured, receiverName, receiverAmount) } + val entity = slot() + coEvery { accountService.save(capture(entity)) } + .answers { entity.captured } + + // when + assertThrows { + 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() + coEvery { accountService.getForUpdateById(capture(capture)) } + .answers { AccountEntity(capture.captured, accountName, accountAmount) } + .andThenThrows(AccountNotFoundException()) + val entity = slot() + coEvery { accountService.save(capture(entity)) } + .answers { entity.captured } + + // when + assertThrows { + service.transfer(accountUuid, receiverUuid, transfer) + } + + // then + coVerify(exactly = 2) { accountService.getForUpdateById(any(UUID::class)) } + coVerify(exactly = 1) { accountService.save(any()) } + } + } +}