diff --git a/src/main/java/camp/woowak/lab/coupon/domain/Coupon.java b/src/main/java/camp/woowak/lab/coupon/domain/Coupon.java index e4ce24a5..18f76cd6 100644 --- a/src/main/java/camp/woowak/lab/coupon/domain/Coupon.java +++ b/src/main/java/camp/woowak/lab/coupon/domain/Coupon.java @@ -6,6 +6,7 @@ import org.springframework.format.annotation.DateTimeFormat; +import camp.woowak.lab.coupon.exception.InsufficientCouponQuantityException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -47,4 +48,19 @@ public Coupon(String title, int discountAmount, int quantity, LocalDateTime expi this.quantity = quantity; this.expiredAt = expiredAt; } + + public boolean isExpired() { + return expiredAt.isBefore(LocalDateTime.now()); + } + + public boolean hasAvailableQuantity() { + return quantity > 0; + } + + public void decreaseQuantity() { + if (!hasAvailableQuantity()) { + throw new InsufficientCouponQuantityException("quantity is not enough"); + } + quantity--; + } } diff --git a/src/main/java/camp/woowak/lab/coupon/domain/CouponIssuance.java b/src/main/java/camp/woowak/lab/coupon/domain/CouponIssuance.java new file mode 100644 index 00000000..6dc7e4ef --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/domain/CouponIssuance.java @@ -0,0 +1,51 @@ +package camp.woowak.lab.coupon.domain; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; + +import camp.woowak.lab.customer.domain.Customer; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; + +@Entity +@Getter +public class CouponIssuance { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "coupon_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Coupon coupon; + + @JoinColumn(name = "customer_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Customer customer; + + @Column(nullable = false, updatable = false) + @CreatedDate + private LocalDateTime issuedAt; + + @Column + private LocalDateTime usedAt; + + protected CouponIssuance() { + } + + public CouponIssuance(Coupon coupon, Customer customer) { + CouponIssuanceValidator.validate(customer, coupon); + // coupon 수량 감소 + coupon.decreaseQuantity(); + this.coupon = coupon; + this.customer = customer; + this.issuedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/domain/CouponIssuanceValidator.java b/src/main/java/camp/woowak/lab/coupon/domain/CouponIssuanceValidator.java new file mode 100644 index 00000000..6dd566fa --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/domain/CouponIssuanceValidator.java @@ -0,0 +1,30 @@ +package camp.woowak.lab.coupon.domain; + +import camp.woowak.lab.coupon.exception.ExpiredCouponException; +import camp.woowak.lab.coupon.exception.InvalidICreationIssuanceException; +import camp.woowak.lab.customer.domain.Customer; + +public class CouponIssuanceValidator { + private CouponIssuanceValidator() { + } + + public static void validate(Customer customer, Coupon coupon) { + validateNotNull(customer, coupon); + validateNotExpired(coupon); + + } + + private static void validateNotExpired(Coupon coupon) { + if (coupon.isExpired()) { + throw new ExpiredCouponException("만료된 쿠폰입니다."); + } + } + + private static void validateNotNull(Object... objects) { + for (var object : objects) { + if (object == null) { + throw new InvalidICreationIssuanceException("쿠폰 발급에 필요한 정보가 없습니다."); + } + } + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/exception/CouponErrorCode.java b/src/main/java/camp/woowak/lab/coupon/exception/CouponErrorCode.java index 100b6c0b..8e1279d1 100644 --- a/src/main/java/camp/woowak/lab/coupon/exception/CouponErrorCode.java +++ b/src/main/java/camp/woowak/lab/coupon/exception/CouponErrorCode.java @@ -7,6 +7,7 @@ public enum CouponErrorCode implements ErrorCode { INVALID_CREATION(HttpStatus.BAD_REQUEST, "cp_1_1", "잘못된 요청입니다."), DUPLICATE_COUPON_TITLE(HttpStatus.CONFLICT, "cp_1_2", "중복된 쿠폰 제목입니다."), + INSUFFICIENT_QUANTITY(HttpStatus.CONFLICT, "cp_1_3", "발급 가능한 쿠폰이 부족합니다."), ; int status; diff --git a/src/main/java/camp/woowak/lab/coupon/exception/CouponIssuanceErrorCode.java b/src/main/java/camp/woowak/lab/coupon/exception/CouponIssuanceErrorCode.java new file mode 100644 index 00000000..64a752f8 --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/exception/CouponIssuanceErrorCode.java @@ -0,0 +1,38 @@ +package camp.woowak.lab.coupon.exception; + +import org.springframework.http.HttpStatus; + +import camp.woowak.lab.common.exception.ErrorCode; + +public enum CouponIssuanceErrorCode implements ErrorCode { + INVALID_ISSUANCE(HttpStatus.BAD_REQUEST, "ci_1_1", "잘못된 발급 요청입니다."), + NOT_FOUND_COUPON(HttpStatus.NOT_FOUND, "ci_1_2", "존재하지 않는 쿠폰입니다."), + EXPIRED_COUPON(HttpStatus.CONFLICT, "ci_1_3", "만료된 쿠폰입니다."), + NOT_ENOUGH_COUPON(HttpStatus.CONFLICT, "ci_1_4", "쿠폰이 부족합니다."), + ; + + int status; + String errorCode; + String message; + + CouponIssuanceErrorCode(HttpStatus status, String errorCode, String message) { + this.status = status.value(); + this.errorCode = errorCode; + this.message = message; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getErrorCode() { + return errorCode; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/exception/DuplicateCouponTitleException.java b/src/main/java/camp/woowak/lab/coupon/exception/DuplicateCouponTitleException.java index 646bbcd8..510ddccf 100644 --- a/src/main/java/camp/woowak/lab/coupon/exception/DuplicateCouponTitleException.java +++ b/src/main/java/camp/woowak/lab/coupon/exception/DuplicateCouponTitleException.java @@ -1,8 +1,8 @@ package camp.woowak.lab.coupon.exception; -import camp.woowak.lab.common.exception.BadRequestException; +import camp.woowak.lab.common.exception.ConflictException; -public class DuplicateCouponTitleException extends BadRequestException { +public class DuplicateCouponTitleException extends ConflictException { public DuplicateCouponTitleException(String message) { super(CouponErrorCode.DUPLICATE_COUPON_TITLE, message); } diff --git a/src/main/java/camp/woowak/lab/coupon/exception/ExpiredCouponException.java b/src/main/java/camp/woowak/lab/coupon/exception/ExpiredCouponException.java new file mode 100644 index 00000000..0a03f74a --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/exception/ExpiredCouponException.java @@ -0,0 +1,9 @@ +package camp.woowak.lab.coupon.exception; + +import camp.woowak.lab.common.exception.ConflictException; + +public class ExpiredCouponException extends ConflictException { + public ExpiredCouponException(String message) { + super(CouponIssuanceErrorCode.EXPIRED_COUPON, message); + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/exception/InsufficientCouponQuantityException.java b/src/main/java/camp/woowak/lab/coupon/exception/InsufficientCouponQuantityException.java new file mode 100644 index 00000000..5a97df10 --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/exception/InsufficientCouponQuantityException.java @@ -0,0 +1,9 @@ +package camp.woowak.lab.coupon.exception; + +import camp.woowak.lab.common.exception.ConflictException; + +public class InsufficientCouponQuantityException extends ConflictException { + public InsufficientCouponQuantityException(String message) { + super(CouponErrorCode.INSUFFICIENT_QUANTITY, message); + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/exception/InvalidICreationIssuanceException.java b/src/main/java/camp/woowak/lab/coupon/exception/InvalidICreationIssuanceException.java new file mode 100644 index 00000000..83924bf4 --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/exception/InvalidICreationIssuanceException.java @@ -0,0 +1,9 @@ +package camp.woowak.lab.coupon.exception; + +import camp.woowak.lab.common.exception.BadRequestException; + +public class InvalidICreationIssuanceException extends BadRequestException { + public InvalidICreationIssuanceException(String message) { + super(CouponIssuanceErrorCode.INVALID_ISSUANCE, message); + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/repository/CouponIssuanceRepository.java b/src/main/java/camp/woowak/lab/coupon/repository/CouponIssuanceRepository.java new file mode 100644 index 00000000..fb345111 --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/repository/CouponIssuanceRepository.java @@ -0,0 +1,8 @@ +package camp.woowak.lab.coupon.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import camp.woowak.lab.coupon.domain.CouponIssuance; + +public interface CouponIssuanceRepository extends JpaRepository { +} diff --git a/src/main/java/camp/woowak/lab/coupon/repository/CouponRepository.java b/src/main/java/camp/woowak/lab/coupon/repository/CouponRepository.java index b204e72a..0ba029e1 100644 --- a/src/main/java/camp/woowak/lab/coupon/repository/CouponRepository.java +++ b/src/main/java/camp/woowak/lab/coupon/repository/CouponRepository.java @@ -1,8 +1,16 @@ package camp.woowak.lab.coupon.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import camp.woowak.lab.coupon.domain.Coupon; +import jakarta.persistence.LockModeType; public interface CouponRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Coupon c WHERE c.id = :id") + Optional findByIdWithPessimisticLock(Long id); } diff --git a/src/main/java/camp/woowak/lab/coupon/service/IssueCouponService.java b/src/main/java/camp/woowak/lab/coupon/service/IssueCouponService.java new file mode 100644 index 00000000..d85d5b5f --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/service/IssueCouponService.java @@ -0,0 +1,57 @@ +package camp.woowak.lab.coupon.service; + +import org.springframework.stereotype.Service; + +import camp.woowak.lab.coupon.domain.Coupon; +import camp.woowak.lab.coupon.domain.CouponIssuance; +import camp.woowak.lab.coupon.exception.ExpiredCouponException; +import camp.woowak.lab.coupon.exception.InsufficientCouponQuantityException; +import camp.woowak.lab.coupon.exception.InvalidICreationIssuanceException; +import camp.woowak.lab.coupon.repository.CouponIssuanceRepository; +import camp.woowak.lab.coupon.repository.CouponRepository; +import camp.woowak.lab.coupon.service.command.IssueCouponCommand; +import camp.woowak.lab.customer.domain.Customer; +import camp.woowak.lab.customer.repository.CustomerRepository; +import jakarta.transaction.Transactional; + +@Service +public class IssueCouponService { + private final CouponIssuanceRepository couponIssuanceRepository; + private final CouponRepository couponRepository; + private final CustomerRepository customerRepository; + + public IssueCouponService(CouponIssuanceRepository couponIssuanceRepository, CouponRepository couponRepository, + CustomerRepository customerRepository) { + this.couponIssuanceRepository = couponIssuanceRepository; + this.couponRepository = couponRepository; + this.customerRepository = customerRepository; + } + + /** + * + * @throws InvalidICreationIssuanceException customer 또는 coupon이 존재하지 않을 경우 + * @throws ExpiredCouponException coupon이 만료되었을 경우 + * @throws InsufficientCouponQuantityException coupon 수량이 부족할 경우 + */ + @Transactional + public Long issueCoupon(IssueCouponCommand cmd) { + // customer 조회 + Customer targetCustomer = customerRepository.findById(cmd.customerId()) + .orElseThrow(() -> new InvalidICreationIssuanceException("customer not found")); + + // coupon 조회 + Coupon targetCoupon = couponRepository.findByIdWithPessimisticLock(cmd.couponId()) + .orElseThrow(() -> new InvalidICreationIssuanceException("coupon not found")); + + // coupon 수량 확인 + if (!targetCoupon.hasAvailableQuantity()) { + throw new InsufficientCouponQuantityException("quantity of coupon is insufficient"); + } + + // coupon issuance 생성 + CouponIssuance newCouponIssuance = new CouponIssuance(targetCoupon, targetCustomer); + + // coupon issuance 저장 + return couponIssuanceRepository.save(newCouponIssuance).getId(); + } +} diff --git a/src/main/java/camp/woowak/lab/coupon/service/command/IssueCouponCommand.java b/src/main/java/camp/woowak/lab/coupon/service/command/IssueCouponCommand.java new file mode 100644 index 00000000..a2324396 --- /dev/null +++ b/src/main/java/camp/woowak/lab/coupon/service/command/IssueCouponCommand.java @@ -0,0 +1,6 @@ +package camp.woowak.lab.coupon.service.command; + +import java.util.UUID; + +public record IssueCouponCommand(UUID customerId, Long couponId) { +} diff --git a/src/main/java/camp/woowak/lab/customer/repository/CustomerRepository.java b/src/main/java/camp/woowak/lab/customer/repository/CustomerRepository.java index 615781b0..28be0c62 100644 --- a/src/main/java/camp/woowak/lab/customer/repository/CustomerRepository.java +++ b/src/main/java/camp/woowak/lab/customer/repository/CustomerRepository.java @@ -1,11 +1,12 @@ package camp.woowak.lab.customer.repository; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import camp.woowak.lab.customer.domain.Customer; -public interface CustomerRepository extends JpaRepository { +public interface CustomerRepository extends JpaRepository { Optional findByEmail(String email); } diff --git a/src/main/java/camp/woowak/lab/web/api/coupon/CouponApiController.java b/src/main/java/camp/woowak/lab/web/api/coupon/CouponApiController.java index be2f35ff..0b355d40 100644 --- a/src/main/java/camp/woowak/lab/web/api/coupon/CouponApiController.java +++ b/src/main/java/camp/woowak/lab/web/api/coupon/CouponApiController.java @@ -1,23 +1,31 @@ package camp.woowak.lab.web.api.coupon; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import camp.woowak.lab.coupon.service.CreateCouponService; +import camp.woowak.lab.coupon.service.IssueCouponService; import camp.woowak.lab.coupon.service.command.CreateCouponCommand; +import camp.woowak.lab.coupon.service.command.IssueCouponCommand; +import camp.woowak.lab.web.authentication.LoginCustomer; +import camp.woowak.lab.web.authentication.annotation.AuthenticationPrincipal; import camp.woowak.lab.web.dto.request.coupon.CreateCouponRequest; import camp.woowak.lab.web.dto.response.coupon.CreateCouponResponse; +import camp.woowak.lab.web.dto.response.coupon.IssueCouponResponse; import jakarta.validation.Valid; @RestController public class CouponApiController { private final CreateCouponService createCouponService; + private final IssueCouponService issueCouponService; - public CouponApiController(CreateCouponService createCouponService) { + public CouponApiController(CreateCouponService createCouponService, IssueCouponService issueCouponService) { this.createCouponService = createCouponService; + this.issueCouponService = issueCouponService; } @PostMapping("/coupons") @@ -31,4 +39,14 @@ public CreateCouponResponse createCoupon(@Valid @RequestBody CreateCouponRequest return new CreateCouponResponse(couponId); } + @PostMapping("/coupons/{couponId}/issue") + @ResponseStatus(HttpStatus.CREATED) + public IssueCouponResponse issueCoupon(@AuthenticationPrincipal LoginCustomer loginCustomer, + @PathVariable Long couponId) { + IssueCouponCommand cmd = new IssueCouponCommand(loginCustomer.getId(), couponId); + + issueCouponService.issueCoupon(cmd); + + return new IssueCouponResponse(); + } } diff --git a/src/main/java/camp/woowak/lab/web/api/coupon/CouponExceptionHandler.java b/src/main/java/camp/woowak/lab/web/api/coupon/CouponExceptionHandler.java index bffc3ab4..65dd9307 100644 --- a/src/main/java/camp/woowak/lab/web/api/coupon/CouponExceptionHandler.java +++ b/src/main/java/camp/woowak/lab/web/api/coupon/CouponExceptionHandler.java @@ -7,6 +7,8 @@ import camp.woowak.lab.common.advice.DomainExceptionHandler; import camp.woowak.lab.common.exception.HttpStatusException; import camp.woowak.lab.coupon.exception.DuplicateCouponTitleException; +import camp.woowak.lab.coupon.exception.ExpiredCouponException; +import camp.woowak.lab.coupon.exception.InvalidICreationIssuanceException; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -23,6 +25,22 @@ public ProblemDetail handleDuplicateCouponTitleException(DuplicateCouponTitleExc return getProblemDetail(e, HttpStatus.CONFLICT); } + /** + * + * InvalidICreationIssuanceException.class 를 처리한다. + */ + @ExceptionHandler(value = InvalidICreationIssuanceException.class) + public ProblemDetail handleInvalidICreationIssuanceException(InvalidICreationIssuanceException e) { + log.warn("Bad Request", e.getMessage()); + return getProblemDetail(e, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(value = ExpiredCouponException.class) + public ProblemDetail handleExpiredCouponException(ExpiredCouponException e) { + log.warn("Conflict", e.getMessage()); + return getProblemDetail(e, HttpStatus.CONFLICT); + } + private ProblemDetail getProblemDetail(HttpStatusException e, HttpStatus status) { ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, e.errorCode().getMessage()); problemDetail.setProperty("errorCode", e.errorCode().getErrorCode()); diff --git a/src/main/java/camp/woowak/lab/web/dto/response/coupon/IssueCouponResponse.java b/src/main/java/camp/woowak/lab/web/dto/response/coupon/IssueCouponResponse.java new file mode 100644 index 00000000..ca3d0654 --- /dev/null +++ b/src/main/java/camp/woowak/lab/web/dto/response/coupon/IssueCouponResponse.java @@ -0,0 +1,4 @@ +package camp.woowak.lab.web.dto.response.coupon; + +public record IssueCouponResponse() { +} diff --git a/src/test/java/camp/woowak/lab/coupon/domain/CouponIssuanceTest.java b/src/test/java/camp/woowak/lab/coupon/domain/CouponIssuanceTest.java new file mode 100644 index 00000000..c585d71b --- /dev/null +++ b/src/test/java/camp/woowak/lab/coupon/domain/CouponIssuanceTest.java @@ -0,0 +1,91 @@ +package camp.woowak.lab.coupon.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import camp.woowak.lab.coupon.exception.ExpiredCouponException; +import camp.woowak.lab.coupon.exception.InvalidICreationIssuanceException; +import camp.woowak.lab.customer.domain.Customer; +import camp.woowak.lab.fixture.CouponFixture; +import camp.woowak.lab.fixture.CustomerFixture; +import camp.woowak.lab.payaccount.domain.PayAccount; +import camp.woowak.lab.web.authentication.NoOpPasswordEncoder; +import camp.woowak.lab.web.authentication.PasswordEncoder; + +class CouponIssuanceTest implements CouponFixture, CustomerFixture { + private static PasswordEncoder passwordEncoder; + + @BeforeAll + static void setUpAll() { + passwordEncoder = new NoOpPasswordEncoder(); + } + + @Test + @DisplayName("CouponIssuance 생성 테스트") + void testConstruct() { + // given + Long fakeCouponId = 1L; + String title = "할인 쿠폰"; + int discountAmount = 1000; + int quantity = 100; + LocalDateTime expiredAt = LocalDateTime.now().plusDays(7); + PayAccount payAccount = createPayAccount(); + Coupon coupon = createCoupon(fakeCouponId, title, discountAmount, quantity, expiredAt); + Customer customer = createCustomer(payAccount, passwordEncoder); + + // when + CouponIssuance couponIssuance = new CouponIssuance(coupon, customer); + + // then + assertEquals(coupon, couponIssuance.getCoupon()); + assertEquals(customer, couponIssuance.getCustomer()); + assertNotNull(couponIssuance.getIssuedAt()); + assertNull(couponIssuance.getUsedAt()); + } + + @Test + @DisplayName("CouponIssuance 생성 테스트 - 쿠폰이 null 인 경우") + void testConstructWithNullCoupon() { + // given + Coupon coupon = null; + Customer customer = createCustomer(createPayAccount(), passwordEncoder); + + // when & then + assertThrows(InvalidICreationIssuanceException.class, () -> new CouponIssuance(coupon, customer)); + } + + @Test + @DisplayName("CouponIssuance 생성 테스트 - 고객이 null 인 경우") + void testConstructWithNullCustomer() { + // given + Coupon coupon = createCoupon(1L, "할인 쿠폰", 1000, 100, LocalDateTime.now().plusDays(7)); + Customer customer = null; + + // when & then + assertThrows(InvalidICreationIssuanceException.class, () -> new CouponIssuance(coupon, customer)); + } + + @Test + @DisplayName("CouponIssuance 생성 테스트 - 쿠폰이 만료되었을 때") + void testConstructWithExpiredCoupon() { + // given + Long fakeCouponId = 1L; + String title = "할인 쿠폰"; + int discountAmount = 1000; + int quantity = 100; + LocalDateTime expiredAt = LocalDateTime.now().minusDays(1); + PayAccount payAccount = createPayAccount(); + TestCoupon coupon = (TestCoupon)createCoupon(fakeCouponId, title, discountAmount, quantity, + LocalDateTime.now().plusDays(7)); + coupon.setExpiredAt(expiredAt); + Customer customer = createCustomer(payAccount, passwordEncoder); + + // when & then + assertThrows(ExpiredCouponException.class, () -> new CouponIssuance(coupon, customer)); + } +} diff --git a/src/test/java/camp/woowak/lab/coupon/domain/TestCoupon.java b/src/test/java/camp/woowak/lab/coupon/domain/TestCoupon.java index e6577002..5cf83d03 100644 --- a/src/test/java/camp/woowak/lab/coupon/domain/TestCoupon.java +++ b/src/test/java/camp/woowak/lab/coupon/domain/TestCoupon.java @@ -2,16 +2,48 @@ import java.time.LocalDateTime; +import camp.woowak.lab.coupon.exception.InsufficientCouponQuantityException; + public class TestCoupon extends Coupon { private final Long id; + private int quantity; + private LocalDateTime expiredAt; - public TestCoupon(Long id, String title, int discountAmount, int amount, LocalDateTime expiredAt) { - super(title, discountAmount, amount, expiredAt); + public TestCoupon(Long id, String title, int discountAmount, int quantity, LocalDateTime expiredAt) { + super(title, discountAmount, quantity, expiredAt); this.id = id; + this.expiredAt = expiredAt; + this.quantity = quantity; } @Override public Long getId() { return id; } + + public void setExpiredAt(LocalDateTime expiredAt) { + this.expiredAt = expiredAt; + } + + @Override + public boolean isExpired() { + return expiredAt.isBefore(LocalDateTime.now()); + } + + @Override + public void decreaseQuantity() { + if (!hasAvailableQuantity()) { + throw new InsufficientCouponQuantityException("쿠폰의 수량이 부족합니다."); + } + this.quantity--; + } + + @Override + public boolean hasAvailableQuantity() { + return this.quantity > 0; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } } diff --git a/src/test/java/camp/woowak/lab/coupon/domain/TestCouponIssuance.java b/src/test/java/camp/woowak/lab/coupon/domain/TestCouponIssuance.java new file mode 100644 index 00000000..4e80bcf2 --- /dev/null +++ b/src/test/java/camp/woowak/lab/coupon/domain/TestCouponIssuance.java @@ -0,0 +1,17 @@ +package camp.woowak.lab.coupon.domain; + +import camp.woowak.lab.customer.domain.Customer; + +public class TestCouponIssuance extends CouponIssuance { + private final Long id; + + public TestCouponIssuance(Long id, Coupon coupon, Customer customer) { + super(coupon, customer); + this.id = id; + } + + @Override + public Long getId() { + return id; + } +} diff --git a/src/test/java/camp/woowak/lab/coupon/service/IssueCouponServiceIntegrationTest.java b/src/test/java/camp/woowak/lab/coupon/service/IssueCouponServiceIntegrationTest.java new file mode 100644 index 00000000..5573e906 --- /dev/null +++ b/src/test/java/camp/woowak/lab/coupon/service/IssueCouponServiceIntegrationTest.java @@ -0,0 +1,157 @@ +package camp.woowak.lab.coupon.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +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.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import camp.woowak.lab.coupon.domain.Coupon; +import camp.woowak.lab.coupon.domain.CouponIssuance; +import camp.woowak.lab.coupon.exception.InsufficientCouponQuantityException; +import camp.woowak.lab.coupon.repository.CouponIssuanceRepository; +import camp.woowak.lab.coupon.repository.CouponRepository; +import camp.woowak.lab.coupon.service.command.IssueCouponCommand; +import camp.woowak.lab.customer.domain.Customer; +import camp.woowak.lab.customer.repository.CustomerRepository; +import camp.woowak.lab.fixture.CouponFixture; +import camp.woowak.lab.fixture.CustomerFixture; +import camp.woowak.lab.payaccount.domain.PayAccount; +import camp.woowak.lab.payaccount.repository.PayAccountRepository; +import camp.woowak.lab.web.authentication.NoOpPasswordEncoder; +import jakarta.transaction.Transactional; + +@SpringBootTest +class IssueCouponServiceIntegrationTest implements CouponFixture, CustomerFixture { + @Autowired + private IssueCouponService service; + + @Autowired + private CouponIssuanceRepository couponIssuanceRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private CustomerRepository customerRepository; + + @Autowired + private PayAccountRepository payAccountRepository; + + @Test + @DisplayName("쿠폰 발급 테스트 - 성공") + @Transactional + void testIssueCoupon() { + // given + int initialQuantity = 100; + Coupon coupon = createCoupon("할인 쿠폰", 1000, initialQuantity, LocalDateTime.now().plusDays(7)); + // 쿠폰 등록 + Long couponId = couponRepository.save(coupon).getId(); + + // 고객 등록 + // 계좌 생성 + PayAccount payAccount = payAccountRepository.save(createPayAccount()); + Customer customer = createCustomer(payAccount, new NoOpPasswordEncoder()); + UUID customerId = customerRepository.saveAndFlush(customer).getId(); + IssueCouponCommand cmd = new IssueCouponCommand(customerId, couponId); + + // when + Long saveCouponIssuanceId = service.issueCoupon(cmd); + CouponIssuance couponIssuance = couponIssuanceRepository.findById(saveCouponIssuanceId).get(); + + // then + // 쿠폰 발급 확인 + assertNotNull(saveCouponIssuanceId); + assertEquals(coupon.getId(), couponIssuance.getCoupon().getId()); + assertEquals(customer.getId(), couponIssuance.getCustomer().getId()); + assertEquals(initialQuantity - 1, couponIssuance.getCoupon().getQuantity()); + } + + @Test + @DisplayName("쿠폰 발급 테스트 - 수량 부족 실패") + @Transactional + void testIssueCouponFailWithInsufficientQuantity() { + // given + Coupon coupon = createCoupon("할인 쿠폰", 1000, 1, LocalDateTime.now().plusDays(7)); + coupon.decreaseQuantity(); + // 쿠폰 등록 + Long couponId = couponRepository.save(coupon).getId(); + + // 고객 등록 + // 계좌 생성 + PayAccount payAccount = payAccountRepository.save(createPayAccount()); + Customer customer = createCustomer(payAccount, new NoOpPasswordEncoder()); + UUID customerId = customerRepository.saveAndFlush(customer).getId(); + IssueCouponCommand cmd = new IssueCouponCommand(customerId, couponId); + + // when & then + assertThrows(InsufficientCouponQuantityException.class, + () -> service.issueCoupon(cmd)); + } + + @Test + @DisplayName("쿠폰 발급 테스트 - 동시성 제어") + void testIssueCouponWithConcurrency() throws InterruptedException { + // given + int couponQuantity = 10; + int numberOfThreads = 20; + Coupon coupon = createCoupon("할인 쿠폰", 1000, couponQuantity, LocalDateTime.now().plusDays(7)); + Long couponId = couponRepository.save(coupon).getId(); + + PayAccount payAccount = payAccountRepository.save(createPayAccount()); + Customer customer = createCustomer(payAccount, new NoOpPasswordEncoder()); + UUID customerId = customerRepository.saveAndFlush(customer).getId(); + + IssueCouponCommand cmd = new IssueCouponCommand(customerId, couponId); + + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch latch = new CountDownLatch(numberOfThreads); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // when + for (int i = 0; i < numberOfThreads; i++) { + executorService.submit(() -> { + try { + service.issueCoupon(cmd); + successCount.incrementAndGet(); + } catch (InsufficientCouponQuantityException e) { + failCount.incrementAndGet(); + } catch (Exception e) { + exceptions.add(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 모든 스레드가 작업을 마칠 때까지 대기 + executorService.shutdown(); + + // then + assertEquals(couponQuantity, successCount.get(), "성공적으로 발급된 쿠폰 수가 초기 수량과 일치해야 합니다."); + assertEquals(numberOfThreads - couponQuantity, failCount.get(), "실패한 요청 수가 예상과 일치해야 합니다."); + assertTrue(exceptions.isEmpty(), "예상치 못한 예외가 발생하지 않아야 합니다."); + + Coupon updatedCoupon = couponRepository.findById(couponId).orElseThrow(); + assertEquals(0, updatedCoupon.getQuantity(), "모든 쿠폰이 소진되어야 합니다."); + + // 쓰레드 트랜잭션 전파 문제로 인해 수동 데이터 제거 + // 데이터 제거 + couponIssuanceRepository.deleteAll(); + couponRepository.delete(updatedCoupon); + customerRepository.delete(customer); + payAccountRepository.delete(payAccount); + } +} diff --git a/src/test/java/camp/woowak/lab/coupon/service/IssueCouponServiceTest.java b/src/test/java/camp/woowak/lab/coupon/service/IssueCouponServiceTest.java new file mode 100644 index 00000000..8e8c2644 --- /dev/null +++ b/src/test/java/camp/woowak/lab/coupon/service/IssueCouponServiceTest.java @@ -0,0 +1,150 @@ +package camp.woowak.lab.coupon.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import camp.woowak.lab.coupon.domain.Coupon; +import camp.woowak.lab.coupon.domain.CouponIssuance; +import camp.woowak.lab.coupon.domain.TestCoupon; +import camp.woowak.lab.coupon.exception.ExpiredCouponException; +import camp.woowak.lab.coupon.exception.InsufficientCouponQuantityException; +import camp.woowak.lab.coupon.exception.InvalidICreationIssuanceException; +import camp.woowak.lab.coupon.repository.CouponIssuanceRepository; +import camp.woowak.lab.coupon.repository.CouponRepository; +import camp.woowak.lab.coupon.service.command.IssueCouponCommand; +import camp.woowak.lab.customer.domain.Customer; +import camp.woowak.lab.customer.repository.CustomerRepository; +import camp.woowak.lab.fixture.CouponFixture; +import camp.woowak.lab.fixture.CouponIssuanceFixture; +import camp.woowak.lab.fixture.CustomerFixture; + +@ExtendWith(MockitoExtension.class) +class IssueCouponServiceTest implements CouponFixture, CustomerFixture, CouponIssuanceFixture { + @InjectMocks + private IssueCouponService issueCouponService; + + @Mock + private CouponIssuanceRepository couponIssuanceRepository; + + @Mock + private CustomerRepository customerRepository; + + @Mock + private CouponRepository couponRepository; + + @Test + @DisplayName("Coupon 발급 테스트 - 성공") + void testIssueCoupon() { + // given + UUID fakeCustomerId = UUID.randomUUID(); + Long fakeCouponId = 1L; + Long fakeCouponIssuanceId = 1L; + Coupon fakeCoupon = createCoupon(fakeCouponId, "할인 쿠폰", 1000, 100, LocalDateTime.now().plusDays(7)); + Customer fakeCustomer = createCustomer(fakeCustomerId); + CouponIssuance fakeCouponIssuance = createCouponIssuance(fakeCouponId, fakeCoupon, fakeCustomer); + given(customerRepository.findById(fakeCustomerId)).willReturn(Optional.of(fakeCustomer)); + given(couponRepository.findByIdWithPessimisticLock(fakeCouponId)).willReturn(Optional.of(fakeCoupon)); + given(couponIssuanceRepository.save(any(CouponIssuance.class))).willReturn(fakeCouponIssuance); + + IssueCouponCommand cmd = new IssueCouponCommand(fakeCustomerId, fakeCouponId); + + // when + Long saveId = issueCouponService.issueCoupon(cmd); + + // then + assertEquals(fakeCouponIssuanceId, saveId); + verify(customerRepository).findById(fakeCustomerId); + verify(couponRepository).findByIdWithPessimisticLock(fakeCouponId); + verify(couponIssuanceRepository).save(any(CouponIssuance.class)); + } + + @Test + @DisplayName("Coupon 발급 테스트 - 존재하지 않는 Customer") + void testIssueCouponFailWithNotExistCustomer() { + // given + UUID fakeCustomerId = UUID.randomUUID(); + Long fakeCouponId = 1L; + given(customerRepository.findById(fakeCustomerId)).willReturn(Optional.empty()); + + IssueCouponCommand cmd = new IssueCouponCommand(fakeCustomerId, fakeCouponId); + + // when & then + assertThrows(InvalidICreationIssuanceException.class, () -> issueCouponService.issueCoupon(cmd)); + verify(customerRepository).findById(fakeCustomerId); + verify(couponRepository, never()).findById(fakeCouponId); + verify(couponIssuanceRepository, never()).save(any(CouponIssuance.class)); + } + + @Test + @DisplayName("Coupon 발급 테스트 - 존재하지 않는 Coupon") + void testIssueCouponFailWithNotExistCoupon() { + // given + UUID fakeCustomerId = UUID.randomUUID(); + Long fakeCouponId = 1L; + Customer fakeCustomer = createCustomer(fakeCustomerId); + given(customerRepository.findById(fakeCustomerId)).willReturn(Optional.of(fakeCustomer)); + given(couponRepository.findByIdWithPessimisticLock(fakeCouponId)).willReturn(Optional.empty()); + + IssueCouponCommand cmd = new IssueCouponCommand(fakeCustomerId, fakeCouponId); + + // when & then + assertThrows(InvalidICreationIssuanceException.class, () -> issueCouponService.issueCoupon(cmd)); + verify(customerRepository).findById(fakeCustomerId); + verify(couponRepository).findByIdWithPessimisticLock(fakeCouponId); + verify(couponIssuanceRepository, never()).save(any(CouponIssuance.class)); + } + + @Test + @DisplayName("Coupon 발급 테스트 - 만료된 Coupon") + void testIssueCouponFailWithExpiredCoupon() { + // given + UUID fakeCustomerId = UUID.randomUUID(); + Long fakeCouponId = 1L; + TestCoupon fakeCoupon = (TestCoupon)createCoupon(fakeCouponId, "할인 쿠폰", 1000, 100, + LocalDateTime.now().plusDays(7)); + fakeCoupon.setExpiredAt(LocalDateTime.now().minusDays(1)); + Customer fakeCustomer = createCustomer(fakeCustomerId); + given(customerRepository.findById(fakeCustomerId)).willReturn(Optional.of(fakeCustomer)); + given(couponRepository.findByIdWithPessimisticLock(fakeCouponId)).willReturn(Optional.of(fakeCoupon)); + + IssueCouponCommand cmd = new IssueCouponCommand(fakeCustomerId, fakeCouponId); + + // when & then + assertThrows(ExpiredCouponException.class, () -> issueCouponService.issueCoupon(cmd)); + verify(customerRepository).findById(fakeCustomerId); + verify(couponRepository).findByIdWithPessimisticLock(fakeCouponId); + verify(couponIssuanceRepository, never()).save(any(CouponIssuance.class)); + } + + @Test + @DisplayName("Coupon 발급 테스트 - 수량 부족") + void testIssueCouponFailWithInsufficientCouponQuantity() { + // given + UUID fakeCustomerId = UUID.randomUUID(); + Long fakeCouponId = 1L; + TestCoupon fakeCoupon = (TestCoupon)createCoupon(fakeCouponId, "할인 쿠폰", 1000, 1, + LocalDateTime.now().plusDays(7)); + Customer fakeCustomer = createCustomer(fakeCustomerId); + fakeCoupon.setQuantity(0); // 수량 부족 시나리오 적용 + given(customerRepository.findById(fakeCustomerId)).willReturn(Optional.of(fakeCustomer)); + given(couponRepository.findByIdWithPessimisticLock(fakeCouponId)).willReturn(Optional.of(fakeCoupon)); + IssueCouponCommand cmd = new IssueCouponCommand(fakeCustomerId, fakeCouponId); + + // when & then + assertThrows(InsufficientCouponQuantityException.class, () -> issueCouponService.issueCoupon(cmd)); + verify(customerRepository).findById(fakeCustomerId); + verify(couponRepository).findByIdWithPessimisticLock(fakeCouponId); + verify(couponIssuanceRepository, never()).save(any(CouponIssuance.class)); + } +} diff --git a/src/test/java/camp/woowak/lab/customer/domain/TestCustomer.java b/src/test/java/camp/woowak/lab/customer/domain/TestCustomer.java new file mode 100644 index 00000000..b7a7fa30 --- /dev/null +++ b/src/test/java/camp/woowak/lab/customer/domain/TestCustomer.java @@ -0,0 +1,22 @@ +package camp.woowak.lab.customer.domain; + +import java.util.UUID; + +import camp.woowak.lab.payaccount.domain.PayAccount; +import camp.woowak.lab.web.authentication.NoOpPasswordEncoder; + +public class TestCustomer extends Customer { + private final UUID id; + + public TestCustomer(UUID id, String customerName, String mail, String customerPassword, String s, + PayAccount payAccount, + NoOpPasswordEncoder noOpPasswordEncoder) { + super(customerName, mail, customerPassword, s, payAccount, noOpPasswordEncoder); + this.id = id; + } + + @Override + public UUID getId() { + return id; + } +} diff --git a/src/test/java/camp/woowak/lab/fixture/CouponFixture.java b/src/test/java/camp/woowak/lab/fixture/CouponFixture.java index 16b45882..8921ebf7 100644 --- a/src/test/java/camp/woowak/lab/fixture/CouponFixture.java +++ b/src/test/java/camp/woowak/lab/fixture/CouponFixture.java @@ -9,7 +9,11 @@ * CouponFixture는 테스트에서 사용할 Coupon 객체를 생성하는 역할을 합니다. */ public interface CouponFixture { - default Coupon createCoupon(Long id, String title, int discountAmount, int amount, LocalDateTime expiredAt) { - return new TestCoupon(id, title, discountAmount, amount, expiredAt); + default Coupon createCoupon(String title, int discountAmount, int quantity, LocalDateTime expiredAt) { + return new Coupon(title, discountAmount, quantity, expiredAt); + } + + default Coupon createCoupon(Long id, String title, int discountAmount, int quantity, LocalDateTime expiredAt) { + return new TestCoupon(id, title, discountAmount, quantity, expiredAt); } } diff --git a/src/test/java/camp/woowak/lab/fixture/CouponIssuanceFixture.java b/src/test/java/camp/woowak/lab/fixture/CouponIssuanceFixture.java new file mode 100644 index 00000000..53c3c94e --- /dev/null +++ b/src/test/java/camp/woowak/lab/fixture/CouponIssuanceFixture.java @@ -0,0 +1,15 @@ +package camp.woowak.lab.fixture; + +import camp.woowak.lab.coupon.domain.Coupon; +import camp.woowak.lab.coupon.domain.CouponIssuance; +import camp.woowak.lab.coupon.domain.TestCouponIssuance; +import camp.woowak.lab.customer.domain.Customer; + +/** + * CouponIssuanceFixture는 테스트에서 CouponIssuance를 생성할 때 사용하는 Fixture입니다. + */ +public interface CouponIssuanceFixture { + default CouponIssuance createCouponIssuance(Long id, Coupon coupon, Customer customer) { + return new TestCouponIssuance(id, coupon, customer); + } +} diff --git a/src/test/java/camp/woowak/lab/fixture/CustomerFixture.java b/src/test/java/camp/woowak/lab/fixture/CustomerFixture.java index d30b2ea2..d5d212f4 100644 --- a/src/test/java/camp/woowak/lab/fixture/CustomerFixture.java +++ b/src/test/java/camp/woowak/lab/fixture/CustomerFixture.java @@ -1,8 +1,12 @@ package camp.woowak.lab.fixture; +import java.util.UUID; + import camp.woowak.lab.customer.domain.Customer; +import camp.woowak.lab.customer.domain.TestCustomer; import camp.woowak.lab.customer.exception.InvalidCreationException; import camp.woowak.lab.payaccount.domain.PayAccount; +import camp.woowak.lab.web.authentication.NoOpPasswordEncoder; import camp.woowak.lab.web.authentication.PasswordEncoder; /** @@ -23,4 +27,13 @@ default Customer createCustomer(PayAccount payAccount, PasswordEncoder passwordE } } + default Customer createCustomer(UUID id) { + try { + return new TestCustomer(id, "customerName", "customer@email.com", "customerPassword", "010-0000-0000", + createPayAccount(), + new NoOpPasswordEncoder()); + } catch (InvalidCreationException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/test/java/camp/woowak/lab/web/api/coupon/CouponApiControllerTest.java b/src/test/java/camp/woowak/lab/web/api/coupon/CouponApiControllerTest.java index 2342ee0b..b8ba8faf 100644 --- a/src/test/java/camp/woowak/lab/web/api/coupon/CouponApiControllerTest.java +++ b/src/test/java/camp/woowak/lab/web/api/coupon/CouponApiControllerTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDateTime; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,14 +15,22 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; import camp.woowak.lab.coupon.exception.DuplicateCouponTitleException; +import camp.woowak.lab.coupon.exception.ExpiredCouponException; +import camp.woowak.lab.coupon.exception.InsufficientCouponQuantityException; +import camp.woowak.lab.coupon.exception.InvalidICreationIssuanceException; import camp.woowak.lab.coupon.service.CreateCouponService; +import camp.woowak.lab.coupon.service.IssueCouponService; import camp.woowak.lab.coupon.service.command.CreateCouponCommand; +import camp.woowak.lab.coupon.service.command.IssueCouponCommand; +import camp.woowak.lab.web.authentication.LoginCustomer; import camp.woowak.lab.web.dto.request.coupon.CreateCouponRequest; +import camp.woowak.lab.web.resolver.session.SessionConst; @WebMvcTest(CouponApiController.class) @MockBean(JpaMetamodelMappingContext.class) @@ -32,12 +41,18 @@ class CouponApiControllerTest { @MockBean private CreateCouponService createCouponService; + @MockBean + private IssueCouponService issueCouponService; + @Autowired private ObjectMapper objectMapper; @Test - @DisplayName("쿠폰 발급 테스트 - 성공") + @DisplayName("쿠폰 등록 테스트 - 성공") void testCreateCoupon() throws Exception { + // given + given(createCouponService.createCoupon(any(CreateCouponCommand.class))).willReturn(1L); + mockMvc.perform(post("/coupons") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString( @@ -49,7 +64,7 @@ void testCreateCoupon() throws Exception { } @Test - @DisplayName("쿠폰 발급 테스트 - 잘못된 제목 입력 시 실패") + @DisplayName("쿠폰 등록 테스트 - 잘못된 제목 입력 시 실패") void testCreateCouponFailWithInvalidTitle() throws Exception { mockMvc.perform(post("/coupons") .contentType(MediaType.APPLICATION_JSON) @@ -61,7 +76,7 @@ void testCreateCouponFailWithInvalidTitle() throws Exception { } @Test - @DisplayName("쿠폰 발급 테스트 - 잘못된 할인 금액 입력 시 실패") + @DisplayName("쿠폰 등록 테스트 - 잘못된 할인 금액 입력 시 실패") void testCreateCouponFailWithInvalidDiscountAmount() throws Exception { mockMvc.perform(post("/coupons") .contentType(MediaType.APPLICATION_JSON) @@ -73,7 +88,7 @@ void testCreateCouponFailWithInvalidDiscountAmount() throws Exception { } @Test - @DisplayName("쿠폰 발급 테스트 - 잘못된 수량 입력 시 실패") + @DisplayName("쿠폰 등록 테스트 - 잘못된 수량 입력 시 실패") void testCreateCouponFailWithInvalidQuantity() throws Exception { mockMvc.perform(post("/coupons") .contentType(MediaType.APPLICATION_JSON) @@ -85,7 +100,7 @@ void testCreateCouponFailWithInvalidQuantity() throws Exception { } @Test - @DisplayName("쿠폰 발급 테스트 - 잘못된 만료일 입력 시 실패") + @DisplayName("쿠폰 등록 테스트 - 잘못된 만료일 입력 시 실패") void testCreateCouponFailWithInvalidExpiredAt() throws Exception { mockMvc.perform(post("/coupons") .contentType(MediaType.APPLICATION_JSON) @@ -97,7 +112,7 @@ void testCreateCouponFailWithInvalidExpiredAt() throws Exception { } @Test - @DisplayName("쿠폰 발급 테스트 - 중복된 제목 입력 시 실패") + @DisplayName("쿠폰 등록 테스트 - 중복된 제목 입력 시 실패") void testCreateCouponFailWithDuplicateTitle() throws Exception { // given CreateCouponRequest request = new CreateCouponRequest("테스트 쿠폰", 1000, 100, LocalDateTime.now().plusDays(7)); @@ -113,4 +128,100 @@ void testCreateCouponFailWithDuplicateTitle() throws Exception { .andDo(print()) .andExpect(status().isConflict()); } + + @Test + @DisplayName("쿠폰 발급 테스트 - 성공") + void testIssueCoupon() throws Exception { + // given + Long couponId = 1L; + UUID customerId = UUID.randomUUID(); + LoginCustomer loginCustomer = new LoginCustomer(customerId); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, loginCustomer); + + given(issueCouponService.issueCoupon(any(IssueCouponCommand.class))).willReturn(1L); + + // when & then + mockMvc.perform(post("/coupons/" + couponId + "/issue") + .session(session) // 세션 설정 + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("쿠폰 발급 테스트 - 세션 없이 요청 시 실패") + void testIssueCouponFailWithoutSession() throws Exception { + // given + Long couponId = 1L; + + // when & then + mockMvc.perform(post("/coupons/" + couponId + "/issue") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("쿠폰 발급 테스트 - 존재하지 않는 쿠폰 or 구매자 ID 입력 시 실패") + void testIssueCouponFailWithNotExistsId() throws Exception { + // given + UUID customerId = UUID.randomUUID(); + LoginCustomer loginCustomer = new LoginCustomer(customerId); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, loginCustomer); + given(issueCouponService.issueCoupon(any(IssueCouponCommand.class))) + .willThrow(new InvalidICreationIssuanceException("존재하지 않는 쿠폰 or 구매자 ID 입력입니다.")); + // when & then + mockMvc.perform(post("/coupons/999/issue") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("쿠폰 발급 테스트 - 쿠폰 만료 실패") + void testIssueCouponFailWithExpiredCoupon() throws Exception { + // given + UUID customerId = UUID.randomUUID(); + LoginCustomer loginCustomer = new LoginCustomer(customerId); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, loginCustomer); + given(issueCouponService.issueCoupon(any(IssueCouponCommand.class))) + .willThrow(new ExpiredCouponException("쿠폰이 만료되었습니다.")); + // when & then + mockMvc.perform(post("/coupons/999/issue") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("쿠폰 발급 테스트 - 수량 부족 실패") + void testIssueCouponFailWithInsufficientQuantity() throws Exception { + // given + UUID customerId = UUID.randomUUID(); + LoginCustomer loginCustomer = new LoginCustomer(customerId); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, loginCustomer); + given(issueCouponService.issueCoupon(any(IssueCouponCommand.class))) + .willThrow(new InsufficientCouponQuantityException("수량이 부족합니다.")); + // when & then + mockMvc.perform(post("/coupons/999/issue") + .session(session) // 세션 설정 + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isConflict()); + } }