From d3001d4aedf7779e329badb1c0328e7cfe373046 Mon Sep 17 00:00:00 2001 From: Chhun-Lee Date: Mon, 14 Sep 2026 15:01:37 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat]#62=20BACKGROUND=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=8B=9C?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EC=BD=94=EB=93=9C=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?upsert=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본 보유 7종·상점 배경 5종 정의, 목록 외 활성 아이템은 비활성화 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HtgegmjNs3gAc6qNCTekNa --- .../item/config/ItemMasterDataSeeder.java | 80 +++++++++++++--- .../quespot/domain/item/entity/ShopItem.java | 61 +++++++++++-- .../domain/item/enums/ItemCategory.java | 3 +- .../item/repository/ShopItemRepository.java | 4 +- .../item/config/ItemMasterDataSeederTest.java | 91 ++++++++++++++----- 5 files changed, 193 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java b/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java index e85b007..b68d0a5 100644 --- a/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java +++ b/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java @@ -11,10 +11,17 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; -// 앱 기동 시 퀘스티 꾸미기 마스터 데이터를 시드한다. 로우별로 존재 여부를 확인해 없는 것만 추가한다. +// 앱 기동 시 퀘스티 꾸미기 마스터 데이터를 시드한다. +// 슬롯 제한: 카테고리별로 없는 것만 추가. +// 아이템: 코드 기준으로 없으면 추가, 있으면 정의가 다를 때 갱신, 목록에서 빠진 활성 아이템은 +// 비활성화(#62, RewardMasterDataSeeder.seedBadges와 같은 패턴). image_url은 관리하지 않는다. @Component @RequiredArgsConstructor public class ItemMasterDataSeeder implements CommandLineRunner { @@ -22,6 +29,36 @@ public class ItemMasterDataSeeder implements CommandLineRunner { private final EquipSlotLimitRepository equipSlotLimitRepository; private final ShopItemRepository shopItemRepository; + record ShopItemDefinition( + String code, + String name, + ItemCategory category, + ItemRarity rarity, + int price, + boolean isDefault, + int sortOrder + ) { + ShopItem toEntity() { + return ShopItem.seed(code, name, category, rarity, price, isDefault, false, sortOrder); + } + } + + // 기본 보유 7종(가격 0, is_default) + 상점 배경 5종(#62). 프론트 화면과 1:1. + static final List SHOP_ITEMS = List.of( + new ShopItemDefinition("EXPLORER_HAT", "탐험가 모자", ItemCategory.HAT, ItemRarity.NORMAL, 0, true, 1), + new ShopItemDefinition("PURPLE_SUNGLASSES", "보라 선글라스", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, 2), + new ShopItemDefinition("RED_SCARF", "빨간 스카프", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, 3), + new ShopItemDefinition("YELLOW_RAINCOAT", "노란 우비", ItemCategory.OUTFIT, ItemRarity.NORMAL, 0, true, 4), + new ShopItemDefinition("EXPLORER_VEST", "탐험 조끼", ItemCategory.OUTFIT, ItemRarity.NORMAL, 0, true, 5), + new ShopItemDefinition("TRAVEL_BAG", "여행 가방", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, 6), + new ShopItemDefinition("EXPLORER_MAGNIFIER", "탐험 돋보기", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, 7), + new ShopItemDefinition("COZY_CAFE", "포근한 카페", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 320, false, 11), + new ShopItemDefinition("SUNNY_CLASSROOM", "햇살 교실", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 320, false, 12), + new ShopItemDefinition("COZY_ROOM", "포근한 방", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 280, false, 13), + new ShopItemDefinition("BEACH_CAMPING", "바닷가 캠핑", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 360, false, 14), + new ShopItemDefinition("STARLIGHT_OBSERVATORY", "별빛 관측소", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 280, false, 15) + ); + @Override @Transactional public void run(String... args) { @@ -34,7 +71,8 @@ private void seedEquipSlotLimits() { EquipSlotLimit.seed(ItemCategory.HAT, 1, 1), EquipSlotLimit.seed(ItemCategory.ACCESSORY, 2, 2), EquipSlotLimit.seed(ItemCategory.OUTFIT, 1, 3), - EquipSlotLimit.seed(ItemCategory.ITEM, 1, 4) + EquipSlotLimit.seed(ItemCategory.ITEM, 1, 4), + EquipSlotLimit.seed(ItemCategory.BACKGROUND, 1, 5) ) .filter(limit -> !equipSlotLimitRepository.existsById(limit.getCategory())) .toList(); @@ -45,20 +83,34 @@ private void seedEquipSlotLimits() { } private void seedShopItems() { - List missing = Stream.of( - ShopItem.seed("EXPLORER_HAT", "탐험가 모자", ItemCategory.HAT, ItemRarity.NORMAL, 0, true, false), - ShopItem.seed("SUNGLASSES", "선글라스", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, false), - ShopItem.seed("BLUE_SCARF", "파란 스카프", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, false), - ShopItem.seed("TRAVEL_BAG", "여행 가방", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, false), - ShopItem.seed("GOLDEN_CROWN", "황금 왕관", ItemCategory.HAT, ItemRarity.LEGENDARY, 300, false, false), - ShopItem.seed("TRAVELER_CAP", "여행자 캡", ItemCategory.HAT, ItemRarity.NORMAL, 150, false, false), - ShopItem.seed("GRADUATION_CAP", "학사모", ItemCategory.HAT, ItemRarity.RARE, 200, false, false) - ) - .filter(item -> !shopItemRepository.existsByCode(item.getCode())) - .toList(); - + List missing = new ArrayList<>(); + for (ShopItemDefinition def : SHOP_ITEMS) { + Optional existing = shopItemRepository.findByCode(def.code()); + if (existing.isEmpty()) { + missing.add(def.toEntity()); + } else if (needsUpdate(existing.get(), def)) { + existing.get().updateMaster(def.name(), def.category(), def.rarity(), def.price(), + def.isDefault(), false, def.sortOrder()); + } + } if (!missing.isEmpty()) { shopItemRepository.saveAll(missing); } + + Set knownCodes = SHOP_ITEMS.stream().map(ShopItemDefinition::code).collect(Collectors.toSet()); + shopItemRepository.findByIsActiveTrue().stream() + .filter(item -> !knownCodes.contains(item.getCode())) + .forEach(ShopItem::deactivate); + } + + private boolean needsUpdate(ShopItem item, ShopItemDefinition def) { + return !def.name().equals(item.getName()) + || def.category() != item.getCategory() + || def.rarity() != item.getRarity() + || def.price() != item.getPrice() + || def.isDefault() != item.getIsDefault() + || Boolean.TRUE.equals(item.getIsFeatured()) + || def.sortOrder() != item.getSortOrder() + || !Boolean.TRUE.equals(item.getIsActive()); } } diff --git a/src/main/java/com/quespot/domain/item/entity/ShopItem.java b/src/main/java/com/quespot/domain/item/entity/ShopItem.java index c89422c..1a4189b 100644 --- a/src/main/java/com/quespot/domain/item/entity/ShopItem.java +++ b/src/main/java/com/quespot/domain/item/entity/ShopItem.java @@ -71,13 +71,10 @@ private ShopItem( ItemRarity rarity, Integer price, Boolean isDefault, - Boolean isFeatured + Boolean isFeatured, + Integer sortOrder ) { - // 음수 가격은 차감 없이 지급되는 경로가 된다(ItemPurchaseService는 price > 0일 때만 차감). - // DB CHECK는 ddl-auto: update가 기존 테이블에 안 붙이므로 Flyway 도입 때 같이 건다. - if (price == null || price < 0) { - throw new IllegalArgumentException("아이템 가격은 0 이상이어야 합니다: " + price); - } + validatePrice(price); this.code = code; this.name = name; this.category = category; @@ -86,7 +83,7 @@ private ShopItem( this.isDefault = isDefault; this.isFeatured = isFeatured; this.isActive = true; - this.sortOrder = 0; + this.sortOrder = sortOrder; } public static ShopItem seed( @@ -98,6 +95,54 @@ public static ShopItem seed( Boolean isDefault, Boolean isFeatured ) { - return new ShopItem(code, name, category, rarity, price, isDefault, isFeatured); + return seed(code, name, category, rarity, price, isDefault, isFeatured, 0); + } + + public static ShopItem seed( + String code, + String name, + ItemCategory category, + ItemRarity rarity, + Integer price, + Boolean isDefault, + Boolean isFeatured, + Integer sortOrder + ) { + return new ShopItem(code, name, category, rarity, price, isDefault, isFeatured, sortOrder); + } + + // 마스터 정의가 바뀌었을 때 시더가 호출한다. 비활성이었던 아이템이 정의에 다시 들어오면 활성으로 되돌린다. + // image_url은 시더가 관리하지 않으므로 여기서 건드리지 않는다. + public void updateMaster( + String name, + ItemCategory category, + ItemRarity rarity, + Integer price, + Boolean isDefault, + Boolean isFeatured, + Integer sortOrder + ) { + validatePrice(price); + this.name = name; + this.category = category; + this.rarity = rarity; + this.price = price; + this.isDefault = isDefault; + this.isFeatured = isFeatured; + this.sortOrder = sortOrder; + this.isActive = true; + } + + // 정의 목록에서 빠진 아이템. 행은 남긴다(보유 이력 보존). 판매·지급·보유 목록에서 모두 빠진다. + public void deactivate() { + this.isActive = false; + } + + // 음수 가격은 차감 없이 지급되는 경로가 된다(ItemPurchaseService는 price > 0일 때만 차감). + // DB CHECK는 ddl-auto: update가 기존 테이블에 안 붙이므로 Flyway 도입 때 같이 건다. + private static void validatePrice(Integer price) { + if (price == null || price < 0) { + throw new IllegalArgumentException("아이템 가격은 0 이상이어야 합니다: " + price); + } } } diff --git a/src/main/java/com/quespot/domain/item/enums/ItemCategory.java b/src/main/java/com/quespot/domain/item/enums/ItemCategory.java index 2f4fd24..f37f9d5 100644 --- a/src/main/java/com/quespot/domain/item/enums/ItemCategory.java +++ b/src/main/java/com/quespot/domain/item/enums/ItemCategory.java @@ -4,5 +4,6 @@ public enum ItemCategory { HAT, ACCESSORY, OUTFIT, - ITEM + ITEM, + BACKGROUND } diff --git a/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java b/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java index ffe726a..b997862 100644 --- a/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java +++ b/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java @@ -13,7 +13,9 @@ public interface ShopItemRepository extends JpaRepository { List findByCategoryAndIsActiveTrueOrderBySortOrderAsc(ItemCategory category); - boolean existsByCode(String code); + Optional findByCode(String code); + + List findByIsActiveTrue(); Optional findByIdAndIsActiveTrue(Long id); } diff --git a/src/test/java/com/quespot/domain/item/config/ItemMasterDataSeederTest.java b/src/test/java/com/quespot/domain/item/config/ItemMasterDataSeederTest.java index 3d7c9e7..d4fee08 100644 --- a/src/test/java/com/quespot/domain/item/config/ItemMasterDataSeederTest.java +++ b/src/test/java/com/quespot/domain/item/config/ItemMasterDataSeederTest.java @@ -3,6 +3,7 @@ import com.quespot.domain.item.entity.EquipSlotLimit; import com.quespot.domain.item.entity.ShopItem; import com.quespot.domain.item.enums.ItemCategory; +import com.quespot.domain.item.enums.ItemRarity; import com.quespot.domain.item.repository.EquipSlotLimitRepository; import com.quespot.domain.item.repository.ShopItemRepository; import org.junit.jupiter.api.BeforeEach; @@ -10,6 +11,7 @@ import org.mockito.ArgumentCaptor; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -34,55 +36,100 @@ void setUp() { @Test @SuppressWarnings("unchecked") - void seedsEquipSlotLimitsAndShopItemsWhenTablesAreEmpty() throws Exception { + void seedsFiveSlotLimitsAndTwelveShopItemsWhenTablesAreEmpty() throws Exception { when(equipSlotLimitRepository.existsById(any(ItemCategory.class))).thenReturn(false); - when(shopItemRepository.existsByCode(anyString())).thenReturn(false); + when(shopItemRepository.findByCode(anyString())).thenReturn(Optional.empty()); + when(shopItemRepository.findByIsActiveTrue()).thenReturn(List.of()); seeder.run(); ArgumentCaptor> slotLimitsCaptor = ArgumentCaptor.forClass(List.class); verify(equipSlotLimitRepository).saveAll(slotLimitsCaptor.capture()); - assertThat(slotLimitsCaptor.getValue()).hasSize(4); + assertThat(slotLimitsCaptor.getValue()) + .extracting(EquipSlotLimit::getCategory) + .containsExactly(ItemCategory.HAT, ItemCategory.ACCESSORY, ItemCategory.OUTFIT, + ItemCategory.ITEM, ItemCategory.BACKGROUND); ArgumentCaptor> shopItemsCaptor = ArgumentCaptor.forClass(List.class); verify(shopItemRepository).saveAll(shopItemsCaptor.capture()); - assertThat(shopItemsCaptor.getValue()).hasSize(7); + List saved = shopItemsCaptor.getValue(); + assertThat(saved).hasSize(12); + assertThat(saved).filteredOn(ShopItem::getIsDefault).hasSize(7) + .allSatisfy(item -> { + assertThat(item.getPrice()).isZero(); + assertThat(item.getCategory()).isNotEqualTo(ItemCategory.BACKGROUND); + }); + assertThat(saved).filteredOn(item -> !item.getIsDefault()).hasSize(5) + .allSatisfy(item -> { + assertThat(item.getCategory()).isEqualTo(ItemCategory.BACKGROUND); + assertThat(item.getPrice()).isPositive(); + }); + assertThat(saved).extracting(ShopItem::getCode).contains( + "EXPLORER_HAT", "PURPLE_SUNGLASSES", "RED_SCARF", "YELLOW_RAINCOAT", + "EXPLORER_VEST", "TRAVEL_BAG", "EXPLORER_MAGNIFIER", + "COZY_CAFE", "SUNNY_CLASSROOM", "COZY_ROOM", "BEACH_CAMPING", "STARLIGHT_OBSERVATORY"); + assertThat(saved).filteredOn(item -> "STARLIGHT_OBSERVATORY".equals(item.getCode())) + .singleElement().extracting(ShopItem::getPrice).isEqualTo(280); } @Test - void skipsSeedingWhenAllMasterRowsAlreadyExist() throws Exception { + void doesNothingWhenEveryRowAlreadyMatchesDefinition() throws Exception { when(equipSlotLimitRepository.existsById(any(ItemCategory.class))).thenReturn(true); - when(shopItemRepository.existsByCode(anyString())).thenReturn(true); + List existing = ItemMasterDataSeeder.SHOP_ITEMS.stream() + .map(ItemMasterDataSeeder.ShopItemDefinition::toEntity) + .toList(); + for (ShopItem item : existing) { + when(shopItemRepository.findByCode(item.getCode())).thenReturn(Optional.of(item)); + } + when(shopItemRepository.findByIsActiveTrue()).thenReturn(existing); seeder.run(); verify(equipSlotLimitRepository, never()).saveAll(any()); verify(shopItemRepository, never()).saveAll(any()); + assertThat(existing).allMatch(ShopItem::getIsActive); } @Test @SuppressWarnings("unchecked") - void seedsOnlyMissingRowsWhenTablesArePartiallyPopulated() throws Exception { - when(equipSlotLimitRepository.existsById(ItemCategory.HAT)).thenReturn(true); - when(equipSlotLimitRepository.existsById(ItemCategory.ACCESSORY)).thenReturn(false); - when(equipSlotLimitRepository.existsById(ItemCategory.OUTFIT)).thenReturn(false); - when(equipSlotLimitRepository.existsById(ItemCategory.ITEM)).thenReturn(false); - - when(shopItemRepository.existsByCode(anyString())).thenReturn(true); - when(shopItemRepository.existsByCode("GOLDEN_CROWN")).thenReturn(false); + void updatesExistingRowWhoseDefinitionChangedAndInsertsTheRest() throws Exception { + when(equipSlotLimitRepository.existsById(any(ItemCategory.class))).thenReturn(true); + ShopItem staleHat = ShopItem.seed("EXPLORER_HAT", "옛 이름", ItemCategory.HAT, ItemRarity.RARE, 50, false, true, 99); + when(shopItemRepository.findByCode(anyString())).thenReturn(Optional.empty()); + when(shopItemRepository.findByCode("EXPLORER_HAT")).thenReturn(Optional.of(staleHat)); + when(shopItemRepository.findByIsActiveTrue()).thenReturn(List.of(staleHat)); seeder.run(); - ArgumentCaptor> slotLimitsCaptor = ArgumentCaptor.forClass(List.class); - verify(equipSlotLimitRepository).saveAll(slotLimitsCaptor.capture()); - assertThat(slotLimitsCaptor.getValue()) - .extracting(EquipSlotLimit::getCategory) - .containsExactly(ItemCategory.ACCESSORY, ItemCategory.OUTFIT, ItemCategory.ITEM); + assertThat(staleHat.getName()).isEqualTo("탐험가 모자"); + assertThat(staleHat.getRarity()).isEqualTo(ItemRarity.NORMAL); + assertThat(staleHat.getPrice()).isZero(); + assertThat(staleHat.getIsDefault()).isTrue(); + assertThat(staleHat.getIsFeatured()).isFalse(); + assertThat(staleHat.getSortOrder()).isEqualTo(1); + assertThat(staleHat.getIsActive()).isTrue(); ArgumentCaptor> shopItemsCaptor = ArgumentCaptor.forClass(List.class); verify(shopItemRepository).saveAll(shopItemsCaptor.capture()); - assertThat(shopItemsCaptor.getValue()) - .extracting(ShopItem::getCode) - .containsExactly("GOLDEN_CROWN"); + assertThat(shopItemsCaptor.getValue()).hasSize(11) + .extracting(ShopItem::getCode).doesNotContain("EXPLORER_HAT"); + } + + @Test + void deactivatesActiveItemsMissingFromDefinitionsAndReactivatesKnownOnes() throws Exception { + when(equipSlotLimitRepository.existsById(any(ItemCategory.class))).thenReturn(true); + ShopItem legacyCrown = ShopItem.seed("GOLDEN_CROWN", "황금 왕관", ItemCategory.HAT, ItemRarity.LEGENDARY, 300, false, false, 0); + ShopItem legacyScarf = ShopItem.seed("BLUE_SCARF", "파란 스카프", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, false, 0); + ShopItem inactiveBag = ShopItem.seed("TRAVEL_BAG", "여행 가방", ItemCategory.ITEM, ItemRarity.NORMAL, 0, true, false, 6); + inactiveBag.deactivate(); + when(shopItemRepository.findByCode(anyString())).thenReturn(Optional.empty()); + when(shopItemRepository.findByCode("TRAVEL_BAG")).thenReturn(Optional.of(inactiveBag)); + when(shopItemRepository.findByIsActiveTrue()).thenReturn(List.of(legacyCrown, legacyScarf)); + + seeder.run(); + + assertThat(legacyCrown.getIsActive()).isFalse(); + assertThat(legacyScarf.getIsActive()).isFalse(); + assertThat(inactiveBag.getIsActive()).isTrue(); } } From 2ae3393397c82f382b4d796cda665f0fa01857a4 Mon Sep 17 00:00:00 2001 From: Chhun-Lee Date: Mon, 14 Sep 2026 15:02:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Feat]#62=20=EC=83=81=EC=A0=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=EA=B8=B0=EB=B3=B8=20=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HtgegmjNs3gAc6qNCTekNa --- .../item/controller/ShopItemController.java | 3 ++- .../item/repository/ShopItemRepository.java | 5 ++-- .../domain/item/service/ShopItemService.java | 5 ++-- .../item/service/ShopItemServiceTest.java | 23 ++++++++++--------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/quespot/domain/item/controller/ShopItemController.java b/src/main/java/com/quespot/domain/item/controller/ShopItemController.java index d2ada45..41d48bd 100644 --- a/src/main/java/com/quespot/domain/item/controller/ShopItemController.java +++ b/src/main/java/com/quespot/domain/item/controller/ShopItemController.java @@ -62,7 +62,8 @@ public ApiResponse purchase( @GetMapping @Operation( summary = "상점 아이템 목록", - description = "판매 중인 상점 아이템 목록을 조회한다. category를 지정하면 해당 카테고리만 반환한다." + description = "판매 중인 상점 아이템 목록을 조회한다. 기본 보유 아이템(isDefault)은 포함하지 않는다. " + + "category를 지정하면 해당 카테고리만 반환한다." ) public ApiResponse> getShopItems( @RequestParam(required = false) ItemCategory category diff --git a/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java b/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java index b997862..9963747 100644 --- a/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java +++ b/src/main/java/com/quespot/domain/item/repository/ShopItemRepository.java @@ -9,9 +9,10 @@ public interface ShopItemRepository extends JpaRepository { - List findByIsActiveTrueOrderBySortOrderAsc(); + // 상점 목록. 기본 보유 아이템(is_default)은 지급 대상이지 판매 대상이 아니라 뺀다(#62). + List findByIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(); - List findByCategoryAndIsActiveTrueOrderBySortOrderAsc(ItemCategory category); + List findByCategoryAndIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(ItemCategory category); Optional findByCode(String code); diff --git a/src/main/java/com/quespot/domain/item/service/ShopItemService.java b/src/main/java/com/quespot/domain/item/service/ShopItemService.java index 529ff6e..0f1ba49 100644 --- a/src/main/java/com/quespot/domain/item/service/ShopItemService.java +++ b/src/main/java/com/quespot/domain/item/service/ShopItemService.java @@ -17,11 +17,12 @@ public class ShopItemService { private final ShopItemRepository shopItemRepository; + // 상점엔 판매 아이템만(기본 보유 아이템 제외). 현재 마스터 기준으로는 배경 5종이다(#62). @Transactional(readOnly = true) public List getShopItems(ItemCategory category) { List shopItems = category == null - ? shopItemRepository.findByIsActiveTrueOrderBySortOrderAsc() - : shopItemRepository.findByCategoryAndIsActiveTrueOrderBySortOrderAsc(category); + ? shopItemRepository.findByIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc() + : shopItemRepository.findByCategoryAndIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(category); return shopItems.stream() .map(ItemConverter::toShopItemResponseDTO) diff --git a/src/test/java/com/quespot/domain/item/service/ShopItemServiceTest.java b/src/test/java/com/quespot/domain/item/service/ShopItemServiceTest.java index 848895a..39d012d 100644 --- a/src/test/java/com/quespot/domain/item/service/ShopItemServiceTest.java +++ b/src/test/java/com/quespot/domain/item/service/ShopItemServiceTest.java @@ -29,27 +29,28 @@ void setUp() { } @Test - void listsAllActiveItemsWhenCategoryIsNull() { - ShopItem item = ShopItem.seed("EXPLORER_HAT", "탐험가 모자", ItemCategory.HAT, ItemRarity.NORMAL, 0, true, false); - when(shopItemRepository.findByIsActiveTrueOrderBySortOrderAsc()).thenReturn(List.of(item)); + void listsActiveNonDefaultItemsWhenCategoryIsNull() { + ShopItem item = ShopItem.seed("COZY_CAFE", "포근한 카페", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 320, false, false, 11); + when(shopItemRepository.findByIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc()).thenReturn(List.of(item)); List result = shopItemService.getShopItems(null); assertThat(result).hasSize(1); - assertThat(result.get(0).code()).isEqualTo("EXPLORER_HAT"); - verify(shopItemRepository, never()).findByCategoryAndIsActiveTrueOrderBySortOrderAsc(any()); + assertThat(result.get(0).code()).isEqualTo("COZY_CAFE"); + assertThat(result.get(0).isDefault()).isFalse(); + verify(shopItemRepository, never()).findByCategoryAndIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(any()); } @Test - void listsItemsByCategoryWhenCategoryProvided() { - ShopItem item = ShopItem.seed("GOLDEN_CROWN", "황금 왕관", ItemCategory.HAT, ItemRarity.LEGENDARY, 300, false, false); - when(shopItemRepository.findByCategoryAndIsActiveTrueOrderBySortOrderAsc(ItemCategory.HAT)) + void listsActiveNonDefaultItemsByCategoryWhenCategoryProvided() { + ShopItem item = ShopItem.seed("BEACH_CAMPING", "바닷가 캠핑", ItemCategory.BACKGROUND, ItemRarity.NORMAL, 360, false, false, 14); + when(shopItemRepository.findByCategoryAndIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(ItemCategory.BACKGROUND)) .thenReturn(List.of(item)); - List result = shopItemService.getShopItems(ItemCategory.HAT); + List result = shopItemService.getShopItems(ItemCategory.BACKGROUND); assertThat(result).hasSize(1); - assertThat(result.get(0).rarity()).isEqualTo(ItemRarity.LEGENDARY); - verify(shopItemRepository, never()).findByIsActiveTrueOrderBySortOrderAsc(); + assertThat(result.get(0).price()).isEqualTo(360); + verify(shopItemRepository, never()).findByIsActiveTrueAndIsDefaultFalseOrderBySortOrderAsc(); } } From a7609d5a18592bcadfe2a771dad84bfdd63e3dd7 Mon Sep 17 00:00:00 2001 From: Chhun-Lee Date: Mon, 14 Sep 2026 15:04:09 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Feat]#62=20=EA=B8=B0=EB=B3=B8=20=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89(DefaultItemGranter)=20=EB=B0=8F=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=20=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HtgegmjNs3gAc6qNCTekNa --- .../item/controller/QuestyController.java | 3 +- .../item/controller/UserItemController.java | 3 +- .../item/repository/UserItemRepository.java | 38 +++++++++++++++++-- .../item/service/DefaultItemGranter.java | 21 ++++++++++ .../domain/item/service/UserItemService.java | 13 +++++-- .../item/service/UserItemServiceTest.java | 27 ++++++++++--- 6 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/quespot/domain/item/service/DefaultItemGranter.java diff --git a/src/main/java/com/quespot/domain/item/controller/QuestyController.java b/src/main/java/com/quespot/domain/item/controller/QuestyController.java index 64c8856..7293712 100644 --- a/src/main/java/com/quespot/domain/item/controller/QuestyController.java +++ b/src/main/java/com/quespot/domain/item/controller/QuestyController.java @@ -27,7 +27,8 @@ public class QuestyController { @Operation( summary = "내 퀘스티 조회", description = "홈 화면에서 꾸민 퀘스티를 그리기 위한 장착 아이템(레이어 URL)과 보유 수, 최고 등급을 조회한다. " - + "서버는 이미지를 합성하지 않는다 — 프론트가 레이어를 겹쳐 그린다." + + "서버는 이미지를 합성하지 않는다 — 프론트가 레이어를 겹쳐 그린다. " + + "아직 지급되지 않은 기본 보유 아이템이 있으면 이 시점에 지급된다." ) public ApiResponse getQuesty(@AuthenticationPrincipal AuthenticatedUser principal) { return ApiResponse.of(ItemSuccessCode.QUESTY_FOUND, userItemService.getQuesty(principal.userId())); diff --git a/src/main/java/com/quespot/domain/item/controller/UserItemController.java b/src/main/java/com/quespot/domain/item/controller/UserItemController.java index 55c5310..b9875df 100644 --- a/src/main/java/com/quespot/domain/item/controller/UserItemController.java +++ b/src/main/java/com/quespot/domain/item/controller/UserItemController.java @@ -32,7 +32,8 @@ public class UserItemController { @GetMapping @Operation( summary = "내 보유 아이템 목록", - description = "로그인한 사용자가 보유한 아이템 목록을 장착 상태와 함께 조회한다." + description = "로그인한 사용자가 보유한 아이템 목록을 장착 상태와 함께 조회한다. " + + "아직 지급되지 않은 기본 보유 아이템이 있으면 이 시점에 지급된다." ) public ApiResponse> getMyItems( @AuthenticationPrincipal AuthenticatedUser principal diff --git a/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java b/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java index f7b1006..c037174 100644 --- a/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java +++ b/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java @@ -5,6 +5,7 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,15 +14,46 @@ public interface UserItemRepository extends JpaRepository { - @Query("select ui from UserItem ui join fetch ui.item where ui.userId = :userId order by ui.purchasedAt desc") + // 비활성 아이템은 보유 목록·퀘스티에서 뺀다(행은 남긴다, #62). + @Query(""" + select ui from UserItem ui + join fetch ui.item i + where ui.userId = :userId + and i.isActive = true + order by ui.purchasedAt desc + """) List findAllByUserIdWithItem(@Param("userId") Long userId); Optional findByUserIdAndItem_Id(Long userId, Long itemId); + // 장착 대상 조회. 비활성 아이템은 "보유하지 않음"으로 취급한다. + Optional findByUserIdAndItem_IdAndItem_IsActiveTrue(Long userId, Long itemId); + boolean existsByUserIdAndItem_Id(Long userId, Long itemId); - // 같은 유저·카테고리의 UserItem 전체를 잠가 장착 슬롯 한도 위반을 막는다 (동시 장착 요청 대비) + // 같은 유저·카테고리의 UserItem 전체를 잠가 장착 슬롯 한도 위반을 막는다 (동시 장착 요청 대비). + // 비활성 아이템은 슬롯을 차지하지 않는다. @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select ui from UserItem ui where ui.userId = :userId and ui.item.category = :category") + @Query(""" + select ui from UserItem ui + where ui.userId = :userId + and ui.item.category = :category + and ui.item.isActive = true + """) List lockAllByUserIdAndItem_Category(@Param("userId") Long userId, @Param("category") ItemCategory category); + + // 활성 기본 아이템 중 미보유분을 한 번에 지급한다(#62). UNIQUE(user_id, item_id) + + // ON DUPLICATE KEY UPDATE id = id로 멱등 — 동시 호출도 예외 없이 no-op. 같은 트랜잭션에서 + // UNIQUE 위반을 catch하면 rollback-only가 되므로(CLAUDE.md) 예외 경로 자체를 없앤다. + // 가격 0이라 포인트 원장·활동 기록은 남기지 않는다. + @Modifying + @Query(value = """ + INSERT INTO user_items (user_id, item_id, is_equipped, purchased_at, created_at, updated_at) + SELECT :userId, si.id, 0, NOW(), NOW(), NOW() + FROM shop_items si + WHERE si.is_default = 1 + AND si.is_active = 1 + ON DUPLICATE KEY UPDATE id = id + """, nativeQuery = true) + int grantDefaultItems(@Param("userId") Long userId); } diff --git a/src/main/java/com/quespot/domain/item/service/DefaultItemGranter.java b/src/main/java/com/quespot/domain/item/service/DefaultItemGranter.java new file mode 100644 index 0000000..e1926fc --- /dev/null +++ b/src/main/java/com/quespot/domain/item/service/DefaultItemGranter.java @@ -0,0 +1,21 @@ +package com.quespot.domain.item.service; + +import com.quespot.domain.item.repository.UserItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +// 기본 보유 아이템(is_default) 지연 지급(#62). 가입 훅은 회원 도메인(허건우)이라 건드리지 않고, +// 보유 목록·퀘스티 조회·장착 진입 시 미보유분을 채운다. 호출부 트랜잭션에 합류한다(REQUIRED) — +// 지급과 이어지는 조회가 어긋날 이유가 없고, 격리가 필요한 독립 항목도 아니다. +@Component +@RequiredArgsConstructor +public class DefaultItemGranter { + + private final UserItemRepository userItemRepository; + + @Transactional + public void grantMissing(Long userId) { + userItemRepository.grantDefaultItems(userId); + } +} diff --git a/src/main/java/com/quespot/domain/item/service/UserItemService.java b/src/main/java/com/quespot/domain/item/service/UserItemService.java index c60dba1..8b85387 100644 --- a/src/main/java/com/quespot/domain/item/service/UserItemService.java +++ b/src/main/java/com/quespot/domain/item/service/UserItemService.java @@ -19,23 +19,28 @@ import java.util.Comparator; import java.util.List; +// 보유 목록·퀘스티·장착은 진입 시 기본 아이템을 먼저 채운다(DefaultItemGranter, #62). +// INSERT가 들어가므로 readOnly 트랜잭션을 쓰지 않는다. @Service @RequiredArgsConstructor public class UserItemService { private final UserItemRepository userItemRepository; private final EquipSlotLimitRepository equipSlotLimitRepository; + private final DefaultItemGranter defaultItemGranter; - @Transactional(readOnly = true) + @Transactional public List getMyItems(Long userId) { + defaultItemGranter.grantMissing(userId); return userItemRepository.findAllByUserIdWithItem(userId).stream() .map(ItemConverter::toUserItemResponseDTO) .toList(); } // 기존 join fetch 쿼리 한 번으로 장착 목록·보유 수·최고 등급을 모두 계산한다(#50). - @Transactional(readOnly = true) + @Transactional public QuestyResponseDTO getQuesty(Long userId) { + defaultItemGranter.grantMissing(userId); List owned = userItemRepository.findAllByUserIdWithItem(userId); List equipped = owned.stream() .filter(UserItem::getIsEquipped) @@ -52,7 +57,8 @@ public QuestyResponseDTO getQuesty(Long userId) { @Transactional public void equip(Long userId, Long itemId) { - UserItem target = userItemRepository.findByUserIdAndItem_Id(userId, itemId) + defaultItemGranter.grantMissing(userId); + UserItem target = userItemRepository.findByUserIdAndItem_IdAndItem_IsActiveTrue(userId, itemId) .orElseThrow(() -> new ItemException(ItemErrorCode.ITEM_NOT_OWNED)); if (target.getIsEquipped()) { @@ -81,6 +87,7 @@ public void equip(Long userId, Long itemId) { target.equip(); } + // 해제는 비활성 아이템이어도 허용(멱등). 지급은 부르지 않는다. @Transactional public void unequip(Long userId, Long itemId) { userItemRepository.findByUserIdAndItem_Id(userId, itemId) diff --git a/src/test/java/com/quespot/domain/item/service/UserItemServiceTest.java b/src/test/java/com/quespot/domain/item/service/UserItemServiceTest.java index 0a599fb..4594049 100644 --- a/src/test/java/com/quespot/domain/item/service/UserItemServiceTest.java +++ b/src/test/java/com/quespot/domain/item/service/UserItemServiceTest.java @@ -14,6 +14,7 @@ import com.quespot.domain.item.dto.res.QuestyResponseDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -21,7 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -29,13 +32,15 @@ class UserItemServiceTest { private UserItemRepository userItemRepository; private EquipSlotLimitRepository equipSlotLimitRepository; + private DefaultItemGranter defaultItemGranter; private UserItemService userItemService; @BeforeEach void setUp() { userItemRepository = mock(UserItemRepository.class); equipSlotLimitRepository = mock(EquipSlotLimitRepository.class); - userItemService = new UserItemService(userItemRepository, equipSlotLimitRepository); + defaultItemGranter = mock(DefaultItemGranter.class); + userItemService = new UserItemService(userItemRepository, equipSlotLimitRepository, defaultItemGranter); } private ShopItem shopItem(Long id, ItemCategory category) { @@ -50,7 +55,7 @@ void equipsOwnedItemThatIsNotYetEquipped() { Long itemId = 10L; UserItem userItem = UserItem.acquire(userId, shopItem(itemId, ItemCategory.HAT)); - when(userItemRepository.findByUserIdAndItem_Id(userId, itemId)).thenReturn(Optional.of(userItem)); + when(userItemRepository.findByUserIdAndItem_IdAndItem_IsActiveTrue(userId, itemId)).thenReturn(Optional.of(userItem)); when(equipSlotLimitRepository.findById(ItemCategory.HAT)) .thenReturn(Optional.of(EquipSlotLimit.seed(ItemCategory.HAT, 1, 1))); when(userItemRepository.lockAllByUserIdAndItem_Category(userId, ItemCategory.HAT)) @@ -59,6 +64,9 @@ void equipsOwnedItemThatIsNotYetEquipped() { userItemService.equip(userId, itemId); assertThat(userItem.getIsEquipped()).isTrue(); + InOrder inOrder = inOrder(defaultItemGranter, userItemRepository); + inOrder.verify(defaultItemGranter).grantMissing(userId); + inOrder.verify(userItemRepository).findByUserIdAndItem_IdAndItem_IsActiveTrue(userId, itemId); } @Test @@ -68,12 +76,13 @@ void equipIsIdempotentWhenAlreadyEquipped() { UserItem userItem = UserItem.acquire(userId, shopItem(itemId, ItemCategory.HAT)); userItem.equip(); - when(userItemRepository.findByUserIdAndItem_Id(userId, itemId)).thenReturn(Optional.of(userItem)); + when(userItemRepository.findByUserIdAndItem_IdAndItem_IsActiveTrue(userId, itemId)).thenReturn(Optional.of(userItem)); userItemService.equip(userId, itemId); assertThat(userItem.getIsEquipped()).isTrue(); verifyNoInteractions(equipSlotLimitRepository); + verify(defaultItemGranter).grantMissing(userId); } @Test @@ -83,7 +92,7 @@ void equipSwapsOldestEquippedItemWhenSlotLimitReached() { currentlyEquipped.equip(); UserItem newItem = UserItem.acquire(userId, shopItem(2L, ItemCategory.HAT)); - when(userItemRepository.findByUserIdAndItem_Id(userId, 2L)).thenReturn(Optional.of(newItem)); + when(userItemRepository.findByUserIdAndItem_IdAndItem_IsActiveTrue(userId, 2L)).thenReturn(Optional.of(newItem)); when(equipSlotLimitRepository.findById(ItemCategory.HAT)) .thenReturn(Optional.of(EquipSlotLimit.seed(ItemCategory.HAT, 1, 1))); when(userItemRepository.lockAllByUserIdAndItem_Category(userId, ItemCategory.HAT)) @@ -99,7 +108,7 @@ void equipSwapsOldestEquippedItemWhenSlotLimitReached() { void equipThrowsWhenItemNotOwned() { Long userId = 1L; Long itemId = 99L; - when(userItemRepository.findByUserIdAndItem_Id(userId, itemId)).thenReturn(Optional.empty()); + when(userItemRepository.findByUserIdAndItem_IdAndItem_IsActiveTrue(userId, itemId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> userItemService.equip(userId, itemId)) .isInstanceOf(ItemException.class) @@ -128,6 +137,8 @@ void unequipIsIdempotentWhenItemNotOwned() { when(userItemRepository.findByUserIdAndItem_Id(userId, itemId)).thenReturn(Optional.empty()); userItemService.unequip(userId, itemId); + + verifyNoInteractions(defaultItemGranter); } @Test @@ -141,6 +152,9 @@ void listsOwnedItemsOrderedByPurchasedAtDescending() { assertThat(result).hasSize(1); assertThat(result.get(0).itemId()).isEqualTo(10L); + InOrder inOrder = inOrder(defaultItemGranter, userItemRepository); + inOrder.verify(defaultItemGranter).grantMissing(userId); + inOrder.verify(userItemRepository).findAllByUserIdWithItem(userId); } @Test @@ -175,5 +189,8 @@ void questyOfUserWithNoItemsHasNullHighestRarity() { assertThat(result.equippedItems()).isEmpty(); assertThat(result.ownedItemCount()).isZero(); assertThat(result.highestRarity()).isNull(); + InOrder inOrder = inOrder(defaultItemGranter, userItemRepository); + inOrder.verify(defaultItemGranter).grantMissing(1L); + inOrder.verify(userItemRepository).findAllByUserIdWithItem(1L); } } From 503076a2ca8e919b4b912c0476924cadd9872f60 Mon Sep 17 00:00:00 2001 From: Chhun-Lee Date: Mon, 14 Sep 2026 15:10:34 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Test]#62=20=EA=B8=B0=EB=B3=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=A7=80=EA=B8=89=20=EB=A9=B1=EB=93=B1?= =?UTF-8?q?=EC=84=B1=C2=B7=EC=83=81=EC=A0=90=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=C2=B7=EC=8B=9C=EB=8D=94=20=EC=9E=AC=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INSERT ... SELECT의 ON DUPLICATE KEY UPDATE id가 모호하다는 실DB 오류를 user_items.id로 한정해 수정. 구매 통합 테스트는 비활성화된 GOLDEN_CROWN 대신 COZY_CAFE(320P) 기준으로 변경 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HtgegmjNs3gAc6qNCTekNa --- .../item/repository/UserItemRepository.java | 4 +- .../DefaultItemGranterIntegrationTest.java | 108 ++++++++++++++++++ .../ItemPurchaseServiceIntegrationTest.java | 26 ++--- 3 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java diff --git a/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java b/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java index c037174..c584398 100644 --- a/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java +++ b/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java @@ -46,6 +46,8 @@ public interface UserItemRepository extends JpaRepository { // ON DUPLICATE KEY UPDATE id = id로 멱등 — 동시 호출도 예외 없이 no-op. 같은 트랜잭션에서 // UNIQUE 위반을 catch하면 rollback-only가 되므로(CLAUDE.md) 예외 경로 자체를 없앤다. // 가격 0이라 포인트 원장·활동 기록은 남기지 않는다. + // INSERT ... SELECT에서는 ON DUPLICATE KEY UPDATE의 id가 SELECT 쪽 테이블과 모호해지므로 + // 테이블명으로 한정한다(실DB에서 "Column 'id' in field list is ambiguous" 확인). @Modifying @Query(value = """ INSERT INTO user_items (user_id, item_id, is_equipped, purchased_at, created_at, updated_at) @@ -53,7 +55,7 @@ INSERT INTO user_items (user_id, item_id, is_equipped, purchased_at, created_at, FROM shop_items si WHERE si.is_default = 1 AND si.is_active = 1 - ON DUPLICATE KEY UPDATE id = id + ON DUPLICATE KEY UPDATE user_items.id = user_items.id """, nativeQuery = true) int grantDefaultItems(@Param("userId") Long userId); } diff --git a/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java b/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java new file mode 100644 index 0000000..a516d8a --- /dev/null +++ b/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java @@ -0,0 +1,108 @@ +package com.quespot.domain.item.service; + +import com.quespot.domain.item.config.ItemMasterDataSeeder; +import com.quespot.domain.item.dto.res.ShopItemResponseDTO; +import com.quespot.domain.item.dto.res.UserItemResponseDTO; +import com.quespot.domain.item.entity.ShopItem; +import com.quespot.domain.item.enums.ItemCategory; +import com.quespot.domain.item.repository.EquipSlotLimitRepository; +import com.quespot.domain.item.repository.ShopItemRepository; +import com.quespot.domain.item.repository.UserItemRepository; +import com.quespot.domain.reward.repository.PointTransactionRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +// 기본 아이템 지연 지급의 멱등성, 상점 목록의 기본 아이템 제외, 시더 재실행 멱등성을 실DB로 검증한다(#62). +@SpringBootTest +@Testcontainers +class DefaultItemGranterIntegrationTest { + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("quespot_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("app.jwt.secret", () -> "dGVzdC1qd3Qtc2VjcmV0LWtleS1tdXN0LWJlLWF0LWxlYXN0LTMyLWJ5dGVz"); + registry.add("app.mail.verification-code-secret", () -> "integration-test-mail-secret"); + } + + @Autowired private UserItemService userItemService; + @Autowired private ShopItemService shopItemService; + @Autowired private ItemMasterDataSeeder seeder; + @Autowired private ShopItemRepository shopItemRepository; + @Autowired private UserItemRepository userItemRepository; + @Autowired private EquipSlotLimitRepository equipSlotLimitRepository; + @Autowired private PointTransactionRepository pointTransactionRepository; + + @Test + void firstListingGrantsSevenDefaultItemsAndRepeatedListingDoesNotDuplicate() { + Long userId = 9401L; + + List first = userItemService.getMyItems(userId); + List second = userItemService.getMyItems(userId); + + assertThat(first).hasSize(7); + assertThat(second).hasSize(7); + assertThat(first).extracting(UserItemResponseDTO::name).containsExactlyInAnyOrder( + "탐험가 모자", "보라 선글라스", "빨간 스카프", "노란 우비", "탐험 조끼", "여행 가방", "탐험 돋보기"); + assertThat(first).allMatch(item -> !item.isEquipped()); + assertThat(userItemRepository.findAll()).filteredOn(ui -> ui.getUserId().equals(userId)).hasSize(7); + assertThat(pointTransactionRepository.findAll()).filteredOn(t -> t.getUserId().equals(userId)).isEmpty(); + } + + @Test + void equipGrantsDefaultItemsBeforeLookingUpOwnership() { + Long userId = 9402L; + ShopItem hat = shopItemRepository.findByCode("EXPLORER_HAT").orElseThrow(); + + userItemService.equip(userId, hat.getId()); + + assertThat(userItemRepository.findByUserIdAndItem_Id(userId, hat.getId())) + .isPresent() + .get().extracting(ui -> ui.getIsEquipped()).isEqualTo(true); + assertThat(userItemService.getQuesty(userId).ownedItemCount()).isEqualTo(7); + } + + @Test + void shopListsOnlyTheFiveBackgrounds() { + List all = shopItemService.getShopItems(null); + List backgrounds = shopItemService.getShopItems(ItemCategory.BACKGROUND); + List hats = shopItemService.getShopItems(ItemCategory.HAT); + + assertThat(all).hasSize(5).allMatch(item -> item.category() == ItemCategory.BACKGROUND && !item.isDefault()); + assertThat(all).extracting(ShopItemResponseDTO::code).containsExactly( + "COZY_CAFE", "SUNNY_CLASSROOM", "COZY_ROOM", "BEACH_CAMPING", "STARLIGHT_OBSERVATORY"); + assertThat(backgrounds).hasSize(5); + assertThat(hats).isEmpty(); + } + + @Test + void seederIsIdempotentOnRerunAndSeededBackgroundSlotLimit() throws Exception { + long before = shopItemRepository.count(); + + seeder.run(); + + assertThat(shopItemRepository.count()).isEqualTo(before).isEqualTo(12); + assertThat(shopItemRepository.findByIsActiveTrue()).hasSize(12); + assertThat(equipSlotLimitRepository.findById(ItemCategory.BACKGROUND)) + .isPresent() + .get().extracting(limit -> limit.getMaxEquip()).isEqualTo(1); + } +} diff --git a/src/test/java/com/quespot/domain/item/service/ItemPurchaseServiceIntegrationTest.java b/src/test/java/com/quespot/domain/item/service/ItemPurchaseServiceIntegrationTest.java index 778cdc7..5613c0d 100644 --- a/src/test/java/com/quespot/domain/item/service/ItemPurchaseServiceIntegrationTest.java +++ b/src/test/java/com/quespot/domain/item/service/ItemPurchaseServiceIntegrationTest.java @@ -22,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; // 조건부 차감 + UNIQUE(user_id, item_id) + 롤백을 실DB로 검증한다. -// 시더(ItemMasterDataSeeder)가 부팅 시 GOLDEN_CROWN(300P) 등을 넣어둔다. +// 시더(ItemMasterDataSeeder)가 부팅 시 COZY_CAFE(320P) 등 배경 5종을 넣어둔다(#62). @SpringBootTest @Testcontainers class ItemPurchaseServiceIntegrationTest { @@ -50,40 +50,38 @@ static void registerProperties(DynamicPropertyRegistry registry) { @Autowired private PointTransactionRepository pointTransactionRepository; @Autowired private PointService pointService; - private ShopItem goldenCrown() { - return shopItemRepository.findAll().stream() - .filter(i -> "GOLDEN_CROWN".equals(i.getCode())) - .findFirst().orElseThrow(); + private ShopItem cozyCafe() { + return shopItemRepository.findByCode("COZY_CAFE").orElseThrow(); } @Test void purchaseDebitsAndGrantsThenSecondPurchaseIsRejectedWithoutSecondDebit() { Long userId = 9301L; - ShopItem crown = goldenCrown(); + ShopItem cafe = cozyCafe(); pointService.credit(userId, 500, "MISSION_REWARD", "MISSION_ATTEMPT", 9301L, "보상"); - itemPurchaseService.purchase(userId, crown.getId()); + itemPurchaseService.purchase(userId, cafe.getId()); - assertThat(userItemRepository.existsByUserIdAndItem_Id(userId, crown.getId())).isTrue(); - assertThat(userPointRepository.findById(userId).get().getBalance()).isEqualTo(500 - crown.getPrice()); + assertThat(userItemRepository.existsByUserIdAndItem_Id(userId, cafe.getId())).isTrue(); + assertThat(userPointRepository.findById(userId).get().getBalance()).isEqualTo(500 - cafe.getPrice()); - assertThatThrownBy(() -> itemPurchaseService.purchase(userId, crown.getId())) + assertThatThrownBy(() -> itemPurchaseService.purchase(userId, cafe.getId())) .isInstanceOf(ItemException.class) .extracting(e -> ((ItemException) e).getErrorCode()) .isEqualTo(ItemErrorCode.ITEM_ALREADY_OWNED); - assertThat(userPointRepository.findById(userId).get().getBalance()).isEqualTo(500 - crown.getPrice()); + assertThat(userPointRepository.findById(userId).get().getBalance()).isEqualTo(500 - cafe.getPrice()); } @Test void insufficientBalanceRejectsPurchaseWithoutGrantingItem() { Long userId = 9302L; - ShopItem crown = goldenCrown(); + ShopItem cafe = cozyCafe(); pointService.credit(userId, 100, "MISSION_REWARD", "MISSION_ATTEMPT", 9302L, "보상"); - assertThatThrownBy(() -> itemPurchaseService.purchase(userId, crown.getId())) + assertThatThrownBy(() -> itemPurchaseService.purchase(userId, cafe.getId())) .isInstanceOf(RewardException.class); - assertThat(userItemRepository.existsByUserIdAndItem_Id(userId, crown.getId())).isFalse(); + assertThat(userItemRepository.existsByUserIdAndItem_Id(userId, cafe.getId())).isFalse(); assertThat(userPointRepository.findById(userId).get().getBalance()).isEqualTo(100); assertThat(pointTransactionRepository.findAll()) .filteredOn(t -> t.getUserId().equals(userId)) From b1a137ebf87b91518ecbc5937c129990367bd9f4 Mon Sep 17 00:00:00 2001 From: Chhun-Lee Date: Mon, 14 Sep 2026 15:36:16 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Fix]#62=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=A7=80=EA=B8=89=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20NOT=20EXISTS=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=20=EC=95=84=EC=9D=B4=ED=85=9C=C2=B7?= =?UTF-8?q?=EC=8B=9C=EB=8D=94=20=EB=A9=B1=EB=93=B1=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정상 상태(전부 보유)에서 호출마다 ON DUPLICATE KEY가 기존 행에 X-lock을 잡던 문제를 SELECT 단계에서 미보유분만 고르도록 수정. 비활성 아이템이 보유 목록·장착에서 빠지는지, 시더 재실행 시 updated_at이 그대로인지 실DB 테스트 추가 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HtgegmjNs3gAc6qNCTekNa --- .../item/config/ItemMasterDataSeeder.java | 1 + .../quespot/domain/item/entity/ShopItem.java | 4 +- .../item/repository/UserItemRepository.java | 7 ++++ .../DefaultItemGranterIntegrationTest.java | 39 ++++++++++++++++++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java b/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java index b68d0a5..619b1b6 100644 --- a/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java +++ b/src/main/java/com/quespot/domain/item/config/ItemMasterDataSeeder.java @@ -44,6 +44,7 @@ ShopItem toEntity() { } // 기본 보유 7종(가격 0, is_default) + 상점 배경 5종(#62). 프론트 화면과 1:1. + // is_featured(카드 별표)는 아직 쓰는 화면이 없어 정의에 두지 않고 시더가 전부 false로 고정한다. static final List SHOP_ITEMS = List.of( new ShopItemDefinition("EXPLORER_HAT", "탐험가 모자", ItemCategory.HAT, ItemRarity.NORMAL, 0, true, 1), new ShopItemDefinition("PURPLE_SUNGLASSES", "보라 선글라스", ItemCategory.ACCESSORY, ItemRarity.NORMAL, 0, true, 2), diff --git a/src/main/java/com/quespot/domain/item/entity/ShopItem.java b/src/main/java/com/quespot/domain/item/entity/ShopItem.java index 1a4189b..d50949d 100644 --- a/src/main/java/com/quespot/domain/item/entity/ShopItem.java +++ b/src/main/java/com/quespot/domain/item/entity/ShopItem.java @@ -133,7 +133,9 @@ public void updateMaster( this.isActive = true; } - // 정의 목록에서 빠진 아이템. 행은 남긴다(보유 이력 보존). 판매·지급·보유 목록에서 모두 빠진다. + // 정의 목록에서 빠진 아이템. 행은 남긴다(보유 이력 보존). 판매·지급·보유 목록·슬롯 계산에서 모두 빠진다. + // user_items.is_equipped는 건드리지 않으므로, 비활성 아이템을 나중에 정의에 되살리면 장착 상태로 + // 다시 나타나 슬롯 한도를 넘길 수 있다 — 되살릴 땐 해당 아이템의 장착 행을 먼저 해제할 것. public void deactivate() { this.isActive = false; } diff --git a/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java b/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java index c584398..c729257 100644 --- a/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java +++ b/src/main/java/com/quespot/domain/item/repository/UserItemRepository.java @@ -46,6 +46,9 @@ public interface UserItemRepository extends JpaRepository { // ON DUPLICATE KEY UPDATE id = id로 멱등 — 동시 호출도 예외 없이 no-op. 같은 트랜잭션에서 // UNIQUE 위반을 catch하면 rollback-only가 되므로(CLAUDE.md) 예외 경로 자체를 없앤다. // 가격 0이라 포인트 원장·활동 기록은 남기지 않는다. + // NOT EXISTS로 이미 보유한 행은 SELECT 단계에서 걸러낸다 — 없으면 정상 상태(전부 보유)에서도 + // 호출마다 ON DUPLICATE KEY가 기존 7행에 X-lock을 잡아 홈 화면 조회가 같은 사용자의 다른 요청과 + // 직렬화된다. ON DUPLICATE KEY UPDATE는 동시 첫 요청 둘이 NOT EXISTS를 같이 통과하는 경합의 안전망. // INSERT ... SELECT에서는 ON DUPLICATE KEY UPDATE의 id가 SELECT 쪽 테이블과 모호해지므로 // 테이블명으로 한정한다(실DB에서 "Column 'id' in field list is ambiguous" 확인). @Modifying @@ -55,6 +58,10 @@ INSERT INTO user_items (user_id, item_id, is_equipped, purchased_at, created_at, FROM shop_items si WHERE si.is_default = 1 AND si.is_active = 1 + AND NOT EXISTS ( + SELECT 1 FROM user_items ui + WHERE ui.user_id = :userId AND ui.item_id = si.id + ) ON DUPLICATE KEY UPDATE user_items.id = user_items.id """, nativeQuery = true) int grantDefaultItems(@Param("userId") Long userId); diff --git a/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java b/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java index a516d8a..84eefa2 100644 --- a/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java +++ b/src/test/java/com/quespot/domain/item/service/DefaultItemGranterIntegrationTest.java @@ -5,6 +5,8 @@ import com.quespot.domain.item.dto.res.UserItemResponseDTO; import com.quespot.domain.item.entity.ShopItem; import com.quespot.domain.item.enums.ItemCategory; +import com.quespot.domain.item.exception.ItemException; +import com.quespot.domain.item.exception.code.ItemErrorCode; import com.quespot.domain.item.repository.EquipSlotLimitRepository; import com.quespot.domain.item.repository.ShopItemRepository; import com.quespot.domain.item.repository.UserItemRepository; @@ -18,9 +20,13 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; // 기본 아이템 지연 지급의 멱등성, 상점 목록의 기본 아이템 제외, 시더 재실행 멱등성을 실DB로 검증한다(#62). @SpringBootTest @@ -93,13 +99,42 @@ void shopListsOnlyTheFiveBackgrounds() { assertThat(hats).isEmpty(); } + // 비활성 아이템은 보유 목록·퀘스티·장착에서 빠지되 행은 남는다(#62 spec 2.5). 같은 컨테이너를 + // 다른 테스트와 공유하므로 끝나면 시더를 다시 돌려 되살린다(정의에 있는 아이템이라 재활성화됨). + @Test + void inactiveOwnedItemIsHiddenFromListAndCannotBeEquippedButRowIsKept() throws Exception { + Long userId = 9403L; + userItemService.getMyItems(userId); + ShopItem bag = shopItemRepository.findByCode("TRAVEL_BAG").orElseThrow(); + bag.deactivate(); + shopItemRepository.save(bag); + + try { + assertThat(userItemService.getMyItems(userId)).hasSize(6) + .extracting(UserItemResponseDTO::name).doesNotContain("여행 가방"); + assertThat(userItemService.getQuesty(userId).ownedItemCount()).isEqualTo(6); + assertThatThrownBy(() -> userItemService.equip(userId, bag.getId())) + .isInstanceOf(ItemException.class) + .extracting(e -> ((ItemException) e).getErrorCode()) + .isEqualTo(ItemErrorCode.ITEM_NOT_OWNED); + assertThat(userItemRepository.existsByUserIdAndItem_Id(userId, bag.getId())).isTrue(); + } finally { + seeder.run(); + } + assertThat(shopItemRepository.findByCode("TRAVEL_BAG").orElseThrow().getIsActive()).isTrue(); + } + + // 재실행 시 UPDATE가 한 건도 없어야 한다 — updated_at이 그대로인지로 확인한다. @Test void seederIsIdempotentOnRerunAndSeededBackgroundSlotLimit() throws Exception { - long before = shopItemRepository.count(); + Map before = shopItemRepository.findAll().stream() + .collect(Collectors.toMap(ShopItem::getCode, ShopItem::getUpdatedAt)); seeder.run(); - assertThat(shopItemRepository.count()).isEqualTo(before).isEqualTo(12); + Map after = shopItemRepository.findAll().stream() + .collect(Collectors.toMap(ShopItem::getCode, ShopItem::getUpdatedAt)); + assertThat(after).hasSize(12).isEqualTo(before); assertThat(shopItemRepository.findByIsActiveTrue()).hasSize(12); assertThat(equipSlotLimitRepository.findById(ItemCategory.BACKGROUND)) .isPresent()