10 Commits

Author SHA1 Message Date
9d6eca4377 infinity money bug :D
This is here to show a small miss with big problems.

- add missing test to
  - TransactionEndpoints
  - TransactionControllerTest
- update Transfer with validation for sender receiver
- add SenderReceiverValidator
- add SenderReceiver
2025-09-14 12:15:08 +02:00
aa8ba9ae1e test integration
- add TransactionEndpoints
- add AccountEndpoints
- add sql files for test
- add dependencies
2025-09-14 12:15:08 +02:00
ddf91791e5 update TransactionController with transfer 2025-09-14 12:15:08 +02:00
32d340223e add Transfer 2025-09-14 12:15:08 +02:00
44aafdb505 update TransactionService with transfer 2025-09-14 12:15:08 +02:00
9dcae1f5e0 update TransactionController with withdrawal 2025-09-14 12:15:08 +02:00
f9a5cd5922 update TransactionService with withdrawal 2025-09-14 12:15:08 +02:00
adfe96720b add InsufficientFundsException 2025-09-13 18:54:02 +02:00
71edd35c8e add TransactionController 2025-09-13 18:54:02 +02:00
ff9a3f70ae add Transaction 2025-09-13 12:53:14 +02:00
16 changed files with 1116 additions and 34 deletions

View File

@@ -26,6 +26,11 @@ dependencies {
testImplementation(aa.springboot.starter.test) testImplementation(aa.springboot.starter.test)
testRuntimeOnly(aa.junit.platform.launcher) testRuntimeOnly(aa.junit.platform.launcher)
testIntegrationImplementation(aa.library.test.integration)
testIntegrationImplementation(aa.springboot.starter.test)
testIntegrationRuntimeOnly(aa.junit.platform.launcher)
} }
group = "ltd.lulz" group = "ltd.lulz"

27
http/transaction.http Normal file
View File

@@ -0,0 +1,27 @@
### Deposit
POST {{url}}/deposit
Content-Type: application/json
{
"account": "00000000-0000-0000-0000-000000000000",
"amount": 1.23
}
### Withdrawal
POST {{url}}/withdrawal
Content-Type: application/json
{
"account": "00000000-0000-0000-0000-000000000000",
"amount": 1
}
### Withdrawal
POST {{url}}/transfer
Content-Type: application/json
{
"account": "00000000-0000-0000-0000-000000000000",
"receiver": "00000000-0000-0000-0000-000000000000",
"amount": 1
}

View File

@@ -0,0 +1,15 @@
package ltd.lulz.annotation
import jakarta.validation.Constraint
import jakarta.validation.Payload
import kotlin.reflect.KClass
import ltd.lulz.annotation.validator.SenderReceiverValidator
@Constraint(validatedBy = [SenderReceiverValidator::class])
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class SenderReceiver(
val message: String = "Receiver and Sender cant be the same account",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
)

View File

@@ -0,0 +1,14 @@
package ltd.lulz.annotation.validator
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import ltd.lulz.annotation.SenderReceiver
import ltd.lulz.model.Transfer.Request
class SenderReceiverValidator : ConstraintValidator<SenderReceiver, Request> {
override fun isValid(
request: Request,
context: ConstraintValidatorContext,
): Boolean = request.receiver != request.account
}

View File

