diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index bcb76f4..b59d35a 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -8,59 +8,75 @@ import com.example.settlement.Charge import com.example.settlement.Collection import com.example.settlement.OrderId import com.example.settlement.SettlementInstruction +import dev.forkhandles.result4k.* import java.math.RoundingMode.DOWN import java.math.RoundingMode.UP import java.util.Currency -abstract class Auction { - abstract val rules: AuctionRules - - abstract var seller: UserId - abstract var description: String - abstract var currency: Currency - abstract var reserve: MonetaryAmount - abstract var commission: MonetaryAmount - abstract var chargePerBid: MonetaryAmount - abstract var id: AuctionId - abstract var state: AuctionState - abstract var bids: MutableList - abstract var winner: AuctionWinner? - - fun placeBid(buyer: UserId, bid: Money) { +sealed class AuctionError() { + abstract val message: String + data class BadRequest(override val message: String) : AuctionError() + data class WrongState(override val message: String) : AuctionError() +} + +fun Result.asExceptionFailure(): Result = + mapFailure { + it.toException() + } + +fun AuctionError.toException(): RuntimeException = when (this) { + is AuctionError.BadRequest -> BadRequestException(message) + is AuctionError.WrongState -> WrongStateException(message) +} + +data class Auction( + val rules: AuctionRules, + val seller: UserId, + val description: String, + val currency: Currency, + val reserve: MonetaryAmount, + val commission: MonetaryAmount, + val chargePerBid: MonetaryAmount, + val id: AuctionId, + val state: AuctionState, + val bids: List, + val winner: AuctionWinner?, +) { + + fun placeBid( + buyer: UserId, + bid: Money, + ): Result { if (buyer == seller) { - throw BadRequestException("shill bidding detected by $seller") + return Failure(AuctionError.BadRequest("shill bidding detected by $seller")) } if (bid.currency != currency) { - throw BadRequestException("bid in wrong currency, should be $currency") + return Failure(AuctionError.BadRequest("bid in wrong currency, should be $currency")) } if (bid.amount == ZERO) { - throw BadRequestException("zero bid") + return Failure(AuctionError.BadRequest("zero bid")) } if (state != open) { - throw WrongStateException("auction $id is closed") + return Failure(AuctionError.WrongState("auction $id is closed")) } - - bids.add(Bid(buyer, bid.amount)) - } - - fun close() { - state = closed - winner = decideWinner() + + return Success(this.copy(bids = bids + Bid(buyer, bid.amount))) } - - protected abstract fun decideWinner(): AuctionWinner? - - fun settled() { + + fun close(): Auction = copy(state = closed, winner = decideWinner()) + + protected fun decideWinner(): AuctionWinner? = rules.decideWinner(this) + + fun settled(): Result4k { if (state == open) { - throw WrongStateException("auction $id not closed") + return Failure(WrongStateException("auction $id not closed")) } - - state = AuctionState.settled + return Success(copy(state = AuctionState.settled)) } - + fun settlement(): SettlementInstruction? { val winner = this.winner ?: return null - + val commissionCharges = when { commission == ZERO -> emptyList() else -> listOf( @@ -71,7 +87,7 @@ abstract class Auction { ) ) } - + val bidCharges = when { chargePerBid == ZERO -> emptyList() else -> bids @@ -88,7 +104,7 @@ abstract class Auction { } .sortedBy { it.from.value } } - + return SettlementInstruction( order = OrderId(id), collect = Collection( @@ -99,7 +115,7 @@ abstract class Auction { charges = commissionCharges + bidCharges ) } - + override fun toString(): String { return "${this::class.simpleName}(seller=$seller, description='$description', currency=$currency, reserve=$reserve, commission=$commission, chargePerBid=$chargePerBid, id=$id, state=$state, rules=$rules, winner=$winner)" } diff --git a/src/main/kotlin/com/example/auction/model/AuctionRules.kt b/src/main/kotlin/com/example/auction/model/AuctionRules.kt index 02a0573..161fed4 100644 --- a/src/main/kotlin/com/example/auction/model/AuctionRules.kt +++ b/src/main/kotlin/com/example/auction/model/AuctionRules.kt @@ -1,5 +1,39 @@ package com.example.auction.model enum class AuctionRules { - Blind, Vickrey, Reverse -} + Blind { + override fun decideWinner(auction: Auction): AuctionWinner? = + auction.bids + .associateBy { it.buyer } + .values + .maxByOrNull { it.amount } + ?.takeIf { it.amount >= auction.reserve }?.toWinner() + }, + Vickrey { + override fun decideWinner(auction: Auction): AuctionWinner? = + auction.bids + .associateBy { it.buyer } + .values + .sortedByDescending { it.amount } + .take(2) + .run { + when { + isEmpty() -> null + last().amount < auction.reserve -> null + else -> AuctionWinner(first().buyer, last().amount) + } + } + }, + Reverse { + override fun decideWinner(auction: Auction): AuctionWinner? = + auction.bids + .filter { it.amount >= auction.reserve } + .groupBy { it.amount } + .values + .filter { it.size == 1 } + .map { it.single() } + .minByOrNull { it.amount }?.toWinner() + }; + + abstract fun decideWinner(auction: Auction): AuctionWinner? +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/model/Bid.kt b/src/main/kotlin/com/example/auction/model/Bid.kt index 9980c96..daf91a0 100644 --- a/src/main/kotlin/com/example/auction/model/Bid.kt +++ b/src/main/kotlin/com/example/auction/model/Bid.kt @@ -2,14 +2,9 @@ package com.example.auction.model import com.example.pii.UserId -class Bid( - var buyer: UserId, - var amount: MonetaryAmount, - - var id: BidId = BidId.NONE -) { - override fun toString(): String { - return "Bid(buyer=$buyer, amount=$amount, id=$id)" - } -} +data class Bid( + val buyer: UserId, + val amount: MonetaryAmount, + val id: BidId = BidId.NONE +) diff --git a/src/main/kotlin/com/example/auction/model/BlindAuction.kt b/src/main/kotlin/com/example/auction/model/BlindAuction.kt index 79c077c..6423d88 100644 --- a/src/main/kotlin/com/example/auction/model/BlindAuction.kt +++ b/src/main/kotlin/com/example/auction/model/BlindAuction.kt @@ -5,25 +5,27 @@ import com.example.auction.model.AuctionState.open import com.example.pii.UserId import java.util.Currency -class BlindAuction( - override var seller: UserId, - override var description: String, - override var currency: Currency, - override var reserve: MonetaryAmount, - override var commission: MonetaryAmount = MonetaryAmount.ZERO, - override var chargePerBid: MonetaryAmount = MonetaryAmount.ZERO, - override var id: AuctionId = AuctionId.NONE, - override var bids: MutableList = mutableListOf(), - override var state: AuctionState = open, - override var winner: AuctionWinner? = null -) : Auction() { - override val rules = Blind - - override fun decideWinner(): AuctionWinner? { - return bids - .associateBy { it.buyer } - .values - .maxByOrNull { it.amount } - ?.takeIf { it.amount >= reserve }?.toWinner() - } -} +fun blindAuction( + seller: UserId, + description: String, + currency: Currency, + reserve: MonetaryAmount, + commission: MonetaryAmount = MonetaryAmount.ZERO, + chargePerBid: MonetaryAmount = MonetaryAmount.ZERO, + id: AuctionId = AuctionId.NONE, + bids: MutableList = mutableListOf(), + state: AuctionState = open, + winner: AuctionWinner? = null, +) = Auction( + seller = seller, + description = description, + currency = currency, + reserve = reserve, + commission = commission, + chargePerBid = chargePerBid, + id = id, + bids = bids, + state = state, + winner = winner, + rules = Blind +) diff --git a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt index 653b794..7b753c6 100644 --- a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt +++ b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt @@ -3,42 +3,29 @@ package com.example.auction.model import com.example.auction.model.AuctionRules.Reverse import com.example.auction.model.AuctionState.open import com.example.pii.UserId -import java.util.Currency +import java.util.* -class ReverseAuction( - override var seller: UserId, - override var description: String, - override var currency: Currency, - override var reserve: MonetaryAmount = MonetaryAmount.ZERO, - override var commission: MonetaryAmount = MonetaryAmount.ZERO, - override var chargePerBid: MonetaryAmount = MonetaryAmount.ZERO, - override var id: AuctionId = AuctionId.NONE, - override var bids: MutableList = mutableListOf(), - override var state: AuctionState = open, - override var winner: AuctionWinner? = null -) : Auction() { - override val rules = Reverse - - override fun decideWinner(): AuctionWinner? { - val bidsByAmount = mutableMapOf>() - for (bid in bids) if (bid.amount >= reserve) { - var bidGroup = bidsByAmount[bid.amount] - if (bidGroup == null) { - bidGroup = mutableListOf() - bidsByAmount[bid.amount] = bidGroup - } - bidGroup.add(bid) - } - - var lowestUniqueBid: Bid? = null - - for (bids in bidsByAmount.values) if (bids.size == 1) { - val bid = bids.single() - if (lowestUniqueBid == null || bid.amount < lowestUniqueBid.amount) { - lowestUniqueBid = bid - } - } - - return lowestUniqueBid?.toWinner() - } -} +fun reverseAuction( + seller: UserId, + description: String, + currency: Currency, + reserve: MonetaryAmount = MonetaryAmount.ZERO, + commission: MonetaryAmount = MonetaryAmount.ZERO, + chargePerBid: MonetaryAmount = MonetaryAmount.ZERO, + id: AuctionId = AuctionId.NONE, + bids: MutableList = mutableListOf(), + state: AuctionState = open, + winner: AuctionWinner? = null, +) = Auction( + seller = seller, + description = description, + currency = currency, + reserve = reserve, + commission = commission, + chargePerBid = chargePerBid, + id = id, + bids = bids, + state = state, + winner = winner, + rules = Reverse +) diff --git a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt index 3e8960e..ef444ab 100644 --- a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt +++ b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt @@ -5,32 +5,27 @@ import com.example.auction.model.AuctionState.open import com.example.pii.UserId import java.util.Currency -class VickreyAuction( - override var seller: UserId, - override var description: String, - override var currency: Currency, - override var reserve: MonetaryAmount, - override var commission: MonetaryAmount = MonetaryAmount.ZERO, - override var chargePerBid: MonetaryAmount = MonetaryAmount.ZERO, - override var id: AuctionId = AuctionId.NONE, - override var bids: MutableList = mutableListOf(), - override var state: AuctionState = open, - override var winner: AuctionWinner? = null -) : Auction() { - override val rules = Vickrey - - override fun decideWinner(): AuctionWinner? { - return bids - .associateBy { it.buyer } - .values - .sortedByDescending { it.amount } - .take(2) - .run { - when { - isEmpty() -> null - last().amount < reserve -> null - else -> AuctionWinner(first().buyer, last().amount) - } - } - } -} \ No newline at end of file +fun vickreyAuction( + seller: UserId, + description: String, + currency: Currency, + reserve: MonetaryAmount, + commission: MonetaryAmount = MonetaryAmount.ZERO, + chargePerBid: MonetaryAmount = MonetaryAmount.ZERO, + id: AuctionId = AuctionId.NONE, + bids: MutableList = mutableListOf(), + state: AuctionState = open, + winner: AuctionWinner? = null, +) = Auction( + seller = seller, + description = description, + currency = currency, + reserve = reserve, + commission = commission, + chargePerBid = chargePerBid, + id = id, + bids = bids, + state = state, + winner = winner, + rules = Vickrey +) diff --git a/src/main/kotlin/com/example/auction/repository/AuctionRepository.kt b/src/main/kotlin/com/example/auction/repository/AuctionRepository.kt index 277bfa4..f84fb24 100644 --- a/src/main/kotlin/com/example/auction/repository/AuctionRepository.kt +++ b/src/main/kotlin/com/example/auction/repository/AuctionRepository.kt @@ -6,8 +6,8 @@ import com.example.auction.model.AuctionId interface AuctionRepository { fun getAuction(id: AuctionId): Auction? - fun addAuction(auction: Auction) - fun updateAuction(auction: Auction) + fun addAuction(auction: Auction): Auction + fun updateAuction(auction: Auction): Auction fun listOpenAuctions(count: Int, after: AuctionId = AuctionId.NONE): List fun listForSettlement(count: Int, after: AuctionId = AuctionId.NONE): List diff --git a/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt b/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt index 544ddf7..ab70af6 100644 --- a/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt +++ b/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt @@ -1,19 +1,10 @@ package com.example.auction.repository -import com.example.auction.model.Auction -import com.example.auction.model.AuctionId -import com.example.auction.model.AuctionRules +import com.example.auction.model.* import com.example.auction.model.AuctionRules.Blind import com.example.auction.model.AuctionRules.Reverse import com.example.auction.model.AuctionRules.Vickrey import com.example.auction.model.AuctionState.valueOf -import com.example.auction.model.AuctionWinner -import com.example.auction.model.Bid -import com.example.auction.model.BidId -import com.example.auction.model.BlindAuction -import com.example.auction.model.MonetaryAmount -import com.example.auction.model.ReverseAuction -import com.example.auction.model.VickreyAuction import com.example.pii.UserId import org.springframework.jdbc.core.simple.JdbcClient import org.springframework.jdbc.support.GeneratedKeyHolder @@ -46,7 +37,7 @@ private const val selectAuction = """ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { private val jdbcClient = JdbcClient.create(dataSource) - override fun addAuction(auction: Auction) { + override fun addAuction(auction: Auction): Auction { val keyHolder = GeneratedKeyHolder() jdbcClient @@ -69,12 +60,12 @@ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { .update(keyHolder) val newId = AuctionId(keyHolder.key?.toLong() ?: error("no ID generated")) - auction.id = newId - - insertNewBids(auction) + val saved = auction.copy(id = newId) + insertNewBids(saved) + return saved } - override fun updateAuction(auction: Auction) { + override fun updateAuction(auction: Auction): Auction { jdbcClient.sql( // language=sql """ @@ -87,9 +78,8 @@ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { .param("state", auction.state.name) .update() - insertNewBids(auction) - - auction.winner?.let { winner -> + val updatedAuction = insertNewBids(auction) + updatedAuction.winner?.let { winner -> jdbcClient.sql( """ MERGE INTO AUCTION_WINNER W @@ -99,22 +89,26 @@ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { WHEN NOT MATCHED THEN INSERT VALUES vals.AUCTION, vals.WINNER, vals.OWED """ ) - .param("auctionId", auction.id.value) + .param("auctionId", updatedAuction.id.value) .param("winner", winner.winner.value) .param("owed", winner.owed.repr) .update() } + return updatedAuction } - private fun insertNewBids(auction: Auction) { - auction.bids.forEach { bid -> + private fun insertNewBids(auction: Auction): Auction { + val updatedBids = auction.bids.map { bid -> if (bid.id == BidId.NONE) { - insertBid(auction, bid) - } + val updated = insertBid(auction, bid) + updated + } else + bid } + return auction.copy(bids = updatedBids) } - private fun insertBid(auction: Auction, bid: Bid) { + private fun insertBid(auction: Auction, bid: Bid): Bid { val keyHolder = GeneratedKeyHolder() jdbcClient @@ -128,8 +122,7 @@ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { .param("bidder", bid.buyer.value) .param("amount", bid.amount.repr) .update(keyHolder) - - bid.id = BidId(keyHolder.key?.toLong() ?: error("no ID generated")) + return bid.copy(id = BidId(keyHolder.key?.toLong() ?: error("no ID generated"))) } override fun getAuction(id: AuctionId): Auction? { @@ -224,23 +217,17 @@ private fun ResultSet.toAuction(bids: MutableList): Auction { MonetaryAmount(getBigDecimal("OWED").setScale(currency.defaultFractionDigits)) ) } else null - - val constructor = when (rules) { - Blind -> ::BlindAuction - Vickrey -> ::VickreyAuction - Reverse -> ::ReverseAuction - } - - return constructor( - UserId(getString("SELLER")), - getString("DESCRIPTION"), - currency, - MonetaryAmount(getBigDecimal("RESERVE").setScale(currency.defaultFractionDigits)), - MonetaryAmount(getBigDecimal("COMMISSION")), - MonetaryAmount(getBigDecimal("CHARGE_PER_BID")), - AuctionId(getLong("ID")), - bids, - valueOf(getString("STATE")), - winner + return Auction( + rules = rules, + seller = UserId(getString("SELLER")), + description = getString("DESCRIPTION"), + currency = currency, + reserve = MonetaryAmount(getBigDecimal("RESERVE").setScale(currency.defaultFractionDigits)), + commission = MonetaryAmount(getBigDecimal("COMMISSION")), + chargePerBid = MonetaryAmount(getBigDecimal("CHARGE_PER_BID")), + id = AuctionId(getLong("ID")), + bids = bids, + state = AuctionState.valueOf(getString("STATE")), + winner = winner ) } diff --git a/src/main/kotlin/com/example/auction/service/AuctionService.kt b/src/main/kotlin/com/example/auction/service/AuctionService.kt index 4533131..140027c 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionService.kt @@ -1,10 +1,30 @@ package com.example.auction.service +import com.example.auction.model.AuctionError import com.example.auction.model.AuctionId +import com.example.auction.model.BadRequestException +import com.example.auction.model.toException +import dev.forkhandles.result4k.Result4k + +sealed class AuctionServiceError { + abstract val message: String + abstract fun toException(): Exception + class InvalidUser(override val message: String ): AuctionServiceError() { + override fun toException() = BadRequestException(message) + } + class InvalidAuction(override val message: String ): AuctionServiceError() { + override fun toException() = NotFoundException(message) + } + class AuctionError(val error: com.example.auction.model.AuctionError): AuctionServiceError() { + override val message: String + get() = error.message + override fun toException() = error.toException() + } +} interface AuctionService { fun listAuctions(count: Int, after: AuctionId): List fun createAuction(rq: CreateAuctionRequest): AuctionId - fun placeBid(auctionId: AuctionId, bid: BidRequest) + fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k fun closeAuction(auctionId: AuctionId): AuctionResult } \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt index 75bd9e4..d1795ec 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -1,16 +1,11 @@ package com.example.auction.service -import com.example.auction.model.Auction -import com.example.auction.model.AuctionId -import com.example.auction.model.BadRequestException -import com.example.auction.model.BlindAuction -import com.example.auction.model.MonetaryAmount +import com.example.auction.model.* import com.example.auction.model.MonetaryAmount.Companion.ZERO -import com.example.auction.model.Money -import com.example.auction.model.ReverseAuction -import com.example.auction.model.VickreyAuction import com.example.auction.repository.AuctionRepository +import com.example.auction.service.AuctionServiceError.* import com.example.pii.UserIdValidator +import dev.forkhandles.result4k.* import org.springframework.dao.ConcurrencyFailureException import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable @@ -41,7 +36,7 @@ annotation class ApiTransaction @Component class AuctionServiceImpl( private val repository: AuctionRepository, - private val piiVault: UserIdValidator + private val piiVault: UserIdValidator, ) : AuctionService { @ApiTransaction override fun listAuctions(count: Int, after: AuctionId): List { @@ -49,30 +44,29 @@ class AuctionServiceImpl( return repository.listOpenAuctions(count, after) .map { it.summarise() } } - + @ApiTransaction override fun createAuction(rq: CreateAuctionRequest): AuctionId { if (!piiVault.isValid(rq.seller)) { throw BadRequestException("invalid user id ${rq.seller}") } - + val auction = newAuction(rq) - repository.addAuction(auction) - return auction.id + return repository.addAuction(auction).id } - + private fun newAuction(rq: CreateAuctionRequest) = when (rq) { is CreateBlindAuctionRequest -> newAuction(rq) is CreateVickreyAuctionRequest -> newAuction(rq) is CreateReverseAuctionRequest -> newAuction(rq) } - + private fun newAuction(rq: CreateBlindAuctionRequest): Auction { checkPositiveAmount(rq.reserve.amount, "reserve") checkWholeMinorUnits(rq.reserve.amount, rq.reserve.currency, "reserve") checkPositiveAmount(rq.commission, "commission") - - return BlindAuction( + + return blindAuction( seller = rq.seller, description = rq.description, currency = rq.reserve.currency, @@ -80,13 +74,13 @@ class AuctionServiceImpl( commission = rq.commission, ) } - + private fun newAuction(rq: CreateVickreyAuctionRequest): Auction { checkPositiveAmount(rq.reserve.amount, "reserve") checkWholeMinorUnits(rq.reserve.amount, rq.reserve.currency, "reserve") checkPositiveAmount(rq.commission, "commission") - - return VickreyAuction( + + return vickreyAuction( seller = rq.seller, description = rq.description, currency = rq.reserve.currency, @@ -94,16 +88,16 @@ class AuctionServiceImpl( commission = rq.commission, ) } - + private fun newAuction(rq: CreateReverseAuctionRequest): Auction { checkWholeMinorUnits(rq.reserve.amount, rq.reserve.currency, "reserve") checkPositiveAmount(rq.chargePerBid.amount, "charge per bid") - + if (rq.reserve.currency != rq.chargePerBid.currency) { throw BadRequestException("reserve and charge-per-bid must have same currency") } - - return ReverseAuction( + + return reverseAuction( seller = rq.seller, description = rq.description, currency = rq.reserve.currency, @@ -112,39 +106,38 @@ class AuctionServiceImpl( chargePerBid = rq.chargePerBid.amount, ) } - + @ApiTransaction - override fun placeBid(auctionId: AuctionId, bid: BidRequest) { + override fun placeBid( + auctionId: AuctionId, + bid: BidRequest, + ): Result4k { if (!piiVault.isValid(bid.buyer)) { - throw BadRequestException("invalid user id ${bid.buyer}") + return InvalidUser("invalid user id ${bid.buyer}").asFailure() } - - val auction = loadAuction(auctionId) - auction.placeBid(bid.buyer, bid.amount) - - repository.updateAuction(auction) + val auction = loadAuction(auctionId) ?: + return InvalidAuction("no auction found with id $auctionId").asFailure() + return auction.placeBid(bid.buyer, bid.amount) + .map { repository.updateAuction(it); Unit} + .mapFailure { AuctionServiceError.AuctionError(it) } } - + @ApiTransaction override fun closeAuction(auctionId: AuctionId): AuctionResult { - val auction = loadAuction(auctionId) - - auction.close() - repository.updateAuction(auction) - - return when (val result = auction.winner) { + val auction = loadAuction(auctionId)?.close() + ?: throw NotFoundException("no auction found with id $auctionId") + val updated = repository.updateAuction(auction) + + return when (val result = updated.winner) { null -> Passed else -> Sold( result.winner, - Money(result.owed, auction.currency).toWholeMinorUnits(UP) + Money(result.owed, updated.currency).toWholeMinorUnits(UP) ) } } - - private fun loadAuction(auctionId: AuctionId): Auction { - return repository.getAuction(auctionId) - ?: throw NotFoundException("no auction found with id $auctionId") - } + + private fun loadAuction(auctionId: AuctionId) = repository.getAuction(auctionId) } private fun checkCount(count: Int) { diff --git a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt index 0dd887d..a55d52b 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt @@ -3,6 +3,10 @@ package com.example.auction.service import com.example.auction.model.AuctionId import com.example.auction.repository.AuctionRepository import com.example.settlement.Settlement +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.Success +import dev.forkhandles.result4k.onFailure +import dev.forkhandles.result4k.orThrow import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @@ -16,26 +20,23 @@ class AuctionSettlementService( ) { @Scheduled(fixedDelayString = "\${auction.settlement.period}") fun requestSettlements() { - var lastAuctionId = settleAuctions(batchSize) + var lastAuctionId = settleAuctions(batchSize).orThrow() while (lastAuctionId != null) { - lastAuctionId = settleAuctions(batchSize, after = lastAuctionId) + lastAuctionId = settleAuctions(batchSize, after = lastAuctionId).orThrow() } } - fun settleAuctions(batchSize: Int, after: AuctionId = AuctionId.NONE): AuctionId? { + private fun settleAuctions(batchSize: Int, after: AuctionId = AuctionId.NONE): Result4k { val batch = repository.listForSettlement(batchSize, after = after) - for (id in batch) { val auction = repository.getAuction(id) ?: continue val instruction = auction.settlement() ?: continue settlement.settle(instruction) - auction.settled() - - repository.updateAuction(auction) + val updated = auction.settled().onFailure { return it } + repository.updateAuction(updated) } - - return batch.lastOrNull() + return Success(batch.lastOrNull()) } } diff --git a/src/main/kotlin/com/example/auction/web/AuctionController.kt b/src/main/kotlin/com/example/auction/web/AuctionController.kt index b3b9dfd..bdf67d5 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionController.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionController.kt @@ -1,14 +1,13 @@ package com.example.auction.web +import com.example.auction.model.AuctionError import com.example.auction.model.AuctionId -import com.example.auction.service.AuctionResult -import com.example.auction.service.AuctionService -import com.example.auction.service.AuctionSummary -import com.example.auction.service.BidRequest -import com.example.auction.service.CreateAuctionRequest -import com.example.auction.service.CreateAuctionResponse +import com.example.auction.service.* +import dev.forkhandles.result4k.* import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -28,7 +27,7 @@ class AuctionController( companion object { val log = LoggerFactory.getLogger(AuctionController::class.java) } - + @GetMapping @ResponseBody fun listAuctions( @@ -37,7 +36,7 @@ class AuctionController( ): List { return auctionService.listAuctions(count, after) } - + @PostMapping( consumes = ["application/json"], produces = ["application/json"] @@ -48,17 +47,23 @@ class AuctionController( val newId = auctionService.createAuction(rq) return CreateAuctionResponse(newId) } - + @PostMapping( "{auctionId}/bids", consumes = ["application/json"], produces = [] ) - @ResponseStatus(HttpStatus.NO_CONTENT) - fun recordBid(@PathVariable auctionId: AuctionId, @RequestBody bid: BidRequest) { - auctionService.placeBid(auctionId, bid) + + fun recordBid(@PathVariable auctionId: AuctionId, @RequestBody bid: BidRequest): ResponseEntity<*> { + val result = auctionService.placeBid(auctionId, bid) + return result.map { + ResponseEntity(HttpStatus.NO_CONTENT) + }.recover { + val problemDetail = it.toProblemDetail() + ResponseEntity.status(problemDetail.status).body(problemDetail) + } } - + @PostMapping( "{auctionId}/closed", consumes = [], @@ -70,3 +75,29 @@ class AuctionController( } } +private fun AuctionServiceError.toProblemDetail(): ProblemDetail = when (this) { + is AuctionServiceError.AuctionError -> + toProblemDetail() + + is AuctionServiceError.InvalidAuction -> ProblemDetail.forStatusAndDetail( + HttpStatus.NOT_FOUND, + message + ) + + is AuctionServiceError.InvalidUser -> ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + message + ) +} + +private fun AuctionServiceError.AuctionError.toProblemDetail() = when (error) { + is AuctionError.BadRequest -> ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + message + ) + + is AuctionError.WrongState -> ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, + message + ) +} \ No newline at end of file diff --git a/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt b/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt index 0593e4d..611fc21 100644 --- a/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt +++ b/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt @@ -1,17 +1,18 @@ package com.example.auction import com.example.auction.model.AuctionId -import com.example.auction.model.BlindAuction +import com.example.auction.model.blindAuction import com.example.auction.model.MonetaryAmount import com.example.auction.model.WrongStateException import com.example.pii.UserId +import dev.forkhandles.result4k.orThrow import kotlin.test.Test import kotlin.test.assertFailsWith class AuctionErrorCasesTest { @Test fun `cannot mark an open auction as settled`() { - val a = BlindAuction( + val a = blindAuction( id = AuctionId(2), seller = UserId.newId(), description = "the-auction", @@ -20,7 +21,7 @@ class AuctionErrorCasesTest { ) assertFailsWith { - a.settled() + a.settled().orThrow() } } } \ No newline at end of file diff --git a/src/test/kotlin/com/example/auction/acceptance/ReservePriceTests.kt b/src/test/kotlin/com/example/auction/acceptance/ReservePriceTests.kt index 70763b5..e05e334 100644 --- a/src/test/kotlin/com/example/auction/acceptance/ReservePriceTests.kt +++ b/src/test/kotlin/com/example/auction/acceptance/ReservePriceTests.kt @@ -2,8 +2,8 @@ package com.example.auction.acceptance import com.example.auction.service.Passed import com.example.auction.service.Sold -import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.Test interface ReservePriceTests : AuctionTesting { @Test diff --git a/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt b/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt index 961a7b7..91793fe 100644 --- a/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt +++ b/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt @@ -15,6 +15,8 @@ import com.example.settlement.SettlementInstruction import com.example.simulators.pii_vault.PiiVaultSimulatorService import com.example.simulators.settlement.SettlementSimulatorService import com.example.simulators.settlement.get +import dev.forkhandles.result4k.mapFailure +import dev.forkhandles.result4k.orThrow abstract class DomainModelOnlyTesting : AuctionTesting { @@ -44,7 +46,7 @@ abstract class DomainModelOnlyTesting : AuctionTesting { service.createAuction(rq) override fun UserId.bid(auction: AuctionId, amount: Money) { - service.placeBid(auction, BidRequest(this, amount)) + service.placeBid(auction, BidRequest(this, amount)).mapFailure { it.toException() }.orThrow() } override fun UserId.closeAuction(auction: AuctionId) = diff --git a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt index 319231c..db071c1 100644 --- a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt +++ b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt @@ -2,19 +2,15 @@ package com.example.auction.repository import com.example.auction.EUR import com.example.auction.acceptance.EUR -import com.example.auction.model.Auction -import com.example.auction.model.AuctionId -import com.example.auction.model.AuctionWinner -import com.example.auction.model.Bid -import com.example.auction.model.BidId -import com.example.auction.model.BlindAuction -import com.example.auction.model.MonetaryAmount -import com.example.auction.model.ReverseAuction -import com.example.auction.model.VickreyAuction +import com.example.auction.model.* import com.example.pii.UserId +import dev.forkhandles.result4k.flatMap +import dev.forkhandles.result4k.map +import dev.forkhandles.result4k.onFailure +import dev.forkhandles.result4k.orThrow +import kotlin.test.assertNotEquals import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -22,146 +18,139 @@ import kotlin.test.fail interface AuctionRepositoryContract { val repository: AuctionRepository - + @Test fun `save a new auction`() { - val auction = newBlindAuction() - - repository.addAuction(auction) + val auction = repository.addAuction(newBlindAuction()) val auctionId = auction.id val loaded = load(auctionId) - + assertEquals(auction.seller, loaded.seller, "seller") assertEquals(auction.description, loaded.description, "description") assertTrue(auction.reserve.compareTo(loaded.reserve) == 0, "reserve") assertEquals(auction.currency, loaded.currency, "currency") assertNull(auction.winner, "winner") } - + @Test fun `returns null when no auction found`() { assertNull(repository.getAuction(AuctionId(99)), "loaded") } - + @Test fun `adding bids`() { - val auction = newBlindAuction() - repository.addAuction(auction) - val auctionId = auction.id - - auction.placeBid(alice, 1.EUR) - auction.placeBid(bob, 2.EUR) - repository.updateAuction(auction) - + val auction = + repository.addAuction(newBlindAuction()) + .placeBid(alice, 1.EUR) + .flatMap { it.placeBid(bob, 2.EUR) } + .map { repository.updateAuction(it) } + .onFailure { fail(it.reason.message) } + assertNotEquals(BidId.NONE, auction.bids[0].id, "bids[0]") assertNotEquals(BidId.NONE, auction.bids[1].id, "bids[1]") assertNotEquals(auction.bids[0], auction.bids[1], "bid ids are not equal") - + + val auctionId = auction.id val loaded = repository.getAuction(auction.id) ?: fail("could not reload auction $auctionId") - + assertEquals(alice, loaded.bids[0].buyer) assertEquals(MonetaryAmount("1.00"), loaded.bids[0].amount) - + assertEquals(bob, loaded.bids[1].buyer) assertEquals(MonetaryAmount("2.00"), loaded.bids[1].amount) } - + @Test fun `saving and loading the winner`() { - val auction = newBlindAuction() - repository.addAuction(auction) + val auction = + repository.addAuction(newBlindAuction()) + .placeBid(alice, 1.EUR) + .flatMap { it.placeBid(bob, 2.EUR) } + .map { repository.updateAuction(it) } + .onFailure { fail(it.reason.message) } + val auctionId = auction.id - - auction.placeBid(alice, 1.EUR) - auction.placeBid(bob, 2.EUR) - repository.updateAuction(auction) - run { - val loaded = load(auctionId) - loaded.winner = AuctionWinner(winner = bob, owed = MonetaryAmount("2.00")) + val loaded = load(auctionId).copy( + winner = AuctionWinner(winner = bob, owed = MonetaryAmount("2.00")) + ) repository.updateAuction(loaded) } - + run { val loaded = load(auctionId) assertNotNull(loaded.winner) assertEquals(AuctionWinner(bob, MonetaryAmount("2.00")), loaded.winner) } } - + @Test fun `listing open auctions`() { - val a1 = newBlindAuction(1) - val a2 = newVickreyAuction(2) - val a3 = newReverseAuction(3) - val a4 = newBlindAuction(4) - - repository.addAuction(a1) - repository.addAuction(a2) - repository.addAuction(a3) - repository.addAuction(a4) - + val a1 = repository.addAuction(newBlindAuction(1)) + val a2 = repository.addAuction(newVickreyAuction(2)) + val a3 = repository.addAuction(newReverseAuction(3)) + val a4 = repository.addAuction(newBlindAuction(4)) + val loaded = repository.listOpenAuctions(4, after = a1.id.predecessor()) - + assertEquals(4, loaded.count()) assertEquals(listOf(a1.id, a2.id, a3.id, a4.id), loaded.map { it.id }) } - + @Test fun `listing auctions for settlement`() { val auctions = (1..8).map { i -> - val auction = newBlindAuction(i) - repository.addAuction(auction) - auction + repository.addAuction(newBlindAuction(i)) } - + val closedAuctions = repository.listOpenAuctions(count = 3, after = auctions[1].id) .map { - it.close() - repository.updateAuction(it) + it.close().let { closed -> + repository.updateAuction(closed) + } repository.getAuction(it.id) ?: fail("could not reload closed auction") } - + val forSettlement = repository.listForSettlement(10, after = auctions[1].id) - + assertEquals(3, forSettlement.count()) assertEquals(closedAuctions.map { it.id }, forSettlement) } - + fun load(auctionId: AuctionId): Auction { return repository.getAuction(auctionId) ?: fail("could not load auction $auctionId") } - + private fun AuctionId.predecessor() = AuctionId(value - 1) - + private operator fun Iterable.get(bidder: UserId) = firstOrNull { it.buyer == bidder } ?: fail("no bid found for $bidder") - + companion object { - fun newBlindAuction(n: Int = 1) = BlindAuction( + fun newBlindAuction(n: Int = 1) = blindAuction( seller = UserId.newId(), description = "description-$n", currency = EUR, reserve = MonetaryAmount(1), ) - - fun newVickreyAuction(n: Int = 1) = VickreyAuction( + + fun newVickreyAuction(n: Int = 1) = vickreyAuction( seller = UserId.newId(), description = "description-$n", currency = EUR, reserve = MonetaryAmount(1), ) - - fun newReverseAuction(n: Int = 1) = ReverseAuction( + + fun newReverseAuction(n: Int = 1) = reverseAuction( seller = UserId.newId(), description = "description-$n", currency = EUR, ) - + val alice = UserId("alice") val bob = UserId("bob") } diff --git a/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt b/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt index 1d589c9..da8bc3b 100644 --- a/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt +++ b/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt @@ -14,24 +14,24 @@ class InMemoryAuctionRepository : AuctionRepository { private val auctions = mutableMapOf() private var nextBidId = 1L - override fun addAuction(auction: Auction) { + override fun addAuction(auction: Auction): Auction { assertEquals(NONE, auction.id, "auction already has an ID") - auction.id = AuctionId(auctions.size + 1L) - auctions[auction.id] = auction + val saved = auction.copy(id = AuctionId(auctions.size + 1L)) + auctions[saved.id] = saved + return saved } override fun getAuction(id: AuctionId) = auctions[id] - override fun updateAuction(auction: Auction) { - auction.bids.forEach { + override fun updateAuction(auction: Auction): Auction { + val updated = auction.copy(bids = auction.bids.map { if (it.id == BidId.NONE) { - it.id = BidId(nextBidId++) - } - } - - // TODO: ask Nat if I can delete this line. It doesn't seem needed because we mutate the auction - // auctions[auction.id] = auction + it.copy(id = BidId(nextBidId++)) + } else it + }) + auctions[updated.id] = updated + return updated } override fun listOpenAuctions(count: Int, after: AuctionId) = diff --git a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt index 6897c1e..aa1caee 100644 --- a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt +++ b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt @@ -3,17 +3,14 @@ package com.example.auction.web import com.example.auction.AuctionApplication import com.example.auction.acceptance.EUR import com.example.auction.model.AuctionId -import com.example.auction.service.AuctionResult -import com.example.auction.service.AuctionService -import com.example.auction.service.AuctionSummary -import com.example.auction.service.BidRequest -import com.example.auction.service.CreateAuctionRequest -import com.example.auction.service.Sold +import com.example.auction.service.* import com.example.auction.web.ControllerFuzzTests.Config import com.example.pii.UserId import com.natpryce.snodge.json.defaultJsonMutagens import com.natpryce.snodge.json.forStrings import com.natpryce.snodge.mutants +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.Success import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -96,7 +93,8 @@ class DummyAuctionService : AuctionService { return AuctionId(1) } - override fun placeBid(auctionId: AuctionId, bid: BidRequest) { + override fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k { + return Success(Unit) } override fun closeAuction(auctionId: AuctionId): AuctionResult { diff --git a/src/test/kotlin/com/example/simulators/pii_vault/PiiVaultClientAndServiceTests.kt b/src/test/kotlin/com/example/simulators/pii_vault/PiiVaultClientAndServiceTests.kt index 07073f3..6a70406 100644 --- a/src/test/kotlin/com/example/simulators/pii_vault/PiiVaultClientAndServiceTests.kt +++ b/src/test/kotlin/com/example/simulators/pii_vault/PiiVaultClientAndServiceTests.kt @@ -2,6 +2,8 @@ package com.example.simulators.pii_vault import com.example.pii.PiiVaultClient import com.example.pii.UserId +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.condition.EnabledIfSystemProperty import org.springframework.beans.factory.annotation.Autowired @@ -12,8 +14,6 @@ import org.springframework.test.annotation.DirtiesContext import org.springframework.test.annotation.DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD import java.net.URI import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue @EnabledIfSystemProperty(named = "run-slow-tests", matches = "true") @SpringBootTest(classes=[PiiVaultSimulator::class], webEnvironment = RANDOM_PORT)