diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index bcb76f4..a381e20 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -2,60 +2,68 @@ package com.example.auction.model import com.example.auction.model.AuctionState.closed import com.example.auction.model.AuctionState.open +import com.example.auction.model.AuctionState.settled import com.example.auction.model.MonetaryAmount.Companion.ZERO import com.example.pii.UserId import com.example.settlement.Charge import com.example.settlement.Collection import com.example.settlement.OrderId import com.example.settlement.SettlementInstruction +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.asFailure +import dev.forkhandles.result4k.asSuccess 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) { +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 saved(newId: AuctionId) = + copy(id = newId) + + fun placeBid( + buyer: UserId, + bid: Money, + ): Result4k { if (buyer == seller) { - throw BadRequestException("shill bidding detected by $seller") + return BadRequestException("shill bidding detected by $seller").asFailure() } if (bid.currency != currency) { - throw BadRequestException("bid in wrong currency, should be $currency") + return BadRequestException("bid in wrong currency, should be $currency").asFailure() } if (bid.amount == ZERO) { - throw BadRequestException("zero bid") + return BadRequestException("zero bid").asFailure() } if (state != open) { - throw WrongStateException("auction $id is closed") + return WrongStateException("auction $id is closed").asFailure() } - - bids.add(Bid(buyer, bid.amount)) + + return copy(bids = bids + Bid(buyer, bid.amount)).asSuccess() } - - fun close() { - state = closed - winner = decideWinner() + + fun close(): Auction { + return copy(state = closed, winner = decideWinner()) } - protected abstract fun decideWinner(): AuctionWinner? + fun decideWinner() = rules.decideWinner(this) - fun settled() { + fun settled(): Auction { if (state == open) { throw WrongStateException("auction $id not closed") } - state = AuctionState.settled + return copy(state = settled) } fun settlement(): SettlementInstruction? { @@ -99,8 +107,4 @@ 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/AuctionError.kt b/src/main/kotlin/com/example/auction/model/AuctionError.kt new file mode 100644 index 0000000..b429e8b --- /dev/null +++ b/src/main/kotlin/com/example/auction/model/AuctionError.kt @@ -0,0 +1,13 @@ +package com.example.auction.model + +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.recover +import java.lang.Exception + +sealed interface AuctionError { + fun orThrow(): Nothing { + throw (this as Exception) + } +} + +fun Result4k.orThrow(): T = recover { it.orThrow() } \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/model/AuctionRules.kt b/src/main/kotlin/com/example/auction/model/AuctionRules.kt index 02a0573..0a61b5b 100644 --- a/src/main/kotlin/com/example/auction/model/AuctionRules.kt +++ b/src/main/kotlin/com/example/auction/model/AuctionRules.kt @@ -1,5 +1,7 @@ package com.example.auction.model -enum class AuctionRules { - Blind, Vickrey, Reverse +enum class AuctionRules(val decideWinner: (Auction) -> AuctionWinner?) { + Blind(::blindAuctionWinner), + Vickrey(::vickreyAuctionWinner), + Reverse(::reverseAuctionWinner) } diff --git a/src/main/kotlin/com/example/auction/model/BadRequestException.kt b/src/main/kotlin/com/example/auction/model/BadRequestException.kt index c2f400a..67a662d 100644 --- a/src/main/kotlin/com/example/auction/model/BadRequestException.kt +++ b/src/main/kotlin/com/example/auction/model/BadRequestException.kt @@ -1,3 +1,3 @@ package com.example.auction.model -class BadRequestException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) \ No newline at end of file +class BadRequestException(message: String, cause: Throwable? = null) : RuntimeException(message, cause), AuctionError \ 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..4ee10bd 100644 --- a/src/main/kotlin/com/example/auction/model/Bid.kt +++ b/src/main/kotlin/com/example/auction/model/Bid.kt @@ -2,14 +2,8 @@ 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..9694f67 100644 --- a/src/main/kotlin/com/example/auction/model/BlindAuction.kt +++ b/src/main/kotlin/com/example/auction/model/BlindAuction.kt @@ -5,25 +5,35 @@ 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( + rules = Blind, + seller = seller, + description = description, + currency = currency, + reserve = reserve, + commission = commission, + chargePerBid = chargePerBid, + id = id, + state = state, + bids = bids, + winner = winner +) + + +fun blindAuctionWinner(auction: Auction) = + auction.bids + .associateBy { it.buyer } + .values + .maxByOrNull { it.amount } + ?.takeIf { it.amount >= auction.reserve }?.toWinner() diff --git a/src/main/kotlin/com/example/auction/model/NotFoundException.kt b/src/main/kotlin/com/example/auction/model/NotFoundException.kt new file mode 100644 index 0000000..728878c --- /dev/null +++ b/src/main/kotlin/com/example/auction/model/NotFoundException.kt @@ -0,0 +1,3 @@ +package com.example.auction.model + +class NotFoundException(message: String, cause: Throwable? = null) : RuntimeException(message, cause), AuctionError \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt index 653b794..d06d8b4 100644 --- a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt +++ b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt @@ -5,40 +5,37 @@ import com.example.auction.model.AuctionState.open import com.example.pii.UserId import java.util.Currency -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( + rules = Reverse, + seller = seller, + description = description, + currency = currency, + reserve = reserve, + commission = commission, + chargePerBid = chargePerBid, + id = id, + state = state, + bids = bids, + winner = winner +) + +fun reverseAuctionWinner(auction: Auction): AuctionWinner? { + return auction.bids + .filter { it.amount >= auction.reserve } + .groupBy { it.amount } + .values + .mapNotNull { it.singleOrNull() } + .minByOrNull { it.amount } + ?.toWinner() } diff --git a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt index 3e8960e..fa571fc 100644 --- a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt +++ b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt @@ -5,32 +5,42 @@ 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) - } +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( + rules = Vickrey, + seller = seller, + description = description, + currency = currency, + reserve = reserve, + commission = commission, + chargePerBid = chargePerBid, + id = id, + state = state, + bids = bids, + winner = winner +) + +fun vickreyAuctionWinner(auction: Auction): AuctionWinner? { + return 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) } - } -} \ No newline at end of file + } +} diff --git a/src/main/kotlin/com/example/auction/model/WrongStateException.kt b/src/main/kotlin/com/example/auction/model/WrongStateException.kt index 8ebd99e..5a171b5 100644 --- a/src/main/kotlin/com/example/auction/model/WrongStateException.kt +++ b/src/main/kotlin/com/example/auction/model/WrongStateException.kt @@ -2,4 +2,4 @@ package com.example.auction.model class WrongStateException(message: String, cause: Throwable? = null) : - RuntimeException(message, cause) \ No newline at end of file + RuntimeException(message, cause), AuctionError \ No newline at end of file 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..efc90a0 100644 --- a/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt +++ b/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt @@ -3,17 +3,11 @@ 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.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 +40,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 +63,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.saved(newId) + insertNewBids(saved) + return saved } - override fun updateAuction(auction: Auction) { + override fun updateAuction(auction: Auction): Auction { jdbcClient.sql( // language=sql """ @@ -87,9 +81,9 @@ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { .param("state", auction.state.name) .update() - insertNewBids(auction) + val updated = insertNewBids(auction) - auction.winner?.let { winner -> + updated.winner?.let { winner -> jdbcClient.sql( """ MERGE INTO AUCTION_WINNER W @@ -99,22 +93,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", updated.id.value) .param("winner", winner.winner.value) .param("owed", winner.owed.repr) .update() } + + return updated } - private fun insertNewBids(auction: Auction) { - auction.bids.forEach { bid -> + private fun insertNewBids(auction: Auction) : Auction { + return auction.copy(bids = auction.bids.map { bid -> if (bid.id == BidId.NONE) { insertBid(auction, bid) + } else { + bid } - } + }) } - private fun insertBid(auction: Auction, bid: Bid) { + private fun insertBid(auction: Auction, bid: Bid): Bid { val keyHolder = GeneratedKeyHolder() jdbcClient @@ -129,7 +127,7 @@ class SpringJdbcAuctionRepository(dataSource: DataSource) : AuctionRepository { .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? { @@ -225,22 +223,17 @@ private fun ResultSet.toAuction(bids: MutableList): Auction { ) } 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 = 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..562aea7 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionService.kt @@ -1,10 +1,13 @@ package com.example.auction.service +import com.example.auction.model.Auction +import com.example.auction.model.AuctionError import com.example.auction.model.AuctionId +import dev.forkhandles.result4k.Result4k 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..0fbc076 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -1,16 +1,12 @@ 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.pii.UserIdValidator +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.asFailure +import dev.forkhandles.result4k.map import org.springframework.dao.ConcurrencyFailureException import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable @@ -56,8 +52,7 @@ class AuctionServiceImpl( throw BadRequestException("invalid user id ${rq.seller}") } - val auction = newAuction(rq) - repository.addAuction(auction) + val auction = repository.addAuction(newAuction(rq)) return auction.id } @@ -114,23 +109,20 @@ class AuctionServiceImpl( } @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 BadRequestException("invalid user id ${bid.buyer}").asFailure() } - - val auction = loadAuction(auctionId) - auction.placeBid(bid.buyer, bid.amount) - - repository.updateAuction(auction) + + return loadAuction(auctionId) + .placeBid(bid.buyer, bid.amount) + .map { withBids -> repository.updateAuction(withBids) } + } @ApiTransaction override fun closeAuction(auctionId: AuctionId): AuctionResult { - val auction = loadAuction(auctionId) - - auction.close() - repository.updateAuction(auction) + val auction = repository.updateAuction(loadAuction(auctionId).close()) return when (val result = auction.winner) { null -> Passed diff --git a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt index 0dd887d..010d9ae 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt @@ -30,9 +30,7 @@ class AuctionSettlementService( val instruction = auction.settlement() ?: continue settlement.settle(instruction) - auction.settled() - - repository.updateAuction(auction) + repository.updateAuction(auction.settled()) } return batch.lastOrNull() diff --git a/src/main/kotlin/com/example/auction/service/NotFoundException.kt b/src/main/kotlin/com/example/auction/service/NotFoundException.kt deleted file mode 100644 index 20f56ae..0000000 --- a/src/main/kotlin/com/example/auction/service/NotFoundException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.example.auction.service - -class NotFoundException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/web/AuctionController.kt b/src/main/kotlin/com/example/auction/web/AuctionController.kt index b3b9dfd..14322f4 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionController.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionController.kt @@ -1,14 +1,16 @@ package com.example.auction.web 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.model.BadRequestException +import com.example.auction.model.NotFoundException +import com.example.auction.model.WrongStateException +import com.example.auction.service.* +import dev.forkhandles.result4k.map +import dev.forkhandles.result4k.recover import org.slf4j.LoggerFactory -import org.springframework.http.HttpStatus +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 @@ -42,7 +44,7 @@ class AuctionController( consumes = ["application/json"], produces = ["application/json"] ) - @ResponseStatus(HttpStatus.CREATED) + @ResponseStatus(CREATED) @ResponseBody fun createAuction(@RequestBody rq: CreateAuctionRequest): CreateAuctionResponse { val newId = auctionService.createAuction(rq) @@ -54,9 +56,18 @@ class AuctionController( 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<*> { + return auctionService.placeBid(auctionId, bid).map { + ResponseEntity(NO_CONTENT) + }.recover { exception -> + val problemDetail = when (exception) { + is BadRequestException -> ProblemDetail.forStatusAndDetail(BAD_REQUEST, exception.message) + is NotFoundException -> ProblemDetail.forStatusAndDetail(NOT_FOUND, exception.message) + is WrongStateException -> ProblemDetail.forStatusAndDetail(CONFLICT, exception.message) + } + ResponseEntity.status(problemDetail.status).body(problemDetail) + } } @PostMapping( diff --git a/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt b/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt index e61dfa7..43c92a9 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt @@ -2,7 +2,7 @@ package com.example.auction.web import com.example.auction.model.BadRequestException import com.example.auction.model.WrongStateException -import com.example.auction.service.NotFoundException +import com.example.auction.model.NotFoundException import org.springframework.http.HttpStatus.BAD_REQUEST import org.springframework.http.HttpStatus.CONFLICT import org.springframework.http.HttpStatus.NOT_FOUND diff --git a/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt b/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt index 46f4373..7f31691 100644 --- a/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt +++ b/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt @@ -10,7 +10,7 @@ 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.NotFoundException +import com.example.auction.model.NotFoundException import com.example.pii.UserId import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue 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..c13995e 100644 --- a/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt +++ b/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt @@ -3,6 +3,7 @@ package com.example.auction.acceptance.mem import com.example.auction.acceptance.AuctionTesting import com.example.auction.model.AuctionId import com.example.auction.model.Money +import com.example.auction.model.orThrow import com.example.auction.repository.InMemoryAuctionRepository import com.example.auction.service.AuctionServiceImpl import com.example.auction.service.AuctionSettlementService @@ -16,7 +17,6 @@ import com.example.simulators.pii_vault.PiiVaultSimulatorService import com.example.simulators.settlement.SettlementSimulatorService import com.example.simulators.settlement.get - abstract class DomainModelOnlyTesting : AuctionTesting { private val piiVault = PiiVaultSimulatorService() private val userIdValidator = object : UserIdValidator { @@ -44,7 +44,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)).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..264cd80 100644 --- a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt +++ b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt @@ -2,16 +2,9 @@ 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 kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -25,9 +18,7 @@ interface AuctionRepositoryContract { @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) @@ -45,13 +36,14 @@ interface AuctionRepositoryContract { @Test fun `adding bids`() { - val auction = newBlindAuction() - repository.addAuction(auction) - val auctionId = auction.id + val original = repository.addAuction(newBlindAuction()) + .placeBid(alice, 1.EUR) + .flatMap { it.placeBid(bob, 2.EUR) } + .orThrow() - auction.placeBid(alice, 1.EUR) - auction.placeBid(bob, 2.EUR) - repository.updateAuction(auction) + val auction = repository.updateAuction(original) + + val auctionId = auction.id assertNotEquals(BidId.NONE, auction.bids[0].id, "bids[0]") assertNotEquals(BidId.NONE, auction.bids[1].id, "bids[1]") @@ -69,20 +61,16 @@ interface AuctionRepositoryContract { @Test fun `saving and loading the winner`() { - val auction = newBlindAuction() - repository.addAuction(auction) + val auction = repository.addAuction(newBlindAuction()) 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")) + .copy(winner = AuctionWinner(winner = bob, owed = MonetaryAmount("2.00"))) repository.updateAuction(loaded) } - + run { val loaded = load(auctionId) assertNotNull(loaded.winner) @@ -92,15 +80,10 @@ interface AuctionRepositoryContract { @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()) @@ -111,15 +94,12 @@ interface AuctionRepositoryContract { @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) + repository.updateAuction(it.close()) repository.getAuction(it.id) ?: fail("could not reload closed auction") } diff --git a/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt b/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt index 1d589c9..5bc77bd 100644 --- a/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt +++ b/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt @@ -14,24 +14,27 @@ 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.saved(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++) + it.copy(id = BidId(nextBidId++)) + } else { + it } - } + }) - // TODO: ask Nat if I can delete this line. It doesn't seem needed because we mutate the auction - // auctions[auction.id] = auction + 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..d1a524e 100644 --- a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt +++ b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt @@ -2,7 +2,8 @@ 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.model.* +import com.example.auction.repository.AuctionRepositoryContract import com.example.auction.service.AuctionResult import com.example.auction.service.AuctionService import com.example.auction.service.AuctionSummary @@ -14,6 +15,8 @@ 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.asSuccess import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -96,7 +99,8 @@ class DummyAuctionService : AuctionService { return AuctionId(1) } - override fun placeBid(auctionId: AuctionId, bid: BidRequest) { + override fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k { + return AuctionRepositoryContract.newReverseAuction(1).asSuccess() } override fun closeAuction(auctionId: AuctionId): AuctionResult {