@@ -0,0 +1,73 @@
package ltd.lulz.controller
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.validation.Valid
import ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.Transaction
import ltd.lulz.model.Transfer
import ltd.lulz.service.TransactionService
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
import org.springframework.http.HttpStatus.NOT_ACCEPTABLE
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Mono
private val log = KotlinLogging.logger {}
@Validated
@RestController
class TransactionController(
private val transactionService: TransactionService,
) {
@PostMapping("/deposit")
@ResponseStatus(CREATED)
fun deposit(
@Valid @RequestBody request: Transaction.Request,
): Mono<Void> = transactionService.deposit(request.account, request.amount)
.onErrorResume {
when (it) {
is AccountNotFoundException -> Mono.error(ResponseStatusException(NOT_FOUND))
else -> Mono.error(ResponseStatusException(INTERNAL_SERVER_ERROR))
}
}
.doOnError { log.warn { "deposit account ${request.account}: " + it.localizedMessage } }
.then()
@PostMapping("/withdrawal")
@ResponseStatus(CREATED)
fun withdrawal(
@Valid @RequestBody request: Transaction.Request,
): Mono<Void> = transactionService.withdrawal(request.account, request.amount)
.onErrorResume {
when (it) {
is InsufficientFundsException -> Mono.error(ResponseStatusException(NOT_ACCEPTABLE))
is AccountNotFoundException -> Mono.error(ResponseStatusException(NOT_FOUND))
else -> Mono.error(ResponseStatusException(INTERNAL_SERVER_ERROR))
}
}
.doOnError { log.warn { "withdrawal account ${request.account}: " + it.localizedMessage } }
.then()
@PostMapping("/transfer")
@ResponseStatus(CREATED)
fun transfer(
@Valid @RequestBody request: Transfer.Request,
): Mono<Void> = transactionService.transfer(request.account, request.receiver, request.amount)
.onErrorResume {
when (it) {
is InsufficientFundsException -> Mono.error(ResponseStatusException(NOT_ACCEPTABLE))
is AccountNotFoundException -> Mono.error(ResponseStatusException(NOT_FOUND))
else -> Mono.error(ResponseStatusException(INTERNAL_SERVER_ERROR))
}
}
.doOnError { log.warn { "transfer account ${request.account}: " + it.localizedMessage } }
.then()
}

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 jakarta.validation.constraints.DecimalMin
import java.math.BigDecimal
import java.util.UUID
object Transaction {
data class Request(
val account: UUID,
@field:DecimalMin(value = "0.01")
val amount: BigDecimal,
)
}

View File

@@ -0,0 +1,17 @@
package ltd.lulz.model
import jakarta.validation.constraints.DecimalMin
import java.math.BigDecimal
import java.util.UUID
import ltd.lulz.annotation.SenderReceiver
object Transfer {
@SenderReceiver
data class Request(
val account: UUID,
val receiver: UUID,
@field:DecimalMin(value = "0.01")
val amount: BigDecimal,
)
}

View File

@@ -2,7 +2,9 @@ package ltd.lulz.service
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigDecimal.ZERO
import java.util.UUID import java.util.UUID
import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.AccountEntity import ltd.lulz.model.AccountEntity
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -23,4 +25,24 @@ class TransactionService(
.map { it.copy(amount = it.amount + amount) } .map { it.copy(amount = it.amount + amount) }
.doOnNext { log.trace { "Deposited $amount to account ${it.id}" } } .doOnNext { log.trace { "Deposited $amount to account ${it.id}" } }
.flatMap { accountService.save(it) } .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 to 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,113 @@
package ltd.lulz
import java.math.BigDecimal
import java.util.UUID
import ltd.lulz.model.Account
import ltd.lulz.test.container.PostgresTestContainer
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.http.HttpStatus.OK
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class AccountEndpoints {
@InjectSoftAssertions
lateinit var softly: SoftAssertions
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Test
fun `create account - success add account to db`() {
// given
val request = Account.Request(
name = "user",
amount = BigDecimal.valueOf(10.01),
)
// when
val result = webClient.post()
.uri("/account")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(CREATED)
.expectBody<Account.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id?.version()).isEqualTo(7)
softly.assertThat(it.responseBody?.name).isEqualTo("user")
softly.assertThat(it.responseBody?.amount).isEqualTo("10.01")
}
}
@Test
fun `create account - fail amount to small`() {
// given
val request = Account.Request(
name = "user",
amount = BigDecimal.valueOf(0.009),
)
// when
val result = webClient.post()
.uri("/account")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(BAD_REQUEST)
}
@Test
fun `get balance - success get balance from db`() {
// given
val user1 = UUID.fromString("00000000-0000-7000-0000-000000000001")
// when
val result = webClient.get()
.uri("/balance/account-$user1")
.exchange()
// then
result.expectStatus().isEqualTo(OK)
.expectBody<Account.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id).isEqualTo(user1)
softly.assertThat(it.responseBody?.name).isEqualTo("user 1")
softly.assertThat(it.responseBody?.amount).isEqualTo("10.00")
}
}
@Test
fun `get balance - fail account not found`() {
// when
val result = webClient.get()
.uri("/balance/account-00000000-0000-7000-0000-000000000000")
.exchange()
// then
result.expectStatus().isEqualTo(NOT_FOUND)
}
}

