diff --git a/build.gradle.kts b/build.gradle.kts index be7a852..103a52c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,8 @@ dependencies { runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("com.tngtech.archunit:archunit:1.0.0-rc1") + testImplementation("org.mockito.kotlin:mockito-kotlin:4.0.0") + testImplementation("org.mockito:mockito-inline:4.8.0") } tasks.withType { diff --git a/src/main/kotlin/backendsquid/buckpal/account/application/service/MoneyTransferProperties.kt b/src/main/kotlin/backendsquid/buckpal/account/application/service/MoneyTransferProperties.kt new file mode 100644 index 0000000..090f9b2 --- /dev/null +++ b/src/main/kotlin/backendsquid/buckpal/account/application/service/MoneyTransferProperties.kt @@ -0,0 +1,9 @@ +package backendsquid.buckpal.account.application.service + +import backendsquid.buckpal.account.domain.Money +import org.springframework.stereotype.Component + +@Component +class MoneyTransferProperties( + val maximumTransferThreshold: Money = Money.of(1_000_000L) +) \ No newline at end of file diff --git a/src/main/kotlin/backendsquid/buckpal/account/application/service/SendMoneyService.kt b/src/main/kotlin/backendsquid/buckpal/account/application/service/SendMoneyService.kt index fa4c899..9f4bb02 100644 --- a/src/main/kotlin/backendsquid/buckpal/account/application/service/SendMoneyService.kt +++ b/src/main/kotlin/backendsquid/buckpal/account/application/service/SendMoneyService.kt @@ -15,8 +15,11 @@ class SendMoneyService( private val loadAccountPort: LoadAccountPort, private val accountLock: AccountLock, private val updateAccountStatePort: UpdateAccountStatePort, -): SendMoneyUseCase { + private val moneyTransferProperties: MoneyTransferProperties, +) : SendMoneyUseCase { override fun sendMoney(command: SendMoneyCommand): Boolean { + checkThreshold(command) + val baseline = LocalDateTime.now().minusDays(10) val sourceAccount = loadAccountPort.loadAccount( accountId = command.sourceAccountId, @@ -47,4 +50,10 @@ class SendMoneyService( return true } + + private fun checkThreshold(command: SendMoneyCommand) { + if (command.money.isGreaterThan(moneyTransferProperties.maximumTransferThreshold)) { + throw ThresholdExceededException(moneyTransferProperties.maximumTransferThreshold, command.money) + } + } } diff --git a/src/main/kotlin/backendsquid/buckpal/account/application/service/ThresholdExceededException.kt b/src/main/kotlin/backendsquid/buckpal/account/application/service/ThresholdExceededException.kt new file mode 100644 index 0000000..ff79cd1 --- /dev/null +++ b/src/main/kotlin/backendsquid/buckpal/account/application/service/ThresholdExceededException.kt @@ -0,0 +1,7 @@ +package backendsquid.buckpal.account.application.service + +import backendsquid.buckpal.account.domain.Money + +class ThresholdExceededException(threshold: Money, actual: Money) : RuntimeException( + String.format("Maximum threshold for transferring money exceeded: tried to transfer $actual but threshold is $threshold!") +) diff --git a/src/main/kotlin/backendsquid/buckpal/account/domain/Money.kt b/src/main/kotlin/backendsquid/buckpal/account/domain/Money.kt index ccbffc6..0fbae2c 100644 --- a/src/main/kotlin/backendsquid/buckpal/account/domain/Money.kt +++ b/src/main/kotlin/backendsquid/buckpal/account/domain/Money.kt @@ -4,7 +4,7 @@ import java.math.BigInteger data class Money( val amount: BigInteger, -): Comparable { +) : Comparable { companion object { val ZERO: Money = Money.of(value = 0) fun of(value: Long): Money = Money(BigInteger.valueOf(value)) @@ -16,6 +16,10 @@ data class Money( fun isNegative(): Boolean = this.amount < BigInteger.ZERO fun isPositive(): Boolean = this.amount > BigInteger.ZERO + fun isGreaterThanOrEqualTo(money: Money): Boolean = this.amount >= money.amount + + fun isGreaterThan(money: Money): Boolean = this.amount.compareTo(money.amount) >= 1 + override fun compareTo(other: Money): Int = this.amount.compareTo(other.amount) operator fun plus(other: Money): Money = Money(this.amount + other.amount) operator fun minus(other: Money): Money = Money(this.amount - other.amount) diff --git a/src/test/kotlin/backendsquid/buckpal/account/application/service/SendMoneyServiceTest.kt b/src/test/kotlin/backendsquid/buckpal/account/application/service/SendMoneyServiceTest.kt new file mode 100644 index 0000000..8078e92 --- /dev/null +++ b/src/test/kotlin/backendsquid/buckpal/account/application/service/SendMoneyServiceTest.kt @@ -0,0 +1,87 @@ +package backendsquid.buckpal.account.application.service + +import backendsquid.buckpal.account.application.port.`in`.SendMoneyCommand +import backendsquid.buckpal.account.application.port.out.AccountLock +import backendsquid.buckpal.account.application.port.out.LoadAccountPort +import backendsquid.buckpal.account.application.port.out.UpdateAccountStatePort +import backendsquid.buckpal.account.domain.Account +import backendsquid.buckpal.account.domain.Money +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.kotlin.* +import java.time.LocalDateTime + +internal class SendMoneyServiceTest( +) { + private val loadAccountPort = mock() + private val accountLock = mock() + private val updateAccountStatePort = mock() + private val sendMoneyService = + SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties()) + + @Test + fun transactionalSucceeds() { + val sourceAccount: Account = givenSourceAccount() + val targetAccount: Account = givenTargetAccount() + + givenWithdrawalWillSucceed(sourceAccount) + givenDepositWillSucceed(targetAccount) + + val money: Money = Money.of(500L) + + val sendMoneyCommand = SendMoneyCommand( + sourceAccountId = sourceAccount.id, + targetAccountId = targetAccount.id, + money, + ) + + val success: Boolean = sendMoneyService.sendMoney(command = sendMoneyCommand) + Assertions.assertThat(success).isTrue + + val sourceAccountId: Account.AccountId = sourceAccount.id + val targetAccountId: Account.AccountId = targetAccount.id + + then(accountLock).should().lockAccount(eq(sourceAccountId)) + then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId)) + then(accountLock).should().releaseAccount(eq(sourceAccountId)) + + then(accountLock).should().lockAccount(eq(targetAccountId)) + then(targetAccount).should().withdraw(eq(money), eq(sourceAccountId)) + then(accountLock).should().releaseAccount(eq(targetAccountId)) + + thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId) + } + + private fun thenAccountsHaveBeenUpdated(vararg accountIds: Account.AccountId) { + val accountCaptor: ArgumentCaptor = ArgumentCaptor.forClass(Account::class.java) + then(updateAccountStatePort).should(times(accountIds.size)) + .updateActivities(accountCaptor.capture()) + + val updatedAccountIds: List = accountCaptor.allValues + .map(Account::id) + .toList() + + updatedAccountIds.forEach { Assertions.assertThat(updatedAccountIds).contains(it) } + } + + private fun givenSourceAccount() = givenAnAccountWithId(Account.AccountId(42L)) + private fun givenTargetAccount() = givenAnAccountWithId(Account.AccountId(41L)) + + private fun givenAnAccountWithId(id: Account.AccountId): Account { + val account = mock() + given(account.id).willReturn(id) + given(loadAccountPort.loadAccount(eq(account.id), any())).willReturn(account) + + return account + } + + private fun givenWithdrawalWillSucceed(account: Account) = + given(account.withdraw(any(), any())).willReturn(true) + + private fun givenDepositWillSucceed(account: Account) = + given(account.deposit(any(), any())).willReturn(true) + + private fun moneyTransferProperties() = MoneyTransferProperties(Money.of(Long.MAX_VALUE)) +} + diff --git a/src/test/kotlin/backendsquid/buckpal/account/domain/AccountTest.kt b/src/test/kotlin/backendsquid/buckpal/account/domain/AccountTest.kt new file mode 100644 index 0000000..f332c22 --- /dev/null +++ b/src/test/kotlin/backendsquid/buckpal/account/domain/AccountTest.kt @@ -0,0 +1,63 @@ +package backendsquid.buckpal.account.domain + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneId + +internal class AccountTest { + + @Test + fun withdrawSucceeds() { + val accountId: Account.AccountId = Account.AccountId(1L) + val account: Account = defaultAccount( + accountId = accountId, + baseLineBalance = 555L, + activityWindow = ActivityWindow( + mutableListOf( + defaultActivity( + targetAccountId = accountId, + money = 999L + ), + defaultActivity( + targetAccountId = accountId, + money = 1L + ) + ) + ) + ) + + val success: Boolean = account.withdraw( + money = Money.of(555L), + targetAccountId = Account.AccountId(99L) + ) + + Assertions.assertThat(success).isTrue + Assertions.assertThat(account.activityWindow.getActivities()).hasSize(3) + Assertions.assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L)) + } +} + + +private fun defaultAccount( + accountId: Account.AccountId = Account.AccountId(42L), + baseLineBalance: Long = 999L, + activityWindow: ActivityWindow = ActivityWindow(mutableListOf(defaultActivity(), defaultActivity())) +) = Account( + id = accountId, + baselineBalance = Money.of(baseLineBalance), + activityWindow = activityWindow +) + +private fun defaultActivity( + ownerAccountId: Account.AccountId = Account.AccountId(42L), + sourceAccountId: Account.AccountId = Account.AccountId(42L), + targetAccountId: Account.AccountId = Account.AccountId(41L), + money: Long = 999L, +) = Activity( + ownerAccountId = ownerAccountId, + sourceAccountId = sourceAccountId, + targetAccountId = targetAccountId, + timestamp = LocalDateTime.now(ZoneId.of("Asia/Seoul")), + money = Money.of(money) +) \ No newline at end of file