From f33753c5f3080cc799af38356d697fd51d01a873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=B8=EC=84=9D?= <66772624+HiiWee@users.noreply.github.com> Date: Tue, 27 Aug 2024 06:13:10 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B2=BD=EB=A7=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EA=B2=BD=EB=A7=A4=20=EC=8B=9C=EA=B0=84=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94?= =?UTF-8?q?=20=ED=95=A0=EC=9D=B8=20=EC=A3=BC=EA=B8=B0=EB=A5=BC=20=EB=8B=A4?= =?UTF-8?q?=EC=96=91=ED=95=98=EA=B2=8C=20=EC=84=A4=EC=A0=95=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 경매 입찰에 대한 동시성 테스트 추가 - 한정된 재고에서 구매할 수 있는 최대 수량까지만 구매 가능하고, 나머지는 구매하지 못하는 테스트 추가 - 포인트가 부족한 사람과 포인트가 넉넉한 사람이 구매할때, 순서가 섞여있다면, 포인트가 넉넉한 사람의 구매처리가 원자적으로 동작하는지 테스트 추가 * refactor: 기존 1, 5, 10분 경매 시간에서 경매 검증 방식에서 경매시간을 할인 주기로 나누었을때 나누어 떨어질때만 생성할 수 있게 검증을 변경한다. - 분, 초 상관없이 나누어 떨어지기만 하면 됨 * refactor: 경매 주기 시간에 대한 기준을 분 단위이고, 1시간을 넘지 않도록 검증하게 변경한다. - 기존에는 10분단위로 최대 60분이었음 - 변경한 부분은 분 단위면 가능하고, 1시간만 넘지 않도록 변경함 - 이에 경매 주기 시간을 더해 할인 주기 시간으로 나누었을때 나누어떨어지는 시간이어야 함 * chore: 테스트 스크립트 실패 확인을 위한 --info 옵션을 추가합니다 * feat: 임베디드 레디스 시작, 종료 시 로깅 추가 * feat: 테스트 마다 레디스 초기화 하도록 변경 * fix(test): 시간 정밀도 차이를 마이크로초까지 제한한 공통 LocalDateTime 필드를 사용하도록 테스트를 수정합니다. * feat: Entity에서 Enum값 `@Enumerated` 매핑 추가 * fix: 거래내역 ID 변경으로 인한 컴파일 에러 수정 - #310이 반영되면서 컴파일이 깨지는 오류를 수정합니다. --------- Co-authored-by: 유동근 <67232422+yudonggeun@users.noreply.github.com> Co-authored-by: yudonggeun --- .github/workflows/test_run.yml | 2 +- .../core/auction/domain/Auction.java | 43 +++-- .../core/member/entity/MemberEntity.java | 8 +- .../core/payment/entity/ReceiptEntity.java | 3 + .../global/exception/ErrorCode.java | 5 +- .../luckyvickyauction/global/util/Mapper.java | 5 +- src/main/resources/application-dev.yml | 2 +- src/main/resources/docs/index.html | 46 ++--- .../context/EmbeddedRedisConfig.java | 6 + .../context/ServiceTest.java | 5 + .../core/auction/domain/AuctionTest.java | 120 +++++++++++- .../auction/service/AuctionServiceTest.java | 34 ---- .../auctioneer/BasicAuctioneerTest.java | 176 ++++++++++++++++++ .../global/util/MapperMemberTest.java | 4 +- 14 files changed, 366 insertions(+), 93 deletions(-) create mode 100644 src/test/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneerTest.java diff --git a/.github/workflows/test_run.yml b/.github/workflows/test_run.yml index b7cdadd8..b7b6716e 100644 --- a/.github/workflows/test_run.yml +++ b/.github/workflows/test_run.yml @@ -27,5 +27,5 @@ jobs: cache: gradle - name: Run tests - run: ./gradlew clean test + run: ./gradlew clean test --info diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/auction/domain/Auction.java b/src/main/java/com/wootecam/luckyvickyauction/core/auction/domain/Auction.java index 0490d66d..fb939ce6 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/auction/domain/Auction.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/auction/domain/Auction.java @@ -11,9 +11,8 @@ public class Auction { private static final int MINIMUM_STOCK_COUNT = 1; - private static final int DURATION_ONE_MINUTE = 1; - private static final int DURATION_FIVE_MINUTE = 5; - private static final int DURATION_TEN_MINUTE = 10; + private static final long NANOS_IN_MINUTE = 60_000_000_000L; // 1분의 나노초 + private static final long MAX_AUCTION_DURATION_NANOS = 60 * NANOS_IN_MINUTE; // 60분의 나노초 private Long id; private final Long sellerId; @@ -65,24 +64,26 @@ public Auction( } private void validateAuctionTime(LocalDateTime startedAt, LocalDateTime finishedAt) { - Duration diff = Duration.between(startedAt, finishedAt); - long diffNanos = diff.toMillis(); - long tenMinutesInNanos = 10L * 60 * 1_000; // 10분을 나노초로 변환 + Duration duration = Duration.between(startedAt, finishedAt); + long durationNanos = duration.toNanos(); - if (!(diffNanos % tenMinutesInNanos == 0 && diffNanos / tenMinutesInNanos <= 6)) { - String message = String.format("경매 지속 시간은 10분 단위여야하고, 최대 60분까지만 가능합니다. 현재: %.9f분", - diffNanos / (60.0 * 1_000)); + if (durationNanos > MAX_AUCTION_DURATION_NANOS) { + long leftNanoSeconds = durationNanos % NANOS_IN_MINUTE; + String message = String.format("경매 지속 시간은 최대 60분까지만 가능합니다. 현재 초과되는 나노초: %d초", leftNanoSeconds); throw new BadRequestException(message, ErrorCode.A007); } + + if (durationNanos % NANOS_IN_MINUTE != 0) { + long leftNanoSeconds = durationNanos % NANOS_IN_MINUTE; + String message = String.format("경매 지속 시간은 정확히 분 단위여야 합니다. 현재 남는 나노초: %d초", leftNanoSeconds); + throw new BadRequestException(message, ErrorCode.A030); + } } private void validateVariationDuration(Duration variationDuration, Duration auctionDuration) { - if (!isAllowedDuration(variationDuration)) { - String message = String.format("경매 할인 주기 시간은 %d, %d, %d만 선택할 수 있습니다.", - DURATION_ONE_MINUTE, - DURATION_FIVE_MINUTE, - DURATION_TEN_MINUTE - ); + if (!isAllowedDuration(variationDuration, auctionDuration)) { + String message = String.format("경매 할인 주기는 경매 지속 시간에서 나누었을때 나누어 떨어져야 합니다. 할인 주기 시간(초): %d, 경매 주기 시간(초): %d", + variationDuration.getSeconds(), auctionDuration.getSeconds()); throw new BadRequestException(message, ErrorCode.A028); } @@ -92,10 +93,14 @@ private void validateVariationDuration(Duration variationDuration, Duration auct } } - private boolean isAllowedDuration(Duration duration) { - return duration.equals(Duration.ofMinutes(DURATION_ONE_MINUTE)) - || duration.equals(Duration.ofMinutes(DURATION_FIVE_MINUTE)) - || duration.equals(Duration.ofMinutes(DURATION_TEN_MINUTE)); + private boolean isAllowedDuration(Duration duration, Duration auctionDuration) { + if (duration.isZero() || auctionDuration.isZero()) { + return false; + } + long durationSeconds = duration.getSeconds(); + long auctionDurationSeconds = auctionDuration.getSeconds(); + + return auctionDurationSeconds % durationSeconds == 0; } private void validateMinimumPrice(LocalDateTime startedAt, LocalDateTime finishedAt, Duration variationDuration, diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/member/entity/MemberEntity.java b/src/main/java/com/wootecam/luckyvickyauction/core/member/entity/MemberEntity.java index ebbe95e0..f6b4d5b4 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/member/entity/MemberEntity.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/member/entity/MemberEntity.java @@ -1,6 +1,9 @@ package com.wootecam.luckyvickyauction.core.member.entity; +import com.wootecam.luckyvickyauction.core.member.domain.Role; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -20,11 +23,12 @@ public class MemberEntity { private Long id; private String signInId; private String password; - private String role; + @Enumerated(value = EnumType.STRING) + private Role role; private Long point; @Builder - private MemberEntity(Long id, String signInId, String password, String role, Long point) { + private MemberEntity(Long id, String signInId, String password, Role role, Long point) { this.id = id; this.signInId = signInId; this.password = password; diff --git a/src/main/java/com/wootecam/luckyvickyauction/core/payment/entity/ReceiptEntity.java b/src/main/java/com/wootecam/luckyvickyauction/core/payment/entity/ReceiptEntity.java index fe827154..1e6f6d5b 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/core/payment/entity/ReceiptEntity.java +++ b/src/main/java/com/wootecam/luckyvickyauction/core/payment/entity/ReceiptEntity.java @@ -2,6 +2,8 @@ import com.wootecam.luckyvickyauction.core.payment.domain.ReceiptStatus; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -24,6 +26,7 @@ public class ReceiptEntity { private String productName; private long price; private long quantity; + @Enumerated(value = EnumType.STRING) private ReceiptStatus receiptStatus; private long auctionId; private Long sellerId; diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java b/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java index bf7cdee3..9522ca3a 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java +++ b/src/main/java/com/wootecam/luckyvickyauction/global/exception/ErrorCode.java @@ -9,7 +9,7 @@ public enum ErrorCode { A004("가격 변동폭은 0과 같거나 작을 수 없습니다."), A005("변동 시간 단위는 0과 같거나 작을 수 없습니다."), A006("경매의 시작 시간은 종료 시간보다 클 수 없습니다."), - A007("경매 생성 시, 경매 지속시간은 10, 20, 30, 40, 50, 60분이 아닌 경우 예외가 발생합니다."), + A007("경매 생성 시, 경매 지속시간이 60분을 넘기는 경우 예외가 발생합니다."), A008("경매 가격 변동폭은 경매 가격보다 낮아야 합니다."), A009("경매 생성 시, 할인율이 0퍼센트 미만이거나 50 퍼센트를 초과한 경우 예외가 발생합니다."), A010("경매ID를 기준으로 경매를 찾으려고 했지만 찾을 수 없습니다."), @@ -30,8 +30,9 @@ public enum ErrorCode { A025("JSON을 가격 정책 객체로 변환 시, 변환이 불가능할 경우 예외가 발생합니다."), A026("경매 입찰 요청 시, 요청 가격이 0보다 작으면 예외가 발생합니다."), A027("경매 입찰 요청 시, 요청 수량이 1 미만일 경우 예외가 발생합니다."), - A028("경매 생성 시, 경매 할인 주기 시간이 1, 5, 10분이 아닌 경우 예외가 발생합니다."), + A028("경매 생성 시, 경매 지속 시간에서 경매 할인 주기 시간을 나누었을때, 나누어 떨어지지 않는 경우 예외가 발생합니다."), A029("경매 생성 시, 경매 할인 주기 시간이 경매 지속 시간보다 크거나 같은 경우 예외가 발생합니다."), + A030("경매 생성 시, 경매 지속 시간이 정확히 분 단위가 아닌 경우 예외가 발생합니다."), // Receipt 관련 예외 코드 R000("거래 내역 조회 시, 거래 내역을 찾을 수 없을 경우 예외가 발생합니다."), diff --git a/src/main/java/com/wootecam/luckyvickyauction/global/util/Mapper.java b/src/main/java/com/wootecam/luckyvickyauction/global/util/Mapper.java index 44154b93..771412a2 100644 --- a/src/main/java/com/wootecam/luckyvickyauction/global/util/Mapper.java +++ b/src/main/java/com/wootecam/luckyvickyauction/global/util/Mapper.java @@ -9,7 +9,6 @@ import com.wootecam.luckyvickyauction.core.auction.entity.AuctionEntity; import com.wootecam.luckyvickyauction.core.member.domain.Member; import com.wootecam.luckyvickyauction.core.member.domain.Point; -import com.wootecam.luckyvickyauction.core.member.domain.Role; import com.wootecam.luckyvickyauction.core.member.entity.MemberEntity; import com.wootecam.luckyvickyauction.core.payment.domain.Receipt; import com.wootecam.luckyvickyauction.core.payment.dto.BuyerReceiptSimpleInfo; @@ -198,7 +197,7 @@ public static Member convertToMember(MemberEntity entity) { .id(entity.getId()) .signInId(entity.getSignInId()) .password(entity.getPassword()) - .role(Role.find(entity.getRole())) + .role(entity.getRole()) .point(new Point(entity.getPoint())) .build(); } @@ -208,7 +207,7 @@ public static MemberEntity convertToMemberEntity(Member member) { .id(member.getId()) .signInId(member.getSignInId()) .password(member.getPassword()) - .role(member.getRole().name()) + .role(member.getRole()) .point(member.getPoint().getAmount()) .build(); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 618f3865..37b68bda 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -11,7 +11,7 @@ spring: database-platform: org.hibernate.dialect.MySQLDialect open-in-view: off hibernate: - ddl-auto: none + ddl-auto: create data: redis: diff --git a/src/main/resources/docs/index.html b/src/main/resources/docs/index.html index 32ad1920..23cd9ad6 100644 --- a/src/main/resources/docs/index.html +++ b/src/main/resources/docs/index.html @@ -835,14 +835,14 @@

