Full output
GPT-5.6 Sol 원문 결과물
주문 금액 계산 엔진 보수 / OpenAI / GPT-5.6 Sol / 실행일 2026-07-28
gpt-5.6-sol-high 결과
구현과 자동화 테스트를 완료했습니다.
주요 수정:
- 상품 → 회원 → 쿠폰 순서로 계산
BigInteger기반 정확한 비례 배분과 최대 나머지 방식 적용- BOOK 회원 할인 제외, 쿠폰 임계값·상한, 할인 후 배송비 계산 수정
double,float,Math.round제거- 입력 검증과 곱셈·합산·최종 계산 오버플로를 계약된 HTTP 400으로 통일
- DTO, enum, 컨트롤러 경로, 빌드 설정은 변경하지 않음
변경 파일:
OrderPricingService.javaPricingExceptionHandler.javaInvalidPricingRequestException.javaPricingController.javaOrderPricingApiTest.java
검증 결과:
./gradlew test
BUILD SUCCESSFUL
추가 테스트 31개 + 기존 스모크 테스트 1개
실패 0, 오류 0
트레이드오프는 배분 곱셈에 BigInteger를 사용해 소량의 계산 비용이 추가된다는 점입니다. 대신 long 곱셈 오버플로와 부동소수점 오차 없이 행별 할인 합계를 정확히 보존합니다.
실제 변경 코드
아래는 보존된 격리 작업 사본을 원본 하네스와 대조해 확인한 변경 파일의 전체 내용입니다.
src/main/java/com/samebrief/pricing/api/PricingController.java
package com.samebrief.pricing.api;
import com.samebrief.pricing.service.OrderPricingService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/orders")
public class PricingController {
private final OrderPricingService orderPricingService;
public PricingController(OrderPricingService orderPricingService) {
this.orderPricingService = orderPricingService;
}
@PostMapping("/price")
public ResponseEntity<PricingResponse> price(@RequestBody PricingRequest request) {
return ResponseEntity.ok(orderPricingService.price(request));
}
}
src/main/java/com/samebrief/pricing/api/PricingExceptionHandler.java
package com.samebrief.pricing.api;
import com.samebrief.pricing.service.InvalidPricingRequestException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class PricingExceptionHandler {
private static final String ERROR_CODE = "INVALID_PRICING_REQUEST";
private static final String ERROR_MESSAGE = "주문 정보를 확인해 주세요.";
@ExceptionHandler({InvalidPricingRequestException.class, HttpMessageNotReadableException.class})
public ResponseEntity<PricingErrorResponse> handleInvalidRequest(Exception ignored) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new PricingErrorResponse(ERROR_CODE, ERROR_MESSAGE));
}
}
src/main/java/com/samebrief/pricing/service/InvalidPricingRequestException.java
package com.samebrief.pricing.service;
public class InvalidPricingRequestException extends RuntimeException {
public InvalidPricingRequestException() {
super("Invalid pricing request");
}
public InvalidPricingRequestException(Throwable cause) {
super("Invalid pricing request", cause);
}
}
src/main/java/com/samebrief/pricing/service/OrderPricingService.java
package com.samebrief.pricing.service;
import com.samebrief.pricing.api.PricingRequest;
import com.samebrief.pricing.api.PricingResponse;
import com.samebrief.pricing.domain.Category;
import com.samebrief.pricing.domain.CouponType;
import com.samebrief.pricing.domain.MemberLevel;
import com.samebrief.pricing.domain.Region;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OrderPricingService {
private static final long FREE_SHIPPING_THRESHOLD = 50_000L;
private static final long BASE_SHIPPING_FEE = 3_000L;
private static final long REMOTE_EXTRA_SHIPPING_FEE = 5_000L;
private static final long FIXED_COUPON_THRESHOLD = 30_000L;
private static final long FIXED_COUPON_AMOUNT = 5_000L;
private static final long RATE_COUPON_THRESHOLD = 50_000L;
private static final long RATE_COUPON_CAP = 10_000L;
public PricingResponse price(PricingRequest request) {
validate(request);
try {
return calculate(request);
} catch (ArithmeticException exception) {
throw new InvalidPricingRequestException(exception);
}
}
private PricingResponse calculate(PricingRequest request) {
List<PricingRequest.Line> lines = request.getLines();
int lineCount = lines.size();
long[] grossAmounts = new long[lineCount];
long[] itemDiscounts = new long[lineCount];
long[] amountsAfterItemDiscount = new long[lineCount];
long[] memberDiscounts = new long[lineCount];
long[] amountsAfterMemberDiscount = new long[lineCount];
long itemSubtotal = 0L;
long itemDiscountTotal = 0L;
for (int i = 0; i < lineCount; i++) {
PricingRequest.Line line = lines.get(i);
long grossAmount = Math.multiplyExact(line.getUnitPrice(), line.getQuantity().longValue());
long itemDiscount = itemDiscountOf(line, grossAmount);
grossAmounts[i] = grossAmount;
itemDiscounts[i] = itemDiscount;
amountsAfterItemDiscount[i] = Math.subtractExact(grossAmount, itemDiscount);
itemSubtotal = Math.addExact(itemSubtotal, grossAmount);
itemDiscountTotal = Math.addExact(itemDiscountTotal, itemDiscount);
}
boolean[] memberTargets = new boolean[lineCount];
long memberBaseTotal = 0L;
for (int i = 0; i < lineCount; i++) {
memberTargets[i] = lines.get(i).getCategory() != Category.BOOK;
if (memberTargets[i]) {
memberBaseTotal = Math.addExact(memberBaseTotal, amountsAfterItemDiscount[i]);
}
}
long memberDiscountTotal = request.getMemberLevel() == MemberLevel.GOLD
? memberBaseTotal / 20L
: 0L;
allocate(memberDiscountTotal, amountsAfterItemDiscount, memberTargets, memberBaseTotal, memberDiscounts);
long amountBeforeCoupon = 0L;
for (int i = 0; i < lineCount; i++) {
amountsAfterMemberDiscount[i] =
Math.subtractExact(amountsAfterItemDiscount[i], memberDiscounts[i]);
amountBeforeCoupon = Math.addExact(amountBeforeCoupon, amountsAfterMemberDiscount[i]);
}
long couponDiscountTotal = couponDiscountOf(request.getCouponType(), amountBeforeCoupon);
long[] couponDiscounts = new long[lineCount];
boolean[] couponTargets = new boolean[lineCount];
for (int i = 0; i < lineCount; i++) {
couponTargets[i] = true;
}
allocate(
couponDiscountTotal,
amountsAfterMemberDiscount,
couponTargets,
amountBeforeCoupon,
couponDiscounts);
List<PricingResponse.Line> resultLines = new ArrayList<>(lineCount);
long merchandiseTotal = 0L;
for (int i = 0; i < lineCount; i++) {
long finalAmount = Math.subtractExact(amountsAfterMemberDiscount[i], couponDiscounts[i]);
merchandiseTotal = Math.addExact(merchandiseTotal, finalAmount);
PricingResponse.Line resultLine = new PricingResponse.Line();
resultLine.setProductId(lines.get(i).getProductId());
resultLine.setGrossAmount(grossAmounts[i]);
resultLine.setItemDiscount(itemDiscounts[i]);
resultLine.setMemberDiscount(memberDiscounts[i]);
resultLine.setCouponDiscount(couponDiscounts[i]);
resultLine.setFinalAmount(finalAmount);
resultLines.add(resultLine);
}
long shippingFee = shippingFeeOf(request.getRegion(), merchandiseTotal);
long grandTotal = Math.addExact(merchandiseTotal, shippingFee);
PricingResponse response = new PricingResponse();
response.setItemSubtotal(itemSubtotal);
response.setItemDiscount(itemDiscountTotal);
response.setMemberDiscount(memberDiscountTotal);
response.setCouponDiscount(couponDiscountTotal);
response.setMerchandiseTotal(merchandiseTotal);
response.setShippingFee(shippingFee);
response.setGrandTotal(grandTotal);
response.setLines(resultLines);
return response;
}
private void validate(PricingRequest request) {
if (request == null
|| request.getLines() == null
|| request.getLines().isEmpty()
|| request.getMemberLevel() == null
|| request.getCouponType() == null
|| request.getRegion() == null) {
throw new InvalidPricingRequestException();
}
for (PricingRequest.Line line : request.getLines()) {
if (line == null
|| line.getProductId() == null
|| line.getProductId().isBlank()
|| line.getCategory() == null
|| line.getUnitPrice() == null
|| line.getUnitPrice() < 0L
|| line.getQuantity() == null
|| line.getQuantity() < 1) {
throw new InvalidPricingRequestException();
}
}
}
private long itemDiscountOf(PricingRequest.Line line, long grossAmount) {
if (line.getCategory() == Category.BOOK) {
return grossAmount / 10L;
}
if (line.getCategory() == Category.FASHION && line.getQuantity() >= 3) {
return percentageFloor(grossAmount, 15L);
}
return 0L;
}
private long percentageFloor(long amount, long percentage) {
return BigInteger.valueOf(amount)
.multiply(BigInteger.valueOf(percentage))
.divide(BigInteger.valueOf(100L))
.longValueExact();
}
private long couponDiscountOf(CouponType couponType, long amountBeforeCoupon) {
if (amountBeforeCoupon == 0L) {
return 0L;
}
if (couponType == CouponType.FIXED_5000
&& amountBeforeCoupon >= FIXED_COUPON_THRESHOLD) {
return Math.min(FIXED_COUPON_AMOUNT, amountBeforeCoupon);
}
if (couponType == CouponType.RATE_10
&& amountBeforeCoupon >= RATE_COUPON_THRESHOLD) {
return Math.min(Math.min(amountBeforeCoupon / 10L, RATE_COUPON_CAP), amountBeforeCoupon);
}
return 0L;
}
private void allocate(
long totalDiscount,
long[] bases,
boolean[] targets,
long totalBase,
long[] allocations) {
if (totalDiscount == 0L || totalBase == 0L) {
return;
}
BigInteger discount = BigInteger.valueOf(totalDiscount);
BigInteger denominator = BigInteger.valueOf(totalBase);
List<AllocationRemainder> remainders = new ArrayList<>();
long allocated = 0L;
for (int i = 0; i < bases.length; i++) {
if (!targets[i] || bases[i] == 0L) {
continue;
}
BigInteger[] quotientAndRemainder = discount
.multiply(BigInteger.valueOf(bases[i]))
.divideAndRemainder(denominator);
long floorShare = quotientAndRemainder[0].longValueExact();
allocations[i] = floorShare;
allocated = Math.addExact(allocated, floorShare);
remainders.add(new AllocationRemainder(i, quotientAndRemainder[1]));
}
long unallocated = Math.subtractExact(totalDiscount, allocated);
remainders.sort(Comparator
.comparing(AllocationRemainder::remainder)
.reversed()
.thenComparingInt(AllocationRemainder::index));
for (int i = 0; i < unallocated; i++) {
AllocationRemainder remainder = remainders.get(i);
int lineIndex = remainder.index();
allocations[lineIndex] = Math.addExact(allocations[lineIndex], 1L);
if (allocations[lineIndex] > bases[lineIndex]) {
throw new ArithmeticException("Allocated discount exceeds line amount");
}
}
}
private long shippingFeeOf(Region region, long merchandiseTotal) {
long baseFee = merchandiseTotal >= FREE_SHIPPING_THRESHOLD ? 0L : BASE_SHIPPING_FEE;
long remoteFee = region == Region.REMOTE ? REMOTE_EXTRA_SHIPPING_FEE : 0L;
return Math.addExact(baseFee, remoteFee);
}
private record AllocationRemainder(int index, BigInteger remainder) {
}
}
src/test/java/com/samebrief/pricing/OrderPricingApiTest.java
package com.samebrief.pricing;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class OrderPricingApiTest {
private static final String ENDPOINT = "/api/v1/orders/price";
@Autowired
private MockMvc mockMvc;
@Test
void appliesBookDiscountAndKeepsInputLineOrder() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":10000,"quantity":2},
{"productId":"book-1","category":"BOOK","unitPrice":15000,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.itemSubtotal").value(35_000))
.andExpect(jsonPath("$.itemDiscount").value(1_500))
.andExpect(jsonPath("$.memberDiscount").value(0))
.andExpect(jsonPath("$.couponDiscount").value(0))
.andExpect(jsonPath("$.merchandiseTotal").value(33_500))
.andExpect(jsonPath("$.shippingFee").value(3_000))
.andExpect(jsonPath("$.grandTotal").value(36_500))
.andExpect(jsonPath("$.lines[0].productId").value("food-1"))
.andExpect(jsonPath("$.lines[0].finalAmount").value(20_000))
.andExpect(jsonPath("$.lines[1].productId").value("book-1"))
.andExpect(jsonPath("$.lines[1].grossAmount").value(15_000))
.andExpect(jsonPath("$.lines[1].itemDiscount").value(1_500))
.andExpect(jsonPath("$.lines[1].finalAmount").value(13_500));
}
@Test
void floorsFashionDiscountAtThreeItems() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"fashion-1","category":"FASHION","unitPrice":10001,"quantity":3}
],
"memberLevel":"REGULAR","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.itemSubtotal").value(30_003))
.andExpect(jsonPath("$.itemDiscount").value(4_500))
.andExpect(jsonPath("$.merchandiseTotal").value(25_503))
.andExpect(jsonPath("$.lines[0].itemDiscount").value(4_500))
.andExpect(jsonPath("$.lines[0].finalAmount").value(25_503));
}
@Test
void calculatesMemberDiscountOnceAndAllocatesByLargestRemainder() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":10001,"quantity":1},
{"productId":"food-2","category":"FOOD","unitPrice":10010,"quantity":1}
],
"memberLevel":"GOLD","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.memberDiscount").value(1_000))
.andExpect(jsonPath("$.merchandiseTotal").value(19_011))
.andExpect(jsonPath("$.grandTotal").value(22_011))
.andExpect(jsonPath("$.lines[0].memberDiscount").value(500))
.andExpect(jsonPath("$.lines[0].finalAmount").value(9_501))
.andExpect(jsonPath("$.lines[1].memberDiscount").value(500))
.andExpect(jsonPath("$.lines[1].finalAmount").value(9_510));
}
@Test
void allocatesFixedCouponRemainderByInputOrder() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":10000,"quantity":1},
{"productId":"food-2","category":"FOOD","unitPrice":10000,"quantity":1},
{"productId":"food-3","category":"FOOD","unitPrice":10000,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"FIXED_5000","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.couponDiscount").value(5_000))
.andExpect(jsonPath("$.merchandiseTotal").value(25_000))
.andExpect(jsonPath("$.grandTotal").value(28_000))
.andExpect(jsonPath("$.lines[0].couponDiscount").value(1_667))
.andExpect(jsonPath("$.lines[0].finalAmount").value(8_333))
.andExpect(jsonPath("$.lines[1].couponDiscount").value(1_667))
.andExpect(jsonPath("$.lines[1].finalAmount").value(8_333))
.andExpect(jsonPath("$.lines[2].couponDiscount").value(1_666))
.andExpect(jsonPath("$.lines[2].finalAmount").value(8_334));
}
@Test
void capsRateCouponAndAllocatesItsRemainder() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":40000,"quantity":1},
{"productId":"food-2","category":"FOOD","unitPrice":40000,"quantity":1},
{"productId":"food-3","category":"FOOD","unitPrice":40000,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"RATE_10","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.couponDiscount").value(10_000))
.andExpect(jsonPath("$.merchandiseTotal").value(110_000))
.andExpect(jsonPath("$.shippingFee").value(0))
.andExpect(jsonPath("$.grandTotal").value(110_000))
.andExpect(jsonPath("$.lines[0].couponDiscount").value(3_334))
.andExpect(jsonPath("$.lines[0].finalAmount").value(36_666))
.andExpect(jsonPath("$.lines[1].couponDiscount").value(3_333))
.andExpect(jsonPath("$.lines[1].finalAmount").value(36_667))
.andExpect(jsonPath("$.lines[2].couponDiscount").value(3_333))
.andExpect(jsonPath("$.lines[2].finalAmount").value(36_667));
}
@Test
void calculatesShippingFromDiscountedMerchandiseTotal() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":52000,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"FIXED_5000","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.couponDiscount").value(5_000))
.andExpect(jsonPath("$.lines[0].couponDiscount").value(5_000))
.andExpect(jsonPath("$.lines[0].finalAmount").value(47_000))
.andExpect(jsonPath("$.merchandiseTotal").value(47_000))
.andExpect(jsonPath("$.shippingFee").value(3_000))
.andExpect(jsonPath("$.grandTotal").value(50_000));
}
@Test
void addsRemoteFeeEvenWhenBaseShippingIsFree() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":60000,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"NONE","region":"REMOTE"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.merchandiseTotal").value(60_000))
.andExpect(jsonPath("$.shippingFee").value(5_000))
.andExpect(jsonPath("$.grandTotal").value(65_000));
}
@Test
void excludesBookLinesFromMemberDiscount() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"book-1","category":"BOOK","unitPrice":10000,"quantity":1},
{"productId":"food-1","category":"FOOD","unitPrice":20000,"quantity":1}
],
"memberLevel":"GOLD","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.itemDiscount").value(1_000))
.andExpect(jsonPath("$.memberDiscount").value(1_000))
.andExpect(jsonPath("$.lines[0].memberDiscount").value(0))
.andExpect(jsonPath("$.lines[0].finalAmount").value(9_000))
.andExpect(jsonPath("$.lines[1].memberDiscount").value(1_000))
.andExpect(jsonPath("$.lines[1].finalAmount").value(19_000))
.andExpect(jsonPath("$.grandTotal").value(31_000));
}
@Test
void handlesOrderWithNoPositiveMemberDiscountBase() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"book-1","category":"BOOK","unitPrice":10000,"quantity":1}
],
"memberLevel":"GOLD","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.memberDiscount").value(0))
.andExpect(jsonPath("$.lines[0].memberDiscount").value(0))
.andExpect(jsonPath("$.lines[0].finalAmount").value(9_000))
.andExpect(jsonPath("$.grandTotal").value(12_000));
}
@Test
void doesNotApplyCouponsBelowTheirThresholds() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"fixed","category":"FOOD","unitPrice":29999,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"FIXED_5000","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.couponDiscount").value(0))
.andExpect(jsonPath("$.lines[0].couponDiscount").value(0))
.andExpect(jsonPath("$.lines[0].finalAmount").value(29_999))
.andExpect(jsonPath("$.grandTotal").value(32_999));
mockMvc.perform(request("""
{
"lines": [
{"productId":"rate","category":"FOOD","unitPrice":49999,"quantity":1}
],
"memberLevel":"REGULAR","couponType":"RATE_10","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.couponDiscount").value(0))
.andExpect(jsonPath("$.lines[0].couponDiscount").value(0))
.andExpect(jsonPath("$.lines[0].finalAmount").value(49_999))
.andExpect(jsonPath("$.grandTotal").value(52_999));
}
@Test
void doesNotDiscountFashionBelowQuantityThreshold() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"fashion-1","category":"FASHION","unitPrice":10000,"quantity":2}
],
"memberLevel":"REGULAR","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.itemDiscount").value(0))
.andExpect(jsonPath("$.lines[0].itemDiscount").value(0))
.andExpect(jsonPath("$.lines[0].finalAmount").value(20_000))
.andExpect(jsonPath("$.grandTotal").value(23_000));
}
@Test
void givesFreeBaseShippingAtExactThreshold() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"food-1","category":"FOOD","unitPrice":25000,"quantity":2}
],
"memberLevel":"REGULAR","couponType":"NONE","region":"LOCAL"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.merchandiseTotal").value(50_000))
.andExpect(jsonPath("$.shippingFee").value(0))
.andExpect(jsonPath("$.grandTotal").value(50_000));
}
@Test
void acceptsZeroPricedLinesWithoutDividingByZero() throws Exception {
mockMvc.perform(request("""
{
"lines": [
{"productId":"zero","category":"FOOD","unitPrice":0,"quantity":1}
],
"memberLevel":"GOLD","couponType":"RATE_10","region":"REMOTE"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.itemSubtotal").value(0))
.andExpect(jsonPath("$.memberDiscount").value(0))
.andExpect(jsonPath("$.couponDiscount").value(0))
.andExpect(jsonPath("$.merchandiseTotal").value(0))
.andExpect(jsonPath("$.shippingFee").value(8_000))
.andExpect(jsonPath("$.grandTotal").value(8_000))
.andExpect(jsonPath("$.lines[0].finalAmount").value(0));
}
@ParameterizedTest
@MethodSource("invalidRequests")
void returnsContractedErrorForInvalidInput(String json) throws Exception {
mockMvc.perform(request(json))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_PRICING_REQUEST"))
.andExpect(jsonPath("$.message").value("주문 정보를 확인해 주세요."));
}
private static Stream<String> invalidRequests() {
return Stream.of(
requestWithLines("[]"),
requestWithLines("null"),
requestWithLines("[null]"),
requestWithLineFields("\"productId\":null,\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1"),
requestWithLineFields("\"productId\":\" \",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1"),
requestWithLineFields("\"productId\":\"p\",\"category\":null,\"unitPrice\":1,\"quantity\":1"),
requestWithLineFields("\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":null,\"quantity\":1"),
requestWithLineFields("\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":-1,\"quantity\":1"),
requestWithLineFields("\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":null"),
requestWithLineFields("\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":0"),
completeRequest("[{\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1}]",
"null", "\"NONE\"", "\"LOCAL\""),
completeRequest("[{\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1}]",
"\"REGULAR\"", "null", "\"LOCAL\""),
completeRequest("[{\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1}]",
"\"REGULAR\"", "\"NONE\"", "null"),
completeRequest("[{\"productId\":\"p\",\"category\":\"FOOD\",\"unitPrice\":1,\"quantity\":1}]",
"\"PLATINUM\"", "\"NONE\"", "\"LOCAL\""),
requestWithLineFields("\"productId\":\"p\",\"category\":\"FOOD\","
+ "\"unitPrice\":9223372036854775808,\"quantity\":1"),
requestWithLineFields("\"productId\":\"p\",\"category\":\"FOOD\","
+ "\"unitPrice\":9223372036854775807,\"quantity\":2"),
requestWithLines("""
[
{"productId":"p1","category":"FOOD","unitPrice":9223372036854775807,"quantity":1},
{"productId":"p2","category":"FOOD","unitPrice":9223372036854775807,"quantity":1}
]
"""),
completeRequest(
"[{\"productId\":\"p\",\"category\":\"FOOD\","
+ "\"unitPrice\":9223372036854775807,\"quantity\":1}]",
"\"REGULAR\"", "\"NONE\"", "\"REMOTE\""));
}
private static org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request(
String json) {
return post(ENDPOINT).contentType(MediaType.APPLICATION_JSON).content(json);
}
private static String requestWithLines(String lines) {
return completeRequest(lines, "\"REGULAR\"", "\"NONE\"", "\"LOCAL\"");
}
private static String requestWithLineFields(String fields) {
return requestWithLines("[{" + fields + "}]");
}
private static String completeRequest(
String lines,
String memberLevel,
String couponType,
String region) {
return """
{
"lines": %s,
"memberLevel": %s,
"couponType": %s,
"region": %s
}
""".formatted(lines, memberLevel, couponType, region);
}
}