From 1d62c15e102ca6b9338bbb3342336ecdaab0765e Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Mon, 14 Sep 2026 22:45:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(qa):=20qa=EB=AC=B8=EC=84=9C=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=20=20-=20=ED=83=88=ED=87=B4=ED=95=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=A7=A4=EC=B9=AD=20=EC=95=88=EA=B1=B8=EB=A6=AC?= =?UTF-8?q?=EA=B2=8C=20=EC=B2=98=EB=A6=AC=20=20=20-=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=20=20-=203?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=ED=95=9C=EC=9D=98=20800=EC=9B=90=EC=A7=9C?= =?UTF-8?q?=EB=A6=AC=20=EB=A7=A4=EC=B9=AD=EA=B6=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/service/ShopServiceImpl.java | 9 +- .../item/global/init/ShopDataInitializer.java | 28 +++- .../product/service/ShopServiceImplTest.java | 133 ++++++++++++++++++ .../global/init/ShopDataInitializerTest.java | 11 +- .../domain/service/MatchingServiceImpl.java | 4 + .../service/MatchingServiceImplTest.java | 34 +++++ .../member/service/ProfileServiceImpl.java | 4 +- .../service/ProfileServiceImplTest.java | 6 - 8 files changed, 213 insertions(+), 16 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java index 8b97468..dfd5f26 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java @@ -37,6 +37,8 @@ @Transactional public class ShopServiceImpl implements ShopService { + private static final String DISCOUNT_MATCHING_TICKET_CODE = "DISCOUNT_MATCHING_TICKET_1"; + private static final Map PURCHASE_LIMITS = Map.of( ItemType.MATCHING_TICKET, 30, ItemType.OPTION_TICKET, 90 @@ -61,7 +63,8 @@ public List getActiveProducts(Long memberId, Boolean isBundle) LocalDateTime now = LocalDateTime.now(); return products.stream() .map(product -> toMemberProductResponse(memberId, product, now)) - .filter(product -> Boolean.TRUE.equals(product.purchaseCountPurchasable())) + .filter(product -> Boolean.TRUE.equals(product.purchaseCountPurchasable()) + || DISCOUNT_MATCHING_TICKET_CODE.equals(product.code())) .toList(); } @@ -75,6 +78,10 @@ public void requestPurchase(Long memberId, Long productId, int quantity) { throw new BusinessException(ItemErrorCode.PRODUCT_NOT_AVAILABLE); } + if (DISCOUNT_MATCHING_TICKET_CODE.equals(resolveProductCode(product)) && quantity != 1) { + throw new BusinessException(PaymentErrorCode.INVALID_ORDER_QUANTITY); + } + LocalDateTime now = LocalDateTime.now(); boolean hasPendingRequest = orderRepository.existsActivePendingOrder(memberId, now); if (hasPendingRequest) { diff --git a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java index 7eaeb03..1cc82a0 100644 --- a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java +++ b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java @@ -44,12 +44,25 @@ public void run(String... args) throws Exception { addReward(firstPurchaseBundle, ItemType.MATCHING_TICKET, 3); addReward(firstPurchaseBundle, ItemType.OPTION_TICKET, 6); +// 2026/9/14 pm 요구에 따른 추가 + Product discountMatchingTicket = product( + "(할인) 뽑기권 1개", + "DISCOUNT_MATCHING_TICKET_1", + "", + 800, + 2, + true, + 3, + false + ); + addReward(discountMatchingTicket, ItemType.MATCHING_TICKET, 1); + Product miniBundle = product( "미니 번들", "MINI_BUNDLE", "", 500, - 2, + 3, true, 2, false @@ -61,7 +74,7 @@ public void run(String... args) throws Exception { "VALUE_BUNDLE", "", 5500, - 3, + 4, true, 2, false @@ -74,7 +87,7 @@ public void run(String... args) throws Exception { "FULL_OPTION_BUNDLE", "", 7000, - 4, + 5, true, 1, false @@ -87,7 +100,7 @@ public void run(String... args) throws Exception { "SUPER_BUNDLE", "", 9500, - 5, + 6, true, 1, false @@ -100,7 +113,7 @@ public void run(String... args) throws Exception { "HYPER_BUNDLE", "", 18000, - 6, + 7, true, 2, false @@ -113,7 +126,7 @@ public void run(String... args) throws Exception { "MATCHING_TICKET_1", "", 1000, - 7, + 8, false, null, false @@ -125,7 +138,7 @@ public void run(String... args) throws Exception { "OPTION_TICKET_1", "", 200, - 8, + 9, false, null, false @@ -134,6 +147,7 @@ public void run(String... args) throws Exception { List products = List.of( firstPurchaseBundle, + discountMatchingTicket, miniBundle, valueBundle, fullOptionBundle, diff --git a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java index 6f44cbe..ddc02c4 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java @@ -15,6 +15,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -69,6 +72,136 @@ class ShopServiceImplTest { private static final int DEFAULT_QUANTITY = 1; + @ParameterizedTest + @ValueSource(longs = {0, 1, 2}) + @DisplayName("할인 뽑기권은 승인 3회 미만이면 800원에 1개 주문을 생성한다") + void shouldAllowDiscountTicketBeforeThreeApprovedPurchases(long approvedCount) { + Product product = discountMatchingTicket(); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())) + .willReturn(approvedCount); + given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, "홍길동", "길동이")); + given(paymentOrderProperties.expireMinutes()).willReturn(43200L); + + shopService.requestPurchase(100L, 3L, 1); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class); + then(orderRepository).should().save(captor.capture()); + Order order = captor.getValue(); + assertThat(order.getProductCode()).isEqualTo("DISCOUNT_MATCHING_TICKET_1"); + assertThat(order.getExpectedPrice()).isEqualTo(800); + assertThat(order.getRequestedPrice()).isEqualTo(800); + assertThat(order.getOrderItems()).singleElement().satisfies(item -> { + assertThat(item.getItemType()).isEqualTo(ItemType.MATCHING_TICKET); + assertThat(item.getQuantity()).isEqualTo(1); + }); + then(orderOutboxService).should().enqueueOrderCreated(order); + } + + @Test + @DisplayName("할인 뽑기권 승인 3회 이후에는 추가 주문을 거부한다") + void shouldRejectDiscountTicketAfterThreeApprovedPurchases() { + Product product = discountMatchingTicket(); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(3L); + + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 1)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED); + then(orderRepository).should(never()).save(any()); + then(orderOutboxService).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @CsvSource({"0, 0, 3", "1, 0, 2", "2, 0, 1", "3, 0, 0", "2, 1, 0"}) + @DisplayName("할인 뽑기권은 승인과 유효 대기 주문을 합산하고 소진 후에도 구매 완료 표시용으로 반환한다") + void shouldReturnDiscountTicketRemainingCount(long approvedCount, long pendingCount, long remainingCount) { + Product product = discountMatchingTicket(); + given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product)); + given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())) + .willReturn(approvedCount); + given(orderRepository.countActivePendingByMemberIdAndProductCode(eq(100L), eq(product.getCode()), any())) + .willReturn(pendingCount); + + assertThat(shopService.getActiveProducts(100L, true)).singleElement().satisfies(response -> { + assertThat(response.remainingPurchaseCount()).isEqualTo(remainingCount); + assertThat(response.purchaseCountPurchasable()).isEqualTo(remainingCount > 0); + assertThat(response.purchaseBlockReason()).isEqualTo(remainingCount > 0 + ? PurchaseBlockReason.NONE : PurchaseBlockReason.PRODUCT_LIMIT_EXCEEDED); + }); + } + + @Test + @DisplayName("할인 뽑기권 승인 2회와 유효 대기 1회가 있으면 잔여 0회이며 추가 요청도 거부한다") + void shouldRejectDiscountTicketWithTwoApprovedAndOnePendingPurchase() { + Product product = discountMatchingTicket(); + given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product)); + given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(2L); + given(orderRepository.countActivePendingByMemberIdAndProductCode(eq(100L), eq(product.getCode()), any())) + .willReturn(1L); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true); + + assertThat(shopService.getActiveProducts(100L, true)).singleElement().satisfies(response -> { + assertThat(response.remainingPurchaseCount()).isZero(); + assertThat(response.purchaseCountPurchasable()).isFalse(); + }); + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 1)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); + then(orderRepository).should(never()).save(any()); + then(orderOutboxService).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3}) + @DisplayName("할인 뽑기권 quantity 2 또는 3 직접 요청은 서버에서 거부한다") + void shouldRejectMultipleDiscountTicketsInOneRequest(int quantity) { + given(productRepository.findById(3L)).willReturn(Optional.of(discountMatchingTicket())); + + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, quantity)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(PaymentErrorCode.INVALID_ORDER_QUANTITY); + then(orderRepository).shouldHaveNoInteractions(); + then(userOrderClient).shouldHaveNoInteractions(); + then(orderOutboxService).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + @DisplayName("일반 뽑기권은 기존처럼 수량 1~3개에 맞춰 가격과 지급량을 계산한다") + void shouldKeepNormalMatchingTicketQuantityPurchases(int quantity) { + Product product = product("뽑기권 1장", 1000, true); + ReflectionTestUtils.setField(product, "id", 3L); + ReflectionTestUtils.setField(product, "code", "MATCHING_TICKET_1"); + product.addReward(ProductReward.builder().itemType(ItemType.MATCHING_TICKET).quantity(1).build()); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, "홍길동", "길동이")); + given(paymentOrderProperties.expireMinutes()).willReturn(43200L); + + shopService.requestPurchase(100L, 3L, quantity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class); + then(orderRepository).should().save(captor.capture()); + assertThat(captor.getValue().getExpectedPrice()).isEqualTo(1000 * quantity); + assertThat(captor.getValue().getOrderItems()).singleElement().satisfies(item -> { + assertThat(item.getItemType()).isEqualTo(ItemType.MATCHING_TICKET); + assertThat(item.getQuantity()).isEqualTo(quantity); + }); + } + + private Product discountMatchingTicket() { + Product product = product("(할인) 뽑기권 1개", 800, true, true); + ReflectionTestUtils.setField(product, "id", 3L); + ReflectionTestUtils.setField(product, "code", "DISCOUNT_MATCHING_TICKET_1"); + ReflectionTestUtils.setField(product, "purchaseLimitPerMember", 3); + product.addReward(ProductReward.builder().itemType(ItemType.MATCHING_TICKET).quantity(1).build()); + return product; + } + @Test @DisplayName("상품 ID 기반 요청이면 상품 가격/구성품으로 주문을 생성한다") void shouldCreateOrderFromProductSnapshot() { diff --git a/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java b/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java index ce2ff0a..0e782eb 100644 --- a/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java +++ b/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java @@ -44,9 +44,10 @@ void shouldCreateConfirmedDefaultProducts() throws Exception { then(productRepository).should().saveAll(productsCaptor.capture()); List products = toList(productsCaptor.getValue()); - assertThat(products).hasSize(8); + assertThat(products).hasSize(9); assertThat(products).extracting(Product::getCode).containsExactly( "FIRST_PURCHASE_SPECIAL_BUNDLE", + "DISCOUNT_MATCHING_TICKET_1", "MINI_BUNDLE", "VALUE_BUNDLE", "FULL_OPTION_BUNDLE", @@ -55,6 +56,14 @@ void shouldCreateConfirmedDefaultProducts() throws Exception { "MATCHING_TICKET_1", "OPTION_TICKET_1" ); + assertThat(products).extracting(Product::getDisplayOrder).containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9); + + Product discountMatchingTicket = product(products, "DISCOUNT_MATCHING_TICKET_1"); + assertThat(discountMatchingTicket.getName()).isEqualTo("(할인) 뽑기권 1개"); + assertThat(discountMatchingTicket.isActive()).isTrue(); + assertThat(discountMatchingTicket.getDisplayOrder()).isEqualTo(2); + assertThat(discountMatchingTicket.getRewards()).hasSize(1); + assertBundle(discountMatchingTicket, 800, 3, 1, 0); Product firstPurchaseBundle = product(products, "FIRST_PURCHASE_SPECIAL_BUNDLE"); assertThat(firstPurchaseBundle.getName()).isEqualTo("첫 구매 특가 번들"); diff --git a/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingServiceImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingServiceImpl.java index e05ea25..2047a42 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingServiceImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingServiceImpl.java @@ -66,6 +66,10 @@ private MatchingResponse matchWithPairConflictRetry(Long memberId, ProfileRespon for (int attempt = 1; attempt <= MAX_MATCHING_ATTEMPTS; attempt++) { MatchingCandidate matchedCandidate = matchingProcessor.process(memberId, myProfile, request); ProfileResponse partnerProfile = memberClient.getProfile(matchedCandidate.getMemberId()); + // 후보 삭제 이벤트가 늦어져도 탈퇴 시 비활성화된 실제 프로필은 매칭하지 않는다. + if (!partnerProfile.isMatchable()) { + throw new BusinessException(MatchingErrorCode.NO_MATCHING_CANDIDATE); + } try { saveHistoryAndPublishEvent(memberId, matchedCandidate, request); diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingServiceImplTest.java b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingServiceImplTest.java index f2d3895..f660f3d 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingServiceImplTest.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingServiceImplTest.java @@ -68,6 +68,7 @@ private ProfileResponse createProfile(Long memberId, Gender gender) { .mbti("ISTJ") .major("컴퓨터공학과") .birthDate(LocalDate.of(2000, 1, 1)) + .isMatchable(true) .build(); } @@ -78,6 +79,7 @@ private ProfileResponse createProfileWithTags(Long memberId, Gender gender) { .mbti("ISTJ") .major("컴퓨터공학과") .birthDate(LocalDate.of(2000, 1, 1)) + .isMatchable(true) .tags(List.of( new ProfileTagDto("계란형 얼굴"), new ProfileTagDto("밝은 분위기") @@ -213,6 +215,38 @@ void shouldRefundItemsAndSkipHistoryWhenPartnerProfileLookupFails() { verify(matchingEventProducer, never()).sendMatchingSuccess(any()); } + @Test + @DisplayName("탈퇴 회원의 매칭 가능한 후보가 남아도 결과를 저장하지 않고 아이템을 환불한다") + void shouldRejectWithdrawnPartnerWithStaleCandidate() { + Long memberId = 1L; + Long partnerId = 2L; + MatchingRequest request = new MatchingRequest(null, null, null, null, false, null); + ProfileResponse myProfile = createProfile(memberId, Gender.MALE); + MatchingCandidate staleCandidate = createCandidate(partnerId); + // 탈퇴 시 실제 프로필은 비활성화되지만 후보 삭제 이벤트는 아직 반영되지 않은 상태. + ProfileResponse withdrawnProfile = ProfileResponse.builder() + .memberId(partnerId) + .isMatchable(false) + .build(); + + given(memberClient.getProfile(memberId)).willReturn(myProfile); + given(memberClient.getProfile(partnerId)).willReturn(withdrawnProfile); + given(matchingItemPolicy.determine(request)) + .willReturn(List.of(new ItemConsumption(ItemType.MATCHING_TICKET, 1))); + given(matchingProcessor.process(memberId, myProfile, request)).willReturn(staleCandidate); + + assertThat(staleCandidate.isMatchable()).isTrue(); + assertThatThrownBy(() -> matchingService.match(memberId, request)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException)e).getErrorCode()) + .isEqualTo(MatchingErrorCode.NO_MATCHING_CANDIDATE)); + + verify(itemClient).useItem(memberId, ItemType.MATCHING_TICKET, 1); + verify(itemClient).addItem(eq(memberId), any()); + verify(historyRepository, never()).save(any()); + verify(matchingEventProducer, never()).sendMatchingSuccess(any()); + } + @Test @DisplayName("히스토리 저장 실패시 아이템을 환불한다") void shouldRefundItemsWhenHistorySaveFails() { diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java index 73ce4ff..4ef0daf 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java @@ -93,7 +93,9 @@ public ProfileResponse createProfile(Long memberId, ProfileCreateRequest request member.upgradeRoleToUser(); publishMatchingEvent(profile); - eventPublisher.sendSignupEvent(profileResponse); + +// 사전가입 이벤트 중지에 따른 매칭권 지급 중지 +// eventPublisher.sendSignupEvent(profileResponse); return profileResponse; } diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java index 7c39504..99ce072 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java @@ -94,7 +94,6 @@ void shouldCreateProfileWithTags() { given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); - willDoNothing().given(eventPublisher).sendSignupEvent(any()); // when ProfileResponse response = profileService.createProfile(memberId, request); @@ -135,7 +134,6 @@ void shouldCreateProfileWithKoreanTagLabels() { given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); - willDoNothing().given(eventPublisher).sendSignupEvent(any()); // when ProfileResponse response = profileService.createProfile(memberId, request); @@ -203,7 +201,6 @@ void shouldCreateProfileWithoutTags() { given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); - willDoNothing().given(eventPublisher).sendSignupEvent(any()); // when ProfileResponse response = profileService.createProfile(memberId, request); @@ -241,7 +238,6 @@ void shouldConvertS3KeyToPublicUrlWhenCreatingProfile() { given(s3Service.getFileUrl(imageKey)).willReturn(imageUrl); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); - willDoNothing().given(eventPublisher).sendSignupEvent(any()); // when ProfileResponse response = profileService.createProfile(memberId, request); @@ -276,7 +272,6 @@ void shouldUseAnimalDefaultImageWhenCreatingProfile() { given(profileImageProperties.baseUrl()).willReturn("https://img.com/defaults/profile"); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); - willDoNothing().given(eventPublisher).sendSignupEvent(any()); // when ProfileResponse response = profileService.createProfile(memberId, request); @@ -311,7 +306,6 @@ void shouldUseDinosaurDefaultImageWhenCreatingProfile() { given(profileImageProperties.baseUrl()).willReturn("https://img.com/defaults/profile/"); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); - willDoNothing().given(eventPublisher).sendSignupEvent(any()); // when ProfileResponse response = profileService.createProfile(memberId, request); From cc5e6c02f5a3b458bd2e893927d0ec222d3fefe0 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Mon, 14 Sep 2026 23:06:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(qa):=20qa=EB=AC=B8=EC=84=9C=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=20=20-=20=ED=83=88=ED=87=B4=ED=95=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=A7=A4=EC=B9=AD=20=EC=95=88=EA=B1=B8=EB=A6=AC?= =?UTF-8?q?=EA=B2=8C=20=EC=B2=98=EB=A6=AC=20=20=20-=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=20=20-=203?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=ED=95=9C=EC=9D=98=20800=EC=9B=90=EC=A7=9C?= =?UTF-8?q?=EB=A6=AC=20=EB=A7=A4=EC=B9=AD=EA=B6=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/controller/ShopControllerTest.java | 142 ++++++++++++++++++ .../controller/MatchingControllerTest.java | 113 ++++++++++++++ .../controller/ProfileControllerTest.java | 102 +++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java create mode 100644 matching-service/src/test/java/com/comatching/matching/infra/controller/MatchingControllerTest.java create mode 100644 user-service/src/test/java/com/comatching/user/infra/controller/ProfileControllerTest.java diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java new file mode 100644 index 0000000..60a2f4e --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java @@ -0,0 +1,142 @@ +package com.comatching.item.infra.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.config.PaymentOrderProperties; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.order.service.OrderOutboxService; +import com.comatching.item.domain.product.entity.Product; +import com.comatching.item.domain.product.entity.ProductReward; +import com.comatching.item.domain.product.repository.ProductRepository; +import com.comatching.item.domain.product.service.ShopServiceImpl; +import com.comatching.item.infra.client.UserOrderClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ShopController 할인 뽑기권 API 테스트") +class ShopControllerTest { + + private MockMvc mockMvc; + + @Mock private ProductRepository productRepository; + @Mock private OrderRepository orderRepository; + @Mock private ItemRepository itemRepository; + @Mock private UserOrderClient userOrderClient; + @Mock private OrderOutboxService orderOutboxService; + @Mock private PaymentOrderProperties paymentOrderProperties; + + @BeforeEach + void setUp() { + ShopServiceImpl shopService = new ShopServiceImpl( + productRepository, + orderRepository, + itemRepository, + userOrderClient, + orderOutboxService, + paymentOrderProperties + ); + mockMvc = MockMvcBuilders.standaloneSetup(new ShopController(shopService)) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/v1/shop/products는 800원 할인 상품과 남은 3회를 반환한다") + void shouldReturnDiscountMatchingTicket() throws Exception { + Product product = discountMatchingTicket(); + given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product)); + + mockMvc.perform(get("/api/v1/shop/products") + .param("isBundle", "true") + .header("X-Member-Id", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].name").value("(할인) 뽑기권 1개")) + .andExpect(jsonPath("$.data[0].code").value("DISCOUNT_MATCHING_TICKET_1")) + .andExpect(jsonPath("$.data[0].price").value(800)) + .andExpect(jsonPath("$.data[0].displayOrder").value(2)) + .andExpect(jsonPath("$.data[0].isBundle").value(true)) + .andExpect(jsonPath("$.data[0].purchaseLimitPerMember").value(3)) + .andExpect(jsonPath("$.data[0].remainingPurchaseCount").value(3)) + .andExpect(jsonPath("$.data[0].rewards[0].itemType").value("MATCHING_TICKET")) + .andExpect(jsonPath("$.data[0].rewards[0].quantity").value(1)); + } + + @Test + @DisplayName("POST /api/v1/shop/purchase/{id}는 할인 상품 quantity=2를 거부한다") + void shouldRejectMultipleDiscountTickets() throws Exception { + given(productRepository.findById(3L)).willReturn(Optional.of(discountMatchingTicket())); + + mockMvc.perform(post("/api/v1/shop/purchase/3") + .param("quantity", "2") + .header("X-Member-Id", "100")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PAY-007")); + + then(orderRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("POST /api/v1/shop/purchase/{id}는 승인 3회 이후 추가 구매를 거부한다") + void shouldRejectFourthDiscountTicketPurchase() throws Exception { + Product product = discountMatchingTicket(); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false); + given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(3L); + + mockMvc.perform(post("/api/v1/shop/purchase/3") + .param("quantity", "1") + .header("X-Member-Id", "100")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PAY-012")); + + then(orderRepository).should().countActivePendingByMemberIdAndProductCode( + eq(100L), eq(product.getCode()), any()); + then(orderRepository).should(never()).save(any()); + } + + private Product discountMatchingTicket() { + Product product = Product.builder() + .name("(할인) 뽑기권 1개") + .code("DISCOUNT_MATCHING_TICKET_1") + .description("") + .price(800) + .displayOrder(2) + .isActive(true) + .isBundle(true) + .purchaseLimitPerMember(3) + .firstPurchaseOnly(false) + .build(); + ReflectionTestUtils.setField(product, "id", 3L); + product.addReward(ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(1) + .build()); + return product; + } +} diff --git a/matching-service/src/test/java/com/comatching/matching/infra/controller/MatchingControllerTest.java b/matching-service/src/test/java/com/comatching/matching/infra/controller/MatchingControllerTest.java new file mode 100644 index 0000000..578bec6 --- /dev/null +++ b/matching-service/src/test/java/com/comatching/matching/infra/controller/MatchingControllerTest.java @@ -0,0 +1,113 @@ +package com.comatching.matching.infra.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +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.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.domain.enums.ContactFrequency; +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.HobbyCategory; +import com.comatching.common.dto.member.ProfileResponse; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.matching.domain.component.MatchingItemPolicy; +import com.comatching.matching.domain.component.MatchingProcessor; +import com.comatching.matching.domain.dto.MatchingRequest; +import com.comatching.matching.domain.entity.MatchingCandidate; +import com.comatching.matching.domain.repository.history.MatchingHistoryRepository; +import com.comatching.matching.domain.service.MatchingHistoryService; +import com.comatching.matching.domain.service.MatchingServiceImpl; +import com.comatching.matching.infra.client.ItemClient; +import com.comatching.matching.infra.client.MemberClient; +import com.comatching.matching.infra.kafka.MatchingEventProducer; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MatchingController 탈퇴 회원 API 테스트") +class MatchingControllerTest { + + private MockMvc mockMvc; + + @Mock private MatchingHistoryRepository historyRepository; + @Mock private MemberClient memberClient; + @Mock private ItemClient itemClient; + @Mock private MatchingEventProducer matchingEventProducer; + @Mock private MatchingItemPolicy matchingItemPolicy; + @Mock private MatchingProcessor matchingProcessor; + @Mock private MatchingHistoryService matchingHistoryService; + + @BeforeEach + void setUp() { + MatchingServiceImpl matchingService = new MatchingServiceImpl( + historyRepository, + memberClient, + itemClient, + matchingEventProducer, + matchingItemPolicy, + matchingProcessor + ); + mockMvc = MockMvcBuilders.standaloneSetup(new MatchingController(matchingService, matchingHistoryService)) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("POST /api/matching은 남아 있는 후보가 탈퇴 프로필이면 매칭 결과를 만들지 않는다") + void shouldRejectWithdrawnCandidate() throws Exception { + long memberId = 1L; + long withdrawnMemberId = 2L; + MatchingRequest request = new MatchingRequest(null, null, null, null, false, null); + ProfileResponse myProfile = ProfileResponse.builder() + .memberId(memberId) + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .isMatchable(true) + .build(); + MatchingCandidate staleCandidate = MatchingCandidate.create( + withdrawnMemberId, + 10L, + Gender.FEMALE, + "ENFP", + "디자인학과", + ContactFrequency.FREQUENT, + List.of(HobbyCategory.SPORTS), + LocalDate.of(2000, 1, 1), + true + ); + ProfileResponse withdrawnProfile = ProfileResponse.builder() + .memberId(withdrawnMemberId) + .isMatchable(false) + .build(); + + given(memberClient.getProfile(memberId)).willReturn(myProfile); + given(matchingItemPolicy.determine(request)).willReturn(List.of()); + given(matchingProcessor.process(memberId, myProfile, request)).willReturn(staleCandidate); + given(memberClient.getProfile(withdrawnMemberId)).willReturn(withdrawnProfile); + + mockMvc.perform(post("/api/matching") + .header("X-Member-Id", String.valueOf(memberId)) + .contentType("application/json") + .content("{}")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("MATCH-001")); + + then(historyRepository).shouldHaveNoInteractions(); + then(matchingEventProducer).shouldHaveNoInteractions(); + } +} diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/ProfileControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/ProfileControllerTest.java new file mode 100644 index 0000000..7fe32bc --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/ProfileControllerTest.java @@ -0,0 +1,102 @@ +package com.comatching.user.infra.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +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.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.service.S3Service; +import com.comatching.user.domain.event.UserEventPublisher; +import com.comatching.user.domain.member.entity.Member; +import com.comatching.user.domain.member.entity.Profile; +import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.domain.member.repository.ProfileRepository; +import com.comatching.user.domain.member.service.EnumLookupService; +import com.comatching.user.domain.member.service.ProfileServiceImpl; +import com.comatching.user.global.config.ProfileImageProperties; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProfileController 사전가입 이벤트 API 테스트") +class ProfileControllerTest { + + private MockMvc mockMvc; + + @Mock private MemberRepository memberRepository; + @Mock private ProfileRepository profileRepository; + @Mock private UserEventPublisher eventPublisher; + @Mock private ProfileImageProperties profileImageProperties; + @Mock private S3Service s3Service; + @Mock private EnumLookupService enumLookupService; + + @BeforeEach + void setUp() { + ProfileServiceImpl profileService = new ProfileServiceImpl( + memberRepository, + profileRepository, + eventPublisher, + profileImageProperties, + s3Service + ); + mockMvc = MockMvcBuilders.standaloneSetup( + new ProfileController(profileService, profileService, enumLookupService) + ).build(); + } + + @Test + @DisplayName("POST /api/internal/users/profile은 프로필을 생성해도 member-signup 이벤트를 발행하지 않는다") + void shouldCreateProfileWithoutPreSignupEvent() throws Exception { + Member member = Member.builder() + .email("member@example.com") + .password("password") + .role(MemberRole.ROLE_GUEST) + .status(MemberStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(member, "id", 1L); + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(profileImageProperties.baseUrl()).willReturn("https://img.example.com/"); + given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(post("/api/internal/users/profile") + .header("X-Member-Id", "1") + .contentType("application/json") + .content(""" + { + "nickname": "테스트유저", + "gender": "MALE", + "birthDate": "2000-01-01", + "mbti": "ENFP", + "university": "한국대학교", + "major": "컴퓨터공학과", + "contactFrequency": "FREQUENT", + "hobbies": [ + {"category": "SPORTS", "name": "축구"}, + {"category": "CULTURE", "name": "영화감상"} + ], + "tags": [] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.memberId").value(1)) + .andExpect(jsonPath("$.isMatchable").value(true)); + + then(eventPublisher).should().sendProfileUpdatedMatchingEvent(any()); + then(eventPublisher).should(never()).sendSignupEvent(any()); + } +} From 790b372b54f6ebafc098b71a112b726b742b31d5 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Mon, 14 Sep 2026 23:24:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(matching):=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=ED=9B=84=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=20=20=20=20-=20=ED=83=88=ED=87=B4=20tombs?= =?UTF-8?q?tone=EC=9D=B4=20=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EC=9D=84=20=EB=A7=A4=EC=B9=AD=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=20=20=20=20=20?= =?UTF-8?q?-=20stale=20=ED=9B=84=EB=B3=B4=EA=B0=80=20=EB=82=A8=EC=95=84=20?= =?UTF-8?q?=EC=9E=88=EC=96=B4=EB=8F=84=20=ED=83=88=ED=87=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=9D=B4=20=EC=84=A0=ED=83=9D=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=B4=EC=99=84=20=20=20=20=20?= =?UTF-8?q?-=20=ED=83=88=ED=87=B4=20=ED=9A=8C=EC=9B=90=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=9C=EC=99=B8=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MatchingCandidateRepositoryImpl.java | 5 +++++ .../candidate/MatchingCandidateRepositoryIT.java | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java index 8e5bc5f..a193872 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java @@ -158,6 +158,11 @@ private String buildSample(MatchingCandidateSearchCondition condition, HobbyCate conditions.add("mc.is_matchable = 1"); + // 탈퇴 tombstone 이 남아 있는 회원은 후보 테이블에 stale row 가 있어도 + // 표본 단계에서부터 매칭 대상에서 제외한다. + conditions.add("NOT EXISTS (SELECT 1 FROM withdrawn_member wm" + + " WHERE wm.member_id = mc.member_id)"); + conditions.add("mc.random_key >= :randomStart"); params.put("randomStart", condition.randomStart()); diff --git a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java index 48bb1c7..1f11a7a 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -26,6 +27,7 @@ import com.comatching.common.domain.enums.Gender; import com.comatching.common.domain.enums.HobbyCategory; import com.comatching.matching.domain.entity.MatchingCandidate; +import com.comatching.matching.domain.entity.WithdrawnMember; import com.comatching.matching.domain.enums.AgeOption; import com.comatching.matching.support.MySqlContainerSupport; @@ -54,6 +56,9 @@ class MatchingCandidateRepositoryIT extends MySqlContainerSupport { @Autowired private MatchingCandidateRepository repository; + @Autowired + private WithdrawnMemberRepository withdrawnMemberRepository; + @Autowired private EntityManager em; @@ -157,6 +162,15 @@ void excludesNotMatchable() { assertThat(findBest(base().build())).isEmpty(); } + @Test + @DisplayName("탈퇴 tombstone 이 있는 후보는 제외한다") + void excludesWithdrawnMember() { + save(1L, Gender.FEMALE, "XXXX", 99, ContactFrequency.RARE, null, true, List.of()); + withdrawnMemberRepository.save(WithdrawnMember.of(1L, LocalDateTime.now())); + + assertThat(findBest(base().build())).isEmpty(); + } + @Test @DisplayName("이미 매칭된 상대는 제외한다") void excludesAlreadyMatched() { @@ -446,7 +460,7 @@ MatchingCandidateSearchCondition build() { @SpringBootConfiguration @EnableAutoConfiguration - @EntityScan(basePackageClasses = MatchingCandidate.class) + @EntityScan(basePackageClasses = {MatchingCandidate.class, WithdrawnMember.class}) @EnableJpaRepositories(basePackageClasses = MatchingCandidateRepository.class) static class Config { } From dffed1cbfc3f00e4376bd5d4d44e302f40a41822 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Mon, 14 Sep 2026 23:37:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=B4=88=EA=B8=B0=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20=20=20-=20FREE=20=EB=B0=8F=20SPECIAL=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=EB=B3=B4=EC=83=81=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=A4=EC=A0=95=20=20=20=20=20?= =?UTF-8?q?-=20=EB=B3=B4=EC=83=81=EB=B3=84=20=ED=99=95=EB=A5=A0=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=EA=B3=BC=20=EC=A7=80=EA=B8=89=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EB=B0=8F=20=EC=83=81=ED=92=88=EA=B6=8C=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=84=A4=EC=A0=95=20=20=20=20=20-=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EB=B3=B4=EC=83=81=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=20=EC=8B=9C=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EA=B1=B4=EB=84=88=EB=9B=B0=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../init/RouletteRewardDataInitializer.java | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 item-service/src/main/java/com/comatching/item/global/init/RouletteRewardDataInitializer.java diff --git a/item-service/src/main/java/com/comatching/item/global/init/RouletteRewardDataInitializer.java b/item-service/src/main/java/com/comatching/item/global/init/RouletteRewardDataInitializer.java new file mode 100644 index 0000000..4fb7577 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/global/init/RouletteRewardDataInitializer.java @@ -0,0 +1,196 @@ +package com.comatching.item.global.init; + +import java.util.List; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RouletteRewardDataInitializer implements CommandLineRunner { + + private final RouletteRewardRepository rouletteRewardRepository; + + @Override + @Transactional + public void run(String... args) throws Exception { + if (rouletteRewardRepository.count() > 0) { + log.info("[RouletteRewardDataInitializer] 이미 룰렛 보상 데이터가 존재하여 초기화를 건너뜁니다."); + return; + } + + log.info("[RouletteRewardDataInitializer] 초기 룰렛 보상 데이터를 생성합니다..."); + + List rewards = List.of( + + // FREE Roulette + reward( + RouletteType.FREE, + "옵션권 1장", + RewardType.OPTION_TICKET, + 1, + 1, + 4500, + null + ), + + reward( + RouletteType.FREE, + "옵션권 2장", + RewardType.OPTION_TICKET, + 2, + 4501, + 7000, + null + ), + + reward( + RouletteType.FREE, + "꽝", + RewardType.NONE, + 0, + 7001, + 8500, + null + ), + + reward( + RouletteType.FREE, + "뽑기권 1장", + RewardType.MATCHING_TICKET, + 1, + 8501, + 9700, + null + ), + + reward( + RouletteType.FREE, + "풀세트", + RewardType.FULL_SET, + 0, + 9701, + 10000, + null + ), + + // SPECIAL Roulette + reward( + RouletteType.SPECIAL, + "옵션권 2장", + RewardType.OPTION_TICKET, + 2, + 1, + 3900, + null + ), + + reward( + RouletteType.SPECIAL, + "옵션권 5장", + RewardType.OPTION_TICKET, + 5, + 3901, + 6400, + null + ), + + reward( + RouletteType.SPECIAL, + "뽑기권 1장", + RewardType.MATCHING_TICKET, + 1, + 6401, + 8400, + null + ), + + reward( + RouletteType.SPECIAL, + "풀세트", + RewardType.FULL_SET, + 0, + 8401, + 9400, + null + ), + + reward( + RouletteType.SPECIAL, + "뽑기권 5장", + RewardType.MATCHING_TICKET, + 5, + 9401, + 9600, + null + ), + + reward( + RouletteType.SPECIAL, + "뽑기권 10장", + RewardType.MATCHING_TICKET, + 10, + 9601, + 9750, + null + ), + + reward( + RouletteType.SPECIAL, + "1만원권 상품권", + RewardType.GIFT_CARD, + 0, + 9751, + 9900, + 999 + ), + + reward( + RouletteType.SPECIAL, + "2만원권 상품권", + RewardType.GIFT_CARD, + 0, + 9901, + 10000, + 999 + ) + ); + + rouletteRewardRepository.saveAll(rewards); + + log.info( + "[RouletteRewardDataInitializer] 룰렛 보상 {}개 생성 완료.", + rewards.size() + ); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int quantity, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(quantity) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } +} \ No newline at end of file