View File

@@ -0,0 +1,293 @@
package ltd.lulz
import java.math.BigDecimal
import java.util.UUID
import ltd.lulz.model.Transaction
import ltd.lulz.model.Transfer
import ltd.lulz.test.container.PostgresTestContainer
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.HttpStatus.NOT_ACCEPTABLE
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.test.web.reactive.server.WebTestClient
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class TransactionEndpoints {
@InjectSoftAssertions
lateinit var softly: SoftAssertions
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Nested
inner class DepositTest {
@Test
fun `deposit - success`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/deposit")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(CREATED)
}
@Test
fun `deposit - fail amount to small`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
amount = BigDecimal.valueOf(0.009),
)
// when
val result = webClient.post()
.uri("/deposit")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(BAD_REQUEST)
}
@Test
fun `deposit - fail account not found`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000000"),
amount = BigDecimal.valueOf(0.01),
)
// when
val result = webClient.post()
.uri("/deposit")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_FOUND)
}
}
@Nested
inner class WithdrawalTest {
@Test
fun `deposit - success`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/withdrawal")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(CREATED)
}
@Test
fun `deposit - fail amount to small`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
amount = BigDecimal.valueOf(0.009),
)
// when
val result = webClient.post()
.uri("/withdrawal")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(BAD_REQUEST)
}
@Test
fun `deposit - fail account not found`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000000"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/withdrawal")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_FOUND)
}
@Test
fun `deposit - fail insufficient funds`() {
// given
val request = Transaction.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
amount = BigDecimal.valueOf(100.00),
)
// when
val result = webClient.post()
.uri("/withdrawal")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_ACCEPTABLE)
}
}
@Nested
inner class TransferTest {
@Test
fun `deposit - success`() {
// given
val request = Transfer.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
receiver = UUID.fromString("00000000-0000-7000-0000-000000000002"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/transfer")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(CREATED)
}
@Test
fun `deposit - fail same account`() {
// given
val request = Transfer.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
receiver = UUID.fromString("00000000-0000-7000-0000-000000000001"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/transfer")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(BAD_REQUEST)
}
@Test
fun `deposit - fail amount to small`() {
// given
val request = Transfer.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
receiver = UUID.fromString("00000000-0000-7000-0000-000000000002"),
amount = BigDecimal.valueOf(0.009),
)
// when
val result = webClient.post()
.uri("/transfer")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(BAD_REQUEST)
}
@Test
fun `deposit - fail account not found`() {
// given
val request = Transfer.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000000"),
receiver = UUID.fromString("00000000-0000-7000-0000-000000000002"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/transfer")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_FOUND)
}
@Test
fun `deposit - fail receiver not found`() {
// given
val request = Transfer.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
receiver = UUID.fromString("00000000-0000-7000-0000-000000000000"),
amount = BigDecimal.valueOf(1.00),
)
// when
val result = webClient.post()
.uri("/transfer")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_FOUND)
}
@Test
fun `deposit - fail insufficient funds`() {
// given
val request = Transfer.Request(
account = UUID.fromString("00000000-0000-7000-0000-000000000001"),
receiver = UUID.fromString("00000000-0000-7000-0000-000000000002"),
amount = BigDecimal.valueOf(100.00),
)
// when
val result = webClient.post()
.uri("/transfer")
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_ACCEPTABLE)
}
}
}

View File

@@ -0,0 +1,3 @@
INSERT INTO public.accounts(id, name, amount)
VALUES ('00000000-0000-7000-0000-000000000001'::uuid, 'user 1', 10.0),
('00000000-0000-7000-0000-000000000002'::uuid, 'user 2', 10.0);

View File

@@ -0,0 +1 @@
TRUNCATE TABLE accounts CASCADE;

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS public.accounts
(
id UUID DEFAULT uuidv7(),
name VARCHAR(50) NOT NULL,
amount NUMERIC(19, 2) NOT NULL,
CONSTRAINT pk_contact_types PRIMARY KEY (id)
);

View File

@@ -0,0 +1,305 @@
package ltd.lulz.controller
import io.mockk.every
import io.mockk.mockk
import java.math.BigDecimal
import java.util.UUID
import ltd.lulz.controller.AccountControllerTest.Companion.name
import ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.AccountEntity
import ltd.lulz.model.Transaction
import ltd.lulz.model.Transfer
import ltd.lulz.service.TransactionService
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus.NOT_ACCEPTABLE
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.reactive.server.WebTestClient
import reactor.core.publisher.Mono
@Suppress("ReactiveStreamsUnusedPublisher")
class TransactionControllerTest {
companion object {
val amount: BigDecimal = BigDecimal.valueOf(0.01)
val uuid: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
val receiver = UUID.fromString("00000000-0000-0000-0000-000000000001")
}
private val transactionService: TransactionService = mockk()
private lateinit var webTestClient: WebTestClient
@BeforeEach
fun setUp() {
webTestClient = WebTestClient.bindToController(TransactionController(transactionService)).build()
}
@Test
fun `deposit success`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.deposit(any(), any()) } returns Mono.just(
AccountEntity(id = uuid, name = name, amount = amount),
)
// when
val result = webTestClient.post()
.uri("/deposit")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isCreated
}
@Test
fun `deposit fail amount to small`() {
// given
val request = Transaction.Request(uuid, BigDecimal.valueOf(0.009))
// when
val result = webTestClient.post()
.uri("/deposit")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
@Test
fun `deposit default error`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.deposit(any(), any()) } returns Mono.error(RuntimeException())
// when
val result = webTestClient.post()
.uri("/deposit")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().is5xxServerError
}
@Test
fun `deposit account not found`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.deposit(any(), any()) } returns Mono.error(AccountNotFoundException())
// when
val result = webTestClient.post()
.uri("/deposit")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isNotFound
}
@Test
fun `withdrawal success`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.withdrawal(any(), any()) } returns Mono.just(
AccountEntity(id = uuid, name = name, amount = amount),
)
// when
val result = webTestClient.post()
.uri("/withdrawal")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isCreated
}
@Test
fun `withdrawal fail amount to small`() {
// given
val request = Transaction.Request(uuid, BigDecimal.valueOf(0.009))
// when
val result = webTestClient.post()
.uri("/withdrawal")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
@Test
fun `withdrawal insufficient Funds`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.withdrawal(any(), any()) } returns Mono.error(InsufficientFundsException())
// when
val result = webTestClient.post()
.uri("/withdrawal")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_ACCEPTABLE)
}
@Test
fun `withdrawal account not found`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.withdrawal(any(), any()) } returns Mono.error(AccountNotFoundException())
// when
val result = webTestClient.post()
.uri("/withdrawal")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isNotFound
}
@Test
fun `withdrawal default error`() {
// given
val request = Transaction.Request(uuid, amount)
every { transactionService.withdrawal(any(), any()) } returns Mono.error(RuntimeException())
// when
val result = webTestClient.post()
.uri("/withdrawal")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().is5xxServerError
}
@Test
fun `transfer success`() {
// given
val request = Transfer.Request(uuid, receiver, amount)
every { transactionService.transfer(any(), any(), any()) } returns Mono.empty()
// when
val result = webTestClient.post()
.uri("/transfer")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isCreated
}
@Test
fun `transfer fail same accounts`() {
// given
val request = Transfer.Request(uuid, uuid, amount)
every { transactionService.transfer(any(), any(), any()) } returns Mono.empty()
// when
val result = webTestClient.post()
.uri("/transfer")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
@Test
fun `transfer fail amount to small`() {
// given
val request = Transfer.Request(uuid, receiver, BigDecimal.valueOf(0.009))
// when
val result = webTestClient.post()
.uri("/transfer")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
@Test
fun `transfer insufficient Funds`() {
// given
val request = Transfer.Request(uuid, receiver, amount)
every { transactionService.transfer(any(), any(), any()) } returns Mono.error(InsufficientFundsException())
// when
val result = webTestClient.post()
.uri("/transfer")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(NOT_ACCEPTABLE)
}
@Test
fun `transfer account not found`() {
// given
val request = Transfer.Request(uuid, receiver, amount)
every { transactionService.transfer(any(), any(), any()) } returns Mono.error(AccountNotFoundException())
// when
val result = webTestClient.post()
.uri("/transfer")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isNotFound
}
@Test
fun `transfer default error`() {
// given
val request = Transfer.Request(uuid, receiver, amount)
every { transactionService.transfer(any(), any(), any()) } returns Mono.error(RuntimeException())
// when
val result = webTestClient.post()
.uri("/transfer")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().is5xxServerError
}
}

View File

@@ -7,9 +7,11 @@ import io.mockk.verify
import java.math.BigDecimal import java.math.BigDecimal
import java.util.UUID import java.util.UUID
import ltd.lulz.exception.AccountNotFoundException import ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.exception.InsufficientFundsException
import ltd.lulz.model.AccountEntity import ltd.lulz.model.AccountEntity
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.test.StepVerifier import reactor.test.StepVerifier
@@ -18,9 +20,12 @@ import reactor.test.StepVerifier
class TransactionServiceTest { class TransactionServiceTest {
companion object { companion object {
val name: String = "some name" val accountName: String = "some name"
val amount: BigDecimal = BigDecimal.valueOf(1.01) val accountAmount: BigDecimal = BigDecimal.valueOf(1.01)
val uuid: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000") 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 val accountService: AccountService = mockk()
@@ -31,6 +36,9 @@ class TransactionServiceTest {
service = TransactionService(accountService) service = TransactionService(accountService)
} }
@Nested
inner class Deposit {
@Test @Test
fun `deposit to account - success`() { fun `deposit to account - success`() {
// given // given
@@ -38,17 +46,17 @@ class TransactionServiceTest {
val capture = slot<UUID>() val capture = slot<UUID>()
every { accountService.getForUpdateById(capture(capture)) } every { accountService.getForUpdateById(capture(capture)) }
.answers { Mono.just(AccountEntity(capture.captured, name, amount)) } .answers { Mono.just(AccountEntity(capture.captured, accountName, accountAmount)) }
val entity = slot<AccountEntity>() val entity = slot<AccountEntity>()
every { accountService.save(capture(entity)) } every { accountService.save(capture(entity)) }
.answers { Mono.just(entity.captured) } .answers { Mono.just(entity.captured) }
// when stepped // when stepped
StepVerifier.create(service.deposit(uuid, deposit)) StepVerifier.create(service.deposit(accountUuid, deposit))
.assertNext { result -> .assertNext { result ->
assertThat(result.id).isEqualTo(uuid) assertThat(result.id).isEqualTo(accountUuid)
assertThat(result.name).isEqualTo(name) assertThat(result.name).isEqualTo(accountName)
assertThat(result.amount).isEqualTo(amount + deposit) assertThat(result.amount).isEqualTo(accountAmount + deposit)
} }
.verifyComplete() .verifyComplete()
@@ -64,11 +72,166 @@ class TransactionServiceTest {
every { accountService.getForUpdateById(any()) } returns Mono.error(AccountNotFoundException()) every { accountService.getForUpdateById(any()) } returns Mono.error(AccountNotFoundException())
// when stepped // when stepped
StepVerifier.create(service.deposit(uuid, deposit)) StepVerifier.create(service.deposit(accountUuid, deposit))
.expectError(AccountNotFoundException::class.java) .expectError(AccountNotFoundException::class.java)
.verify() .verify()
verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) } verify(exactly = 1) { accountService.getForUpdateById(any(UUID::class)) }
verify(exactly = 0) { accountService.save(any()) } 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()) }
}
}
} }