20 Commits

Author SHA1 Message Date
4f95265872 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 18:12:12 +02:00
e9bbac8296 test integration
- add TransactionEndpoints
- add AccountEndpoints
- add sql files for test
- add dependencies
2025-09-14 18:12:12 +02:00
c8897f7fc0 update TransactionController with transfer 2025-09-14 18:12:11 +02:00
fe3283f5f1 add Transfer 2025-09-14 18:12:11 +02:00
610b259832 update TransactionService with transfer 2025-09-14 18:12:11 +02:00
4833eebdd0 update TransactionController with withdrawal 2025-09-14 18:12:11 +02:00
83375cf9cc update TransactionService with withdrawal 2025-09-14 18:12:11 +02:00
0454f112e7 add InsufficientFundsException 2025-09-14 18:12:11 +02:00
04d102c7e1 add TransactionController 2025-09-14 18:12:11 +02:00
b547d318b3 add Transaction 2025-09-14 18:12:11 +02:00
37dde70194 add TransactionService 2025-09-14 18:12:11 +02:00
e46bf55232 add account not found exception
- update balance in AccountController with onErrorResume
- update AccountService
  - update getForUpdateById with switchIfEmpty
  - update getById with switchIfEmpty
- add AccountNotFoundException
2025-09-14 18:12:11 +02:00
25f534b6e3 update AccountService with getForUpdateById and save 2025-09-14 18:12:11 +02:00
c96604ae79 update AccountController with balance 2025-09-14 18:12:11 +02:00
7ce1b15bca update AccountService with getById 2025-09-14 18:12:11 +02:00
85c140a815 add AccountController 2025-09-14 18:12:11 +02:00
dfadf203de add AccountUtil.kt
- AccountEntity toResponse
- Account Request toEntity
2025-09-14 18:12:11 +02:00
79357e4f4d add AccountService 2025-09-14 18:12:11 +02:00
12bc74c1e6 add account data
- AccountRepository
- AccountEntity
- 001-accounts.sql
- Account with Request and Response
2025-09-14 18:12:11 +02:00
83897285e4 project setup
- update README.md
- setup tls
  - update gradle.properties with docker tls ports
  - update application.yml
    - disable tls in develop
    - set docker values
    - add default tls values
  - add keystore.p12
- setup postgres
  - update application.yml with defualt values
  - add 000-initizalise.sql
  - add postgres dependencies and config
  - add docker compose development
- add actuator.http
- add http environment
- update name and readme for service
2025-09-14 18:12:11 +02:00
39 changed files with 1906 additions and 10 deletions

View File

@@ -9,10 +9,10 @@ insert_final_newline = true
max_line_length = 120
tab_width = 4
[*.{json,md,txt,xml,yaml,yml}]
[*.{http,json,md,txt,xml,yaml,yml}]
max_line_length = 1024
[*.{json,xml,yaml,yml}]
[*.{http,json,xml,yaml,yml}]
indent_size = 2
tab_width = 2

View File

@@ -1,12 +1,20 @@
# {service}
# Basic Banking
{description}
This Monolith is pretend to be different services, it also Postgres 18rc1 to have access to UUIDv7.
## Properties For Deployment
| Name | Required | Information |
|------------------------|:--------:|-------------------------|
|-------------------------------|:--------:|-------------------------|
| spring.profiles.active | ✔ | Spring Boot environment |
| spring.r2dbc.url | ✔ | Postgres host url |
| spring.r2dbc.username | ✓ | Postgres username |
| spring.r2dbc.password | ✱ | Postgres password |
| server.port | | HTTP port |
| server.ssl.enabled | | HTTP Enable SSL |
| server.ssl.key-store | ✗ | HTTP Keystore |
| server.ssl.key-store-type | | HTTP Cert Type |
| server.ssl.key-store-password | ✱ | HTTP Cert Pass |
*Required:*
@@ -14,6 +22,10 @@
- *✗ mounted file.*
- *✱ need to be stored as secret.*
## Development
Use `development-compose.yml` to set up needed external dependencies.
## Releasing Service
Run release pipeline from `master` branch.

View File