@@ -1336,8 +1336,8 @@

@@ -1505,8 +1505,8 @@

@@ -1741,7 +1741,7 @@

@@ -1928,8 +1928,8 @@

@@ -2130,8 +2130,8 @@

@@ -2876,8 +2876,8 @@

@@ -3068,7 +3068,7 @@

Err

A007

-

경매 생성 시, 경매 지속시간은 10, 20, 30, 40, 50, 60분이 아닌 경우 예외가 발생합니다.

+

경매 생성 시, 경매 지속시간이 60분을 넘기는 경우 예외가 발생합니다.

A008

@@ -3152,13 +3152,17 @@

Err

A028

-

경매 생성 시, 경매 할인 주기 시간이 1, 5, 10분이 아닌 경우 예외가 발생합니다.

+

경매 생성 시, 경매 지속 시간에서 경매 할인 주기 시간을 나누었을때, 나누어 떨어지지 않는 경우 예외가 발생합니다.

A029

경매 생성 시, 경매 할인 주기 시간이 경매 지속 시간보다 크거나 같은 경우 예외가 발생합니다.

+

A030

+

경매 생성 시, 경매 지속 시간이 정확히 분 단위가 아닌 경우 예외가 발생합니다.

+ +

R000

거래 내역 조회 시, 거래 내역을 찾을 수 없을 경우 예외가 발생합니다.

diff --git a/src/test/java/com/wootecam/luckyvickyauction/context/EmbeddedRedisConfig.java b/src/test/java/com/wootecam/luckyvickyauction/context/EmbeddedRedisConfig.java index 96516633..78a6e781 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/context/EmbeddedRedisConfig.java +++ b/src/test/java/com/wootecam/luckyvickyauction/context/EmbeddedRedisConfig.java @@ -3,6 +3,8 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import redis.embedded.RedisServer; @@ -10,6 +12,8 @@ @Configuration public class EmbeddedRedisConfig { + private static final Logger log = LoggerFactory.getLogger(EmbeddedRedisConfig.class); + @Value("${spring.data.redis.port}") private int redisPort; @@ -26,12 +30,14 @@ public void redisServer() throws IOException { .build(); redisServer.start(); + log.info("Embedded Redis Server start!!!"); } @PreDestroy public void stopRedis() throws IOException { if (redisServer != null) { redisServer.stop(); + log.info("Embedded Redis Server stop!!!"); } } diff --git a/src/test/java/com/wootecam/luckyvickyauction/context/ServiceTest.java b/src/test/java/com/wootecam/luckyvickyauction/context/ServiceTest.java index dff7375e..4e3ce337 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/context/ServiceTest.java +++ b/src/test/java/com/wootecam/luckyvickyauction/context/ServiceTest.java @@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.data.redis.core.RedisTemplate; @SpringBootTest(webEnvironment = WebEnvironment.NONE) public abstract class ServiceTest { @@ -47,8 +48,12 @@ public abstract class ServiceTest { protected LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.MICROS); + @Autowired + protected RedisTemplate redisTemplate; + @AfterEach void tearDown() { databaseCleaner.clear(); + redisTemplate.getConnectionFactory().getConnection().serverCommands().flushAll(); } } diff --git a/src/test/java/com/wootecam/luckyvickyauction/core/auction/domain/AuctionTest.java b/src/test/java/com/wootecam/luckyvickyauction/core/auction/domain/AuctionTest.java index d92d873f..b9668dfa 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/core/auction/domain/AuctionTest.java +++ b/src/test/java/com/wootecam/luckyvickyauction/core/auction/domain/AuctionTest.java @@ -62,7 +62,7 @@ class 고정_가격_변동_정책을_이용하는_경우 { PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); LocalDateTime now = LocalDateTime.now(); - // when & then + // expect assertThatThrownBy(() -> Auction.builder() .sellerId(1L) @@ -251,7 +251,7 @@ class 만약_경매_할인_주기_시간이_경매_지속_시간보다_크거나 PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); LocalDateTime now = LocalDateTime.now(); - // when & then + // expect assertThatThrownBy(() -> Auction.builder() .sellerId(1L) @@ -273,10 +273,10 @@ class 만약_경매_할인_주기_시간이_경매_지속_시간보다_크거나 } @Nested - class 만약_경매_할인_주기_시간이_1분_5분_10분이_아니라면 { + class 경매_시간에서_할인_주기_시간이_나누어_떨어지지_않는다면 { @ParameterizedTest - @ValueSource(ints = {0, 2, 3, 4, 6, 7, 8, 9, 11}) + @ValueSource(ints = {11, 7, 13, 21, 31, 14}) void 예외가_발생한다(int invalidVariationDuration) { // given int originPrice = 10000; @@ -284,11 +284,11 @@ class 만약_경매_할인_주기_시간이_1분_5분_10분이_아니라면 { int maximumPurchaseLimitCount = 10; int variationWidth = 10000; - Duration varitationDuration = Duration.ofMinutes(invalidVariationDuration); + Duration varitationDuration = Duration.ofSeconds(invalidVariationDuration); PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); LocalDateTime now = LocalDateTime.now(); - // when & then + // expect assertThatThrownBy(() -> Auction.builder() .sellerId(1L) @@ -300,14 +300,118 @@ class 만약_경매_할인_주기_시간이_1분_5분_10분이_아니라면 { .pricePolicy(pricePolicy) .maximumPurchaseLimitCount(maximumPurchaseLimitCount) .variationDuration(varitationDuration) - .startedAt(now.plusMinutes(10L)) - .finishedAt(now.plusMinutes(70L)) + .startedAt(now) + .finishedAt(now.plusMinutes(60L)) .isShowStock(true) .build()) .isInstanceOf(BadRequestException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.A028); } } + + @Nested + class 경매_주기_시간이_60분을_넘긴다면 { + + @Test + void 예외가_발생합니다() { + // given + LocalDateTime startedAt = LocalDateTime.now(); + LocalDateTime finishedAt = startedAt.plusMinutes(60).plusNanos(1L); + int originPrice = 10000; + int stock = 999999; + int maximumPurchaseLimitCount = 10; + + int variationWidth = 1000; + PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); + + // expect + assertThatThrownBy(() -> Auction.builder() + .sellerId(1L) + .productName("상품이름") + .originPrice(originPrice) + .currentPrice(originPrice) + .originStock(stock) + .currentStock(stock) + .pricePolicy(pricePolicy) + .maximumPurchaseLimitCount(maximumPurchaseLimitCount) + .variationDuration(Duration.ofMinutes(10)) + .startedAt(startedAt) + .finishedAt(finishedAt) + .isShowStock(true) + .build()) + .isInstanceOf(BadRequestException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.A007); + } + } + + @Nested + class 경매_주기_시간이_분_단위가_아니라면 { + + @Test + void 예외가_발생합니다() { + // given + LocalDateTime startedAt = LocalDateTime.now(); + LocalDateTime finishedAt = startedAt.plusMinutes(1).plusNanos(1L); + int originPrice = 10000; + int stock = 999999; + int maximumPurchaseLimitCount = 10; + + int variationWidth = 1000; + PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); + + // expect + assertThatThrownBy(() -> Auction.builder() + .sellerId(1L) + .productName("상품이름") + .originPrice(originPrice) + .currentPrice(originPrice) + .originStock(stock) + .currentStock(stock) + .pricePolicy(pricePolicy) + .maximumPurchaseLimitCount(maximumPurchaseLimitCount) + .variationDuration(Duration.ofSeconds(1L)) + .startedAt(startedAt) + .finishedAt(finishedAt) + .isShowStock(true) + .build()) + .isInstanceOf(BadRequestException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.A030); + } + } + + @Nested + class 경매_시간에서_할인_주기_시간이_나누어_떨어진다면 { + + @ParameterizedTest + @ValueSource(ints = {10, 6, 12, 30, 20, 15}) + void 경매를_정상_생성한다(int invalidVariationDuration) { + // given + int originPrice = 10000; + int stock = 999999; + int maximumPurchaseLimitCount = 10; + + int variationWidth = 1000; + Duration varitationDuration = Duration.ofSeconds(invalidVariationDuration); + PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); + LocalDateTime now = LocalDateTime.now(); + + // expect + assertThatNoException().isThrownBy(() -> Auction.builder() + .sellerId(1L) + .productName("상품이름") + .originPrice(originPrice) + .currentPrice(originPrice) + .originStock(stock) + .currentStock(stock) + .pricePolicy(pricePolicy) + .maximumPurchaseLimitCount(maximumPurchaseLimitCount) + .variationDuration(varitationDuration) + .startedAt(now) + .finishedAt(now.plusMinutes(1L)) + .isShowStock(true) + .build()); + } + } } @Nested diff --git a/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/AuctionServiceTest.java b/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/AuctionServiceTest.java index 509b8f1f..aefb8533 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/AuctionServiceTest.java +++ b/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/AuctionServiceTest.java @@ -119,40 +119,6 @@ class 경매의_지속시간이_최소10분_최대60분이면 { assertThatNoException().isThrownBy(() -> auctionService.createAuction(sellerInfo, command)); } } - - @Nested - class 만약_경매_지속시간이_10분_단위가_아니라면 { - - @ParameterizedTest - @ValueSource(ints = {9, 11, 19, 21, 29, 31, 39, 41, 49, 51, 59, 61}) - void 예외가_발생한다(int invalidDurationTime) { - // given - Long sellerId = 1L; - String productName = "상품이름"; - int originPrice = 10000; - int stock = 999999; - int maximumPurchaseLimitCount = 10; - - int variationWidth = 1000; - Duration varitationDuration = Duration.ofMinutes(1L); - PricePolicy pricePolicy = new ConstantPricePolicy(variationWidth); - - LocalDateTime startedAt = now.plusHours(1); - LocalDateTime finishedAt = startedAt.plusMinutes(invalidDurationTime); - - SignInInfo sellerInfo = new SignInInfo(sellerId, Role.SELLER); - CreateAuctionCommand command = new CreateAuctionCommand( - productName, originPrice, stock, maximumPurchaseLimitCount, pricePolicy, - varitationDuration, now, startedAt, finishedAt, true - ); - - // expect - assertThatThrownBy(() -> auctionService.createAuction(sellerInfo, command)) - .isInstanceOf(BadRequestException.class) - .satisfies(exception -> assertThat(exception).hasFieldOrPropertyWithValue("errorCode", - ErrorCode.A007)); - } - } } @Nested diff --git a/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneerTest.java b/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneerTest.java new file mode 100644 index 00000000..8c3cea69 --- /dev/null +++ b/src/test/java/com/wootecam/luckyvickyauction/core/auction/service/auctioneer/BasicAuctioneerTest.java @@ -0,0 +1,176 @@ +package com.wootecam.luckyvickyauction.core.auction.service.auctioneer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.wootecam.luckyvickyauction.context.ServiceTest; +import com.wootecam.luckyvickyauction.core.auction.domain.Auction; +import com.wootecam.luckyvickyauction.core.auction.domain.ConstantPricePolicy; +import com.wootecam.luckyvickyauction.core.member.domain.Member; +import com.wootecam.luckyvickyauction.core.member.domain.Point; +import com.wootecam.luckyvickyauction.core.member.domain.Role; +import com.wootecam.luckyvickyauction.core.member.fixture.MemberFixture; +import com.wootecam.luckyvickyauction.global.dto.AuctionPurchaseRequestMessage; +import java.time.Duration; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; + +class BasicAuctioneerTest extends ServiceTest { + + @Nested + class 동시성_테스트 { + + @Nested + class 재고보다_많은_구매_요청이_들어오면 { + + @RepeatedTest(10) + void 재고_수_만큼만_판매해야_한다() throws InterruptedException { + // given + Member seller = memberRepository.save(MemberFixture.createBuyerWithDefaultPoint()); + Member buyer = memberRepository.save(Member.builder() + .signInId("buyerId") + .password("password00") + .role(Role.BUYER) + .point(new Point(1000000000L)) + .build()); + + Auction auction = auctionRepository.save(Auction.builder() + .sellerId(seller.getId()) + .productName("상품 이름") + .originPrice(1000L) + .currentPrice(1000L) + .originStock(10L) + .currentStock(10L) + .maximumPurchaseLimitCount(5) + .pricePolicy(new ConstantPricePolicy(100L)) + .variationDuration(Duration.ofMinutes(10)) + .startedAt(now) + .finishedAt(now.plusHours(1)) + .build()); + + int numThreads = 10; + CountDownLatch doneSignal = new CountDownLatch(numThreads); + ExecutorService executorService = Executors.newFixedThreadPool(numThreads); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // when + for (int i = 0; i < numThreads; i++) { + executorService.execute(() -> { + try { + auctioneer.process( + new AuctionPurchaseRequestMessage(UUID.randomUUID(), buyer.getId(), + auction.getId(), 1000L, 2L, now)); + successCount.getAndIncrement(); + } catch (Exception e) { + e.printStackTrace(); + failCount.getAndIncrement(); + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(); + executorService.shutdown(); + + assertAll( + () -> assertThat(successCount.get()).isEqualTo(5L), + () -> assertThat(failCount.get()).isEqualTo(5L) + ); + } + } + + @Nested + class 두명_중_한명이_포인트가_부족하다면 { + + @RepeatedTest(10) + void 다른_한_명의_구매_과정이_원자적으로_처리되어야_한다() throws InterruptedException { + // given + Member seller = memberRepository.save(MemberFixture.createBuyerWithDefaultPoint()); + Member buyer1 = memberRepository.save(Member.builder() + .signInId("buyerId1") + .password("password00") + .role(Role.BUYER) + .point(new Point(999L)) + .build()); + Member buyer2 = memberRepository.save(Member.builder() + .signInId("buyerId2") + .password("password00") + .role(Role.BUYER) + .point(new Point(10000L)) + .build()); + + Auction auction = auctionRepository.save(Auction.builder() + .sellerId(seller.getId()) + .productName("상품 이름") + .originPrice(1000L) + .currentPrice(1000L) + .originStock(10L) + .currentStock(10L) + .maximumPurchaseLimitCount(5) + .pricePolicy(new ConstantPricePolicy(100L)) + .variationDuration(Duration.ofMinutes(10)) + .startedAt(now) + .finishedAt(now.plusHours(1)) + .build()); + + int numThreads = 11; + CountDownLatch doneSignal = new CountDownLatch(numThreads); + ExecutorService executorService = Executors.newFixedThreadPool(numThreads); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + Random random = new Random(); + int randomNumber = random.nextInt(11); + + // when + for (int i = 0; i < numThreads; i++) { + int currentIndex = i; + executorService.execute(() -> { + try { + if (currentIndex == randomNumber) { + auctioneer.process( + new AuctionPurchaseRequestMessage(UUID.randomUUID(), buyer1.getId(), + auction.getId(), 1000L, 1L, now)); + } else { + auctioneer.process( + new AuctionPurchaseRequestMessage(UUID.randomUUID(), buyer2.getId(), + auction.getId(), 1000L, 1L, now)); + } + + successCount.getAndIncrement(); + } catch (Exception e) { + e.printStackTrace(); + failCount.getAndIncrement(); + } finally { + doneSignal.countDown(); + } + }); + } + doneSignal.await(); + executorService.shutdown(); + + // then + long buyer1Point = memberRepository.findById(buyer1.getId()).get().getPoint().getAmount(); + long buyer2Point = memberRepository.findById(buyer2.getId()).get().getPoint().getAmount(); + long currentStock = auctionRepository.findById(auction.getId()).get().getCurrentStock(); + + assertAll( + () -> assertThat(successCount.get()).isEqualTo(10L), + () -> assertThat(failCount.get()).isEqualTo(1L), + () -> assertThat(buyer1Point).isEqualTo(999L), + () -> assertThat(buyer2Point).isEqualTo(0L), + () -> assertThat(currentStock).isEqualTo(0L) + ); + } + } + } +} diff --git a/src/test/java/com/wootecam/luckyvickyauction/global/util/MapperMemberTest.java b/src/test/java/com/wootecam/luckyvickyauction/global/util/MapperMemberTest.java index 0974c5b3..706cc1cb 100644 --- a/src/test/java/com/wootecam/luckyvickyauction/global/util/MapperMemberTest.java +++ b/src/test/java/com/wootecam/luckyvickyauction/global/util/MapperMemberTest.java @@ -24,7 +24,7 @@ class 회원_영속성_엔티티를 { .id(1L) .signInId("helloworld") .password("password1234") - .role("BUYER") + .role(Role.BUYER) .point(12345L) .build(); @@ -63,7 +63,7 @@ class 회원_도메인_엔티티를 { () -> assertThat(entity.getId()).isEqualTo(1L), () -> assertThat(entity.getSignInId()).isEqualTo("helloworld"), () -> assertThat(entity.getPassword()).isEqualTo("password1234"), - () -> assertThat(entity.getRole()).isEqualTo(Role.BUYER.name()), + () -> assertThat(entity.getRole()).isEqualTo(Role.BUYER), () -> assertThat(entity.getPoint()).isEqualTo(12345L) ); }