@@ -12,14 +12,26 @@ dependencies {
implementation(aa.kotlin.reflect)
implementation(aa.kotlinx.coroutines)
implementation(aa.springboot.starter.actuator)
implementation(aa.springboot.starter.r2dbc)
implementation(aa.springboot.starter.validation)
implementation(aa.springboot.starter.webflux)
runtimeOnly(aa.postgresql)
runtimeOnly(aa.postgresql.r2dbc)
testImplementation(aa.kotlin.junit5)
testImplementation(aa.kotlinx.coroutines.test)
testImplementation(aa.mockk)
testImplementation(aa.reactor.test)
testImplementation(aa.springboot.starter.test)
testRuntimeOnly(aa.junit.platform.launcher)
testIntegrationImplementation(aa.library.test.integration)
testIntegrationImplementation(aa.springboot.starter.test)
testIntegrationRuntimeOnly(aa.junit.platform.launcher)
}
group = "ltd.lulz"
description = "service template"
description = "service basic banking"

24
development-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
name: develop
networks:
develop:
name: develop
external: true
volumes:
postgres:
services:
postgres:
image: postgres:18rc1-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- 5432:5432
networks:
- develop
volumes:
- postgres:/var/lib/postgresql/data

View File

@@ -1,2 +1,5 @@
version=0.1.0-SNAPSHOT
catalog=0.1.0
catalog=0.2.0-SNAPSHOT
docker.port.expose=8443
container.port.expose=8443
container.port.host=8443

11
http/account.http Normal file
View File

@@ -0,0 +1,11 @@
### Create Account
POST {{url}}/account
Content-Type: application/json
{
"name": "account name",
"amount": -1.11
}
### Create Account
GET {{url}}/balance/account-00000000-0000-0000-0000-000000000000

3
http/actuator.http Normal file
View File

@@ -0,0 +1,3 @@
### Actuator
GET {{url}}/actuator

11
http/http-client.env.json Normal file
View File

@@ -0,0 +1,11 @@
{
"develop": {
"url": "http://localhost:8080"
},
"docker": {
"url": "https://localhost:8443"
},
"kubernetes": {
"url": "https://10.0.0.0"
}
}

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
}

20
instructions.md Normal file
View File

@@ -0,0 +1,20 @@
# Senior Engineer Test
### **Develop a service that simulates basic banking operations in a programming language of your choice. This service will manage accounts, process deposits, withdrawals, and transfers between accounts.**
### The system should be designed reflecting real-world constraints of a bank.
## Requirements:
1. A class or set of functions that allow:
* Account creation: Allow users to create an account with an initial deposit.
* Deposit: Enable users to deposit money into their account.
* Withdrawal: Allow users to withdraw money from their account, ensuring that overdrafts are not allowed.
* Transfer: Enable transferring funds between accounts.
* Account balance: Provide the ability to check the account balance.
2. Database:
* In-memory data storage will suffice, no need to have a database alongside the project, but you can add one at your discretion
The word “service” here is used in a “software component/module” rather “deployable unit with an API” sense, no need to provide API for it.
## Feel free to take as along as you need to complete the exercise. This will be used as a base for a follow-up pair programming session.

View File

@@ -34,4 +34,4 @@ pluginManagement.repositories {
gradlePluginPortal()
}
rootProject.name = "service"
rootProject.name = "basic-banking"

View File

@@ -0,0 +1,64 @@
-- Role: role_administrator
-- DROP ROLE IF EXISTS role_administrator;
CREATE ROLE role_owner;
-- Role: role_service
-- DROP ROLE IF EXISTS role_service;
CREATE ROLE role_service;
-- Role: role_maintainer
-- DROP ROLE IF EXISTS role_maintainer;
CREATE ROLE role_maintainer;
-- Role: support_role
-- DROP ROLE IF EXISTS support_role;
CREATE ROLE role_support;
-- User: services
-- DROP USER IF EXISTS services;
CREATE USER service WITH PASSWORD 'password';
-- Assign role to the user
GRANT role_service TO service;
-- User: user_maintainer
-- DROP USER IF EXISTS user_maintainer;
CREATE USER user_maintainer WITH PASSWORD 'password';
-- Assign role to the user
GRANT role_maintainer TO user_maintainer;
-- User: user_support
-- DROP USER IF EXISTS user_support;
CREATE USER user_support WITH PASSWORD 'password';
-- Assign role to the user
GRANT role_support TO user_support;
-- Database: basic_banking
-- DROP DATABASE IF EXISTS basic_banking;
CREATE DATABASE basic_banking
WITH
OWNER = role_owner
ENCODING = 'UTF8'
TABLESPACE = pg_default
CONNECTION LIMIT = -1
IS_TEMPLATE = False;
COMMENT ON DATABASE basic_banking
IS 'Database for basic banking, registered account and transactions.';

View File

@@ -0,0 +1,21 @@
-- Table: public.accounts
DROP TABLE IF EXISTS public.accounts;
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)
);
ALTER TABLE IF EXISTS public.accounts
OWNER to role_owner;
-- Revoke all permissions from existing roles
REVOKE ALL ON TABLE public.accounts FROM role_service, role_support;
-- Grant appropriate permissions
GRANT ALL ON TABLE public.accounts TO role_maintainer;
GRANT SELECT, INSERT, UPDATE ON TABLE public.accounts TO role_service;
GRANT SELECT ON TABLE public.accounts TO role_support;

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,44 @@
package ltd.lulz.controller
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.validation.Valid
import java.util.UUID
import ltd.lulz.model.Account
import ltd.lulz.service.AccountService
import ltd.lulz.util.toEntity
import ltd.lulz.util.toResponse
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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 {}
@RestController
@Validated
class AccountController(
private val accountService: AccountService,
) {
@PostMapping("/account")
@ResponseStatus(CREATED)
fun create(
@Valid @RequestBody request: Account.Request,
): Mono<Account.Response> = accountService.create(request.toEntity())
.map { it.toResponse() }
@GetMapping("/balance/account-{account}")
fun balance(
@PathVariable account: UUID,
): Mono<Account.Response> = accountService.getById(account)
.map { it.toResponse() }
.onErrorResume { Mono.error(ResponseStatusException(NOT_FOUND)) }
.doOnError { log.debug { "account $account not found for balance" } }
}

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 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,22 @@
package ltd.lulz.model
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.NotEmpty
import java.math.BigDecimal
import java.util.UUID
object Account {
data class Request(
@field:NotEmpty
val name: String,
@field:DecimalMin(value = "0.01")
val amount: BigDecimal,
)
data class Response(
val id: UUID,
val name: String,
val amount: BigDecimal,
)
}

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.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

@@ -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<AccountEntity, UUID> {
@Query("SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT")
fun findByIdForUpdate(id: UUID): Mono<AccountEntity>
}

View File

@@ -0,0 +1,43 @@
package ltd.lulz.service
import io.github.oshai.kotlinlogging.KotlinLogging
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(
entity: AccountEntity,
): Mono<AccountEntity> = accountRepository
.save(entity)
.doOnNext { log.debug { "account created with id: ${it.id}" } }
fun getById(
id: UUID,
): Mono<AccountEntity> = accountRepository.findById(id)
.doOnNext { log.debug { "found account by id: ${it.id}" } }
.switchIfEmpty(Mono.error(AccountNotFoundException()))
@Transactional
fun getForUpdateById(
id: UUID,
): Mono<AccountEntity> = accountRepository.findByIdForUpdate(id)
.doOnNext { log.trace { "account with id: ${it.id} locked for update" } }
.switchIfEmpty(Mono.error(AccountNotFoundException()))
@Transactional
fun save(
entity: AccountEntity,
): Mono<AccountEntity> = accountRepository.save(entity)
.doOnNext { log.trace { "account with id: ${it.id} saved" } }
}

View File

@@ -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<AccountEntity> = 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<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,18 @@
package ltd.lulz.util
import java.math.RoundingMode.DOWN
import ltd.lulz.model.Account
import ltd.lulz.model.AccountEntity
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
import org.springframework.web.server.ResponseStatusException
fun Account.Request.toEntity(): AccountEntity = AccountEntity(
name = name,
amount = amount.setScale(2, DOWN),
)
fun AccountEntity.toResponse(): Account.Response = Account.Response(
id = id ?: throw ResponseStatusException(INTERNAL_SERVER_ERROR),
name = name,
amount = amount,
)

View File

@@ -24,6 +24,13 @@ management:
exposure:
include: "health,info"
server:
port: 8443
ssl:
enabled: true
key-store: classpath:cert/keystore.p12
key-store-type: PKCS12
---
###########################
### Develop environment ###
@@ -32,6 +39,16 @@ spring:
config:
activate:
on-profile: develop
r2dbc:
url: r2dbc:postgresql://localhost:5432/basic_banking
username: service
password: password
server:
port: 8080
ssl:
enabled: false
# key-store-password: password
---
##########################
@@ -41,6 +58,14 @@ spring:
config:
activate:
on-profile: docker
r2dbc:
url: r2dbc:postgresql://postgres:5432/basic_banking
username: service
password: password
server:
ssl:
key-store-password: password
---
##############################
@@ -50,3 +75,5 @@ spring:
config:
activate:
on-profile: kubernetes
r2dbc:
url: r2dbc:postgresql://postgres:5432/basic_banking

Binary file not shown.

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,132 @@
package ltd.lulz.controller
import io.mockk.every
import io.mockk.mockk
import java.math.BigDecimal
import java.util.UUID
import ltd.lulz.exception.AccountNotFoundException
import ltd.lulz.model.Account
import ltd.lulz.model.AccountEntity
import ltd.lulz.service.AccountService
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.reactive.server.WebTestClient
import reactor.core.publisher.Mono
@Suppress("MayBeConstant", "ReactiveStreamsUnusedPublisher")
class AccountControllerTest {
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 val accountService: AccountService = mockk()
private lateinit var webTestClient: WebTestClient
@BeforeEach
fun setUp() {
webTestClient = WebTestClient.bindToController(AccountController(accountService)).build()
}
@Nested
inner class CreateAccount {
@Test
fun `create account success`() {
// given
val request = Account.Request(name, amount)
every { accountService.create(any()) } returns Mono.just(
AccountEntity(id = uuid, name = name, amount = amount),
)
// when
val result = webTestClient.post()
.uri("/account")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isCreated
.expectBody()
.jsonPath("$.id").isEqualTo(uuid.toString())
.jsonPath("$.name").isEqualTo(name)
.jsonPath("$.amount").isEqualTo(amount.toString())
}
@Test
fun `create account fail no name`() {
// given
val request = Account.Request("", amount)
// when
val result = webTestClient.post()
.uri("/account")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
@Test
fun `create account fail zero or less amount`() {
// given
val request = Account.Request("name", BigDecimal.valueOf(0))
// when
val result = webTestClient.post()
.uri("/account")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
}
@Nested
inner class AccountBalance {
@Test
fun `account balance success`() {
// given
every { accountService.getById(any()) } returns Mono.just(
AccountEntity(id = uuid, name = name, amount = amount),
)
// when
val result = webTestClient.get()
.uri("/balance/account-$uuid")
.exchange()
// then
result.expectStatus().isOk
.expectBody()
.jsonPath("$.id").isEqualTo(uuid.toString())
.jsonPath("$.name").isEqualTo(name)
.jsonPath("$.amount").isEqualTo(amount.toString())
}
@Test
fun `account balance fail`() {
// given
every { accountService.getById(any()) } returns Mono.error(AccountNotFoundException())
// when
val result = webTestClient.get()
.uri("/balance/account-$uuid")
.exchange()
// then
result.expectStatus().isNotFound
}
}
}

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

@@ -0,0 +1,138 @@
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 entity = AccountEntity(name = name, amount = amount)
val capture = slot<AccountEntity>()
every { repository.save(capture(capture)) } answers { Mono.just(capture.captured.copy(id = uuid)) }
// when stepped
StepVerifier.create(service.create(entity))
.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<UUID>()
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<UUID>()
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<AccountEntity>()
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()) }
}
}

View File

@@ -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<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.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<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()) }
}
}
}

View File

@@ -0,0 +1,75 @@
package ltd.lulz.util
import java.math.BigDecimal
import java.util.UUID
import kotlin.test.Test
import ltd.lulz.model.Account
import ltd.lulz.model.AccountEntity
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.assertThrows
import org.springframework.web.server.ResponseStatusException
@Suppress("MayBeConstant")
class AccountUtilKtTest {
companion object {
val name: String = "some name"
val amount: BigDecimal = BigDecimal.valueOf(0.01)
val uuid: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
}
@Test
fun `account request to account entity`() {
// given
val request = Account.Request(name, amount)
// when
val result = request.toEntity()
// then
assertThat(result.id).isNull()
assertThat(result.name).isEqualTo(name)
assertThat(result.amount).isEqualTo(amount)
}
@Test
fun `account request to account entity cut decimals`() {
// given
val request = Account.Request(name, BigDecimal.valueOf(0.0099))
// when
val result = request.toEntity()
// then
assertThat(result.id).isNull()
assertThat(result.name).isEqualTo(name)
assertThat(result.amount.toString()).isEqualTo("0.00")
}
@Test
fun `account entity to account response`() {
// given
val entity = AccountEntity(uuid, name, amount)
// when
val result = entity.toResponse()
// then
assertThat(result.id).isEqualTo(uuid)
assertThat(result.name).isEqualTo(name)
}
@Test
fun `account entity to account response - fail`() {
// given
val entity = AccountEntity(null, name, amount)
// when
val exception = assertThrows<ResponseStatusException> {
entity.toResponse()
}
// then
assertThat(exception.message).isEqualTo("500 INTERNAL_SERVER_ERROR")
}
}