From 4edb564e050b4b7e36c8a6a0105ab69338785887 Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Tue, 24 Mar 2026 10:33:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:#453=20=EC=9B=90=EC=B2=9C=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=B0=9C=20API=20v2=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JjymV2Controller.java | 47 ++++ .../dto/response/JjymV2ItemResponse.java | 45 ++++ .../dto/response/JjymV2ListResponse.java | 9 + .../CurationRawProductRepository.java | 2 + .../domain/furniture/service/JjymService.java | 5 + .../furniture/service/JjymServiceImpl.java | 202 ++++++++++++++++++ .../facade/JjymOptimisticLockFacade.java | 20 ++ 7 files changed, 330 insertions(+) create mode 100644 src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java create mode 100644 src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ItemResponse.java create mode 100644 src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ListResponse.java diff --git a/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java b/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java new file mode 100644 index 00000000..89a355ce --- /dev/null +++ b/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java @@ -0,0 +1,47 @@ +package or.sopt.houme.domain.furniture.presentation.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymToggleResponse; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; +import or.sopt.houme.domain.furniture.service.JjymService; +import or.sopt.houme.domain.furniture.service.facade.JjymOptimisticLockFacade; +import or.sopt.houme.domain.user.presentation.controller.dto.CustomUserDetails; +import or.sopt.houme.global.api.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v2") +@RequiredArgsConstructor +@Tag(name = "찜 관련 API v2") +public class JjymV2Controller { + + private final JjymService jjymService; + private final JjymOptimisticLockFacade jjymOptimisticLockFacade; + + @Operation(summary = "원천 상품 찜 토글 API v2", description = "curation_raw_product 기준으로 찜을 등록/해제합니다.") + @PostMapping("/curation-raw-products/{rawProductId}/jjym") + public ResponseEntity> toggleRawProductJjym( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long rawProductId + ) { + boolean favorited = jjymOptimisticLockFacade.toggleRawProduct(userDetails.getUser(), rawProductId); + return ResponseEntity.ok(ApiResponse.ok(new JjymToggleResponse(favorited))); + } + + @Operation(summary = "내가 찜한 원천 상품 목록 조회 API v2", description = "찜한 원천 상품의 색상, 브랜드, 가격, 찜 개수를 포함해 반환합니다.") + @GetMapping("/jjyms") + public ResponseEntity> getMyRawProductJjyms( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + JjymV2ListResponse response = jjymService.getMyRawProductJjyms(userDetails.getUser().getId()); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} diff --git a/src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ItemResponse.java b/src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ItemResponse.java new file mode 100644 index 00000000..ab15607e --- /dev/null +++ b/src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ItemResponse.java @@ -0,0 +1,45 @@ +package or.sopt.houme.domain.furniture.presentation.dto.response; + +import java.util.List; + +public record JjymV2ItemResponse( + Long rawProductId, + boolean isJjym, + String productImageUrl, + String productSiteUrl, + List colors, + String brandName, + String productName, + Long listPrice, + Integer discountRate, + Long discountPrice, + Long jjymCount +) { + public static JjymV2ItemResponse of( + Long rawProductId, + boolean isJjym, + String productImageUrl, + String productSiteUrl, + List colors, + String brandName, + String productName, + Long listPrice, + Integer discountRate, + Long discountPrice, + Long jjymCount + ) { + return new JjymV2ItemResponse( + rawProductId, + isJjym, + productImageUrl, + productSiteUrl, + colors, + brandName, + productName, + listPrice, + discountRate, + discountPrice, + jjymCount + ); + } +} diff --git a/src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ListResponse.java b/src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ListResponse.java new file mode 100644 index 00000000..a4b4d982 --- /dev/null +++ b/src/main/java/or/sopt/houme/domain/furniture/presentation/dto/response/JjymV2ListResponse.java @@ -0,0 +1,9 @@ +package or.sopt.houme.domain.furniture.presentation.dto.response; + +import java.util.List; + +public record JjymV2ListResponse(List items) { + public static JjymV2ListResponse of(List items) { + return new JjymV2ListResponse(items); + } +} diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java index 4030dddf..b912ec9e 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java @@ -42,6 +42,8 @@ List findAllBySourceAndCategoryAndProductIdIn( List productIds ); + List findAllByProductIdIn(List productIds); + @Query(""" select distinct rawProduct from CurationRawProduct rawProduct diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/JjymService.java b/src/main/java/or/sopt/houme/domain/furniture/service/JjymService.java index 92b81e4b..71ed81b4 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/JjymService.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/JjymService.java @@ -1,9 +1,14 @@ package or.sopt.houme.domain.furniture.service; import or.sopt.houme.domain.furniture.presentation.dto.response.JjymListResponse; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; public interface JjymService { boolean jjymToggle(Long userId, Long recommendFurnitureId); + boolean rawProductJjymToggle(Long userId, Long rawProductId); + JjymListResponse getMyJjyms(Long userId); + + JjymV2ListResponse getMyRawProductJjyms(Long userId); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java b/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java index 0c4407f0..228b1f72 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java @@ -1,21 +1,35 @@ package or.sopt.houme.domain.furniture.service; import lombok.RequiredArgsConstructor; +import or.sopt.houme.domain.furniture.model.entity.CurationRawProduct; +import or.sopt.houme.domain.furniture.model.entity.CurationRawProductColor; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import or.sopt.houme.domain.furniture.model.entity.Jjym; import or.sopt.houme.domain.furniture.model.entity.RecommendFurniture; import or.sopt.houme.domain.furniture.presentation.dto.response.JjymItemResponse; import or.sopt.houme.domain.furniture.presentation.dto.response.JjymListResponse; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ItemResponse; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; +import or.sopt.houme.domain.furniture.repository.CurationRawProductColorRepository; +import or.sopt.houme.domain.furniture.repository.CurationRawProductRepository; import or.sopt.houme.domain.furniture.repository.JjymRepository; import or.sopt.houme.domain.furniture.repository.RecommendFurnitureRepository; import or.sopt.houme.domain.user.model.entity.User; import or.sopt.houme.domain.user.repository.UserRepository; import or.sopt.houme.global.api.ErrorCode; import or.sopt.houme.global.api.GeneralException; +import or.sopt.houme.global.api.handler.FurnitureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Optional; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -26,6 +40,8 @@ public class JjymServiceImpl implements JjymService { private final JjymRepository jjymRepository; private final UserRepository userRepository; private final RecommendFurnitureRepository recommendFurnitureRepository; + private final CurationRawProductRepository curationRawProductRepository; + private final CurationRawProductColorRepository curationRawProductColorRepository; @Override public boolean jjymToggle(Long userId, Long recommendFurnitureId) { @@ -47,6 +63,35 @@ public boolean jjymToggle(Long userId, Long recommendFurnitureId) { } } + @Override + public boolean rawProductJjymToggle(Long userId, Long rawProductId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorCode.USER_NOT_FOUND)); + + CurationRawProduct rawProduct = curationRawProductRepository.findById(rawProductId) + .orElseThrow(() -> new FurnitureException(ErrorCode.NOT_FOUND_CURATION_RAW_PRODUCT)); + + RecommendFurniture recommendFurniture = recommendFurnitureRepository + .findBySourceAndFurnitureProductId(CurationSource.RAW, rawProduct.getProductId()) + .orElseGet(() -> recommendFurnitureRepository.save(RecommendFurniture.from( + rawProduct.getProductImageUrl(), + rawProduct.getProductSiteUrl(), + rawProduct.getProductName(), + rawProduct.getProductMallName(), + rawProduct.getProductId(), + CurationSource.RAW + ))); + + Optional existing = jjymRepository.findByUserIdAndRecommendFurnitureId(user.getId(), recommendFurniture.getId()); + if (existing.isPresent()) { + jjymRepository.delete(existing.get()); + return false; + } + + jjymRepository.save(Jjym.of(user, recommendFurniture)); + return true; + } + @Transactional(readOnly = true) @Override public JjymListResponse getMyJjyms(Long userId) { @@ -60,4 +105,161 @@ public JjymListResponse getMyJjyms(Long userId) { return JjymListResponse.of(items); } + + @Transactional(readOnly = true) + @Override + public JjymV2ListResponse getMyRawProductJjyms(Long userId) { + List rawProductJjyms = jjymRepository.findAllByUserIdWithFurnitureOrderByCreatedAtDesc(userId).stream() + .filter(jjym -> jjym.getRecommendFurniture().getSource() == CurationSource.RAW) + .toList(); + + if (rawProductJjyms.isEmpty()) { + return JjymV2ListResponse.of(List.of()); + } + + Map rawProductByProductId = buildRawProductByProductId(rawProductJjyms); + Map> colorsByRawProductId = buildColorNamesByRawProductId(rawProductByProductId); + Map jjymCountByRecommendFurnitureId = jjymRepository.countByRecommendFurnitureIds( + rawProductJjyms.stream() + .map(jjym -> jjym.getRecommendFurniture().getId()) + .distinct() + .toList() + ); + + List items = rawProductJjyms.stream() + .map(jjym -> toV2ItemResponse(jjym, rawProductByProductId, colorsByRawProductId, jjymCountByRecommendFurnitureId)) + .toList(); + + return JjymV2ListResponse.of(items); + } + + private Map buildRawProductByProductId(List rawProductJjyms) { + List productIds = rawProductJjyms.stream() + .map(jjym -> jjym.getRecommendFurniture().getFurnitureProductId()) + .filter(productId -> productId != null) + .distinct() + .toList(); + + if (productIds.isEmpty()) { + return Map.of(); + } + + Map rawProductByProductId = new HashMap<>(); + for (CurationRawProduct rawProduct : curationRawProductRepository.findAllByProductIdIn(productIds)) { + rawProductByProductId.merge( + rawProduct.getProductId(), + rawProduct, + this::selectLatestRawProduct + ); + } + return rawProductByProductId; + } + + private Map> buildColorNamesByRawProductId(Map rawProductByProductId) { + if (rawProductByProductId.isEmpty()) { + return Map.of(); + } + + List rawProductIds = rawProductByProductId.values().stream() + .map(CurationRawProduct::getId) + .toList(); + + Map> colorSetByRawProductId = new HashMap<>(); + for (CurationRawProductColor color : curationRawProductColorRepository.findAllByCurationRawProductIdIn(rawProductIds)) { + Long rawProductId = color.getCurationRawProduct().getId(); + if (rawProductId == null) { + continue; + } + + String colorName = resolveColorName(color); + if (colorName == null) { + continue; + } + + colorSetByRawProductId.computeIfAbsent(rawProductId, key -> new LinkedHashSet<>()).add(colorName); + } + + Map> colorsByRawProductId = new HashMap<>(); + for (Map.Entry> entry : colorSetByRawProductId.entrySet()) { + colorsByRawProductId.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + return colorsByRawProductId; + } + + private JjymV2ItemResponse toV2ItemResponse( + Jjym jjym, + Map rawProductByProductId, + Map> colorsByRawProductId, + Map jjymCountByRecommendFurnitureId + ) { + RecommendFurniture recommendFurniture = jjym.getRecommendFurniture(); + CurationRawProduct rawProduct = rawProductByProductId.get(recommendFurniture.getFurnitureProductId()); + + if (rawProduct == null) { + return JjymV2ItemResponse.of( + null, + true, + recommendFurniture.getFurnitureProductImageUrl(), + recommendFurniture.getFurnitureProductSiteUrl(), + List.of(), + null, + recommendFurniture.getFurnitureProductName(), + null, + null, + null, + jjymCountByRecommendFurnitureId.getOrDefault(recommendFurniture.getId(), 0L) + ); + } + + return JjymV2ItemResponse.of( + rawProduct.getId(), + true, + rawProduct.getProductImageUrl(), + rawProduct.getProductSiteUrl(), + colorsByRawProductId.getOrDefault(rawProduct.getId(), List.of()), + rawProduct.getBrand(), + rawProduct.getProductName(), + rawProduct.getListPrice(), + rawProduct.getDiscountRate(), + rawProduct.getDiscountPrice(), + jjymCountByRecommendFurnitureId.getOrDefault(recommendFurniture.getId(), 0L) + ); + } + + private CurationRawProduct selectLatestRawProduct(CurationRawProduct current, CurationRawProduct candidate) { + LocalDateTime currentFetchedAt = current.getFetchedAt(); + LocalDateTime candidateFetchedAt = candidate.getFetchedAt(); + + if (currentFetchedAt == null && candidateFetchedAt == null) { + return current.getId() != null && candidate.getId() != null && candidate.getId() > current.getId() + ? candidate + : current; + } + if (currentFetchedAt == null) { + return candidate; + } + if (candidateFetchedAt == null) { + return current; + } + if (candidateFetchedAt.isAfter(currentFetchedAt)) { + return candidate; + } + if (candidateFetchedAt.isEqual(currentFetchedAt) + && current.getId() != null + && candidate.getId() != null + && candidate.getId() > current.getId()) { + return candidate; + } + return current; + } + + private String resolveColorName(CurationRawProductColor color) { + if (color.getClientColorName() != null && !color.getClientColorName().isBlank()) { + return color.getClientColorName(); + } + if (color.getRawColorName() != null && !color.getRawColorName().isBlank()) { + return color.getRawColorName(); + } + return null; + } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/facade/JjymOptimisticLockFacade.java b/src/main/java/or/sopt/houme/domain/furniture/service/facade/JjymOptimisticLockFacade.java index 25ccf335..52e46ab4 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/facade/JjymOptimisticLockFacade.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/facade/JjymOptimisticLockFacade.java @@ -36,4 +36,24 @@ public boolean toggle(User user, Long recommendFurnitureId) { // 마지막까지 실패 시 런타임 예외 전파 (글로벌 핸들러에서 처리) throw new DataIntegrityViolationException("찜 시도가 정해진 횟수를 초과하였습니다"); } + + public boolean toggleRawProduct(User user, Long rawProductId) { + int retryCount = 0; + while (retryCount < MAX_RETRIES) { + try { + return jjymService.rawProductJjymToggle(user.getId(), rawProductId); + } catch (OptimisticLockException | DataIntegrityViolationException e) { + long backoffTime = (long) Math.pow(2, retryCount) * RETRY_DELAY_MS; + try { + Thread.sleep(backoffTime); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new DataIntegrityViolationException("찜 토글 처리 중 인터럽트가 발생했습니다", ie); + } + retryCount++; + } + } + + throw new DataIntegrityViolationException("찜 시도가 정해진 횟수를 초과하였습니다"); + } } From b283f7ca3844f23670b09348ea5f12d6389fa04f Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Tue, 24 Mar 2026 10:33:52 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:#453=20=EC=9B=90=EC=B2=9C=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=B0=9C=20API=20v2=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JjymV2ControllerTest.java | 125 +++++++++++++++ .../service/JjymServiceImplTest.java | 145 ++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java create mode 100644 src/test/java/or/sopt/houme/domain/furniture/service/JjymServiceImplTest.java diff --git a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java new file mode 100644 index 00000000..5bca2f0b --- /dev/null +++ b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java @@ -0,0 +1,125 @@ +package or.sopt.houme.domain.furniture.presentation.controller; + +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ItemResponse; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; +import or.sopt.houme.domain.furniture.service.JjymService; +import or.sopt.houme.domain.furniture.service.facade.JjymOptimisticLockFacade; +import or.sopt.houme.domain.user.model.entity.Role; +import or.sopt.houme.domain.user.model.entity.User; +import or.sopt.houme.domain.user.presentation.controller.dto.CustomUserDetails; +import or.sopt.houme.domain.user.presentation.controller.dto.CustomUserDetailsService; +import or.sopt.houme.domain.user.repository.BlacklistTokenRepository; +import or.sopt.houme.global.config.JWTConfig; +import or.sopt.houme.global.jwt.JWTUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +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; + +@WebMvcTest( + controllers = JjymV2Controller.class, + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration.class, + org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration.class + } +) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class JjymV2ControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private JjymService jjymService; + + @MockBean + private JjymOptimisticLockFacade jjymOptimisticLockFacade; + + @MockBean + private JWTConfig jwtConfig; + + @MockBean + private JWTUtil jwtUtil; + + @MockBean + private BlacklistTokenRepository blacklistTokenRepository; + + @MockBean + private CustomUserDetailsService customUserDetailsService; + + @Test + @DisplayName("POST /api/v2/curation-raw-products/{rawProductId}/jjym 요청 시 찜 토글 결과를 반환한다") + void toggleRawProductJjym_success() throws Exception { + given(jjymOptimisticLockFacade.toggleRawProduct(any(), anyLong())).willReturn(true); + + mockMvc.perform(post("/api/v2/curation-raw-products/10/jjym") + .with(authentication(authentication()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.favorited").value(true)); + } + + @Test + @DisplayName("GET /api/v2/jjyms 요청 시 원천 상품 찜 목록을 반환한다") + void getMyRawProductJjyms_success() throws Exception { + given(jjymService.getMyRawProductJjyms(1L)).willReturn( + JjymV2ListResponse.of(List.of( + JjymV2ItemResponse.of( + 10L, + true, + "https://image", + "https://site", + List.of("화이트"), + "브랜드A", + "소파", + 100000L, + 20, + 80000L, + 5L + ) + )) + ); + + mockMvc.perform(get("/api/v2/jjyms") + .with(authentication(authentication()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.items[0].rawProductId").value(10)) + .andExpect(jsonPath("$.data.items[0].isJjym").value(true)) + .andExpect(jsonPath("$.data.items[0].brandName").value("브랜드A")) + .andExpect(jsonPath("$.data.items[0].jjymCount").value(5)); + } + + private UsernamePasswordAuthenticationToken authentication() { + User user = User.builder() + .id(1L) + .email("test@example.com") + .role(Role.ROLE_USER) + .build(); + + return new UsernamePasswordAuthenticationToken( + new CustomUserDetails(user), + null, + List.of(() -> "ROLE_USER") + ); + } +} diff --git a/src/test/java/or/sopt/houme/domain/furniture/service/JjymServiceImplTest.java b/src/test/java/or/sopt/houme/domain/furniture/service/JjymServiceImplTest.java new file mode 100644 index 00000000..06c390b8 --- /dev/null +++ b/src/test/java/or/sopt/houme/domain/furniture/service/JjymServiceImplTest.java @@ -0,0 +1,145 @@ +package or.sopt.houme.domain.furniture.service; + +import or.sopt.houme.domain.furniture.model.entity.CurationRawProduct; +import or.sopt.houme.domain.furniture.model.entity.CurationRawProductColor; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; +import or.sopt.houme.domain.furniture.model.entity.Jjym; +import or.sopt.houme.domain.furniture.model.entity.RecommendFurniture; +import or.sopt.houme.domain.furniture.model.entity.SoozipCategory; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; +import or.sopt.houme.domain.furniture.repository.CurationRawProductColorRepository; +import or.sopt.houme.domain.furniture.repository.CurationRawProductRepository; +import or.sopt.houme.domain.furniture.repository.JjymRepository; +import or.sopt.houme.domain.furniture.repository.RecommendFurnitureRepository; +import or.sopt.houme.domain.user.model.entity.User; +import or.sopt.houme.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +class JjymServiceImplTest { + + private final JjymRepository jjymRepository = mock(JjymRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + private final RecommendFurnitureRepository recommendFurnitureRepository = mock(RecommendFurnitureRepository.class); + private final CurationRawProductRepository curationRawProductRepository = mock(CurationRawProductRepository.class); + private final CurationRawProductColorRepository curationRawProductColorRepository = mock(CurationRawProductColorRepository.class); + + private final JjymServiceImpl jjymService = new JjymServiceImpl( + jjymRepository, + userRepository, + recommendFurnitureRepository, + curationRawProductRepository, + curationRawProductColorRepository + ); + + @Test + @DisplayName("raw product 기준 찜 토글 시 recommend furniture가 없으면 생성 후 찜 저장한다") + void rawProductJjymToggle_createsRecommendFurnitureWhenMissing() { + User user = User.builder().id(1L).build(); + CurationRawProduct rawProduct = CurationRawProduct.builder() + .id(10L) + .source("soozip") + .category(SoozipCategory.FURNITURE) + .productId(1000L) + .productImageUrl("https://image") + .productSiteUrl("https://site") + .productName("소파") + .productMallName("수집몰") + .fetchedAt(LocalDateTime.now()) + .build(); + RecommendFurniture recommendFurniture = RecommendFurniture.builder() + .id(20L) + .furnitureProductId(1000L) + .source(CurationSource.RAW) + .build(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(curationRawProductRepository.findById(10L)).willReturn(Optional.of(rawProduct)); + given(recommendFurnitureRepository.findBySourceAndFurnitureProductId(CurationSource.RAW, 1000L)) + .willReturn(Optional.empty()); + given(recommendFurnitureRepository.save(any(RecommendFurniture.class))).willReturn(recommendFurniture); + given(jjymRepository.findByUserIdAndRecommendFurnitureId(1L, 20L)).willReturn(Optional.empty()); + + boolean result = jjymService.rawProductJjymToggle(1L, 10L); + + assertThat(result).isTrue(); + then(recommendFurnitureRepository).should().save(any(RecommendFurniture.class)); + then(jjymRepository).should().save(any(Jjym.class)); + } + + @Test + @DisplayName("raw product 기반 찜 목록 조회 시 색상, 가격, 찜 개수를 포함해 반환한다") + void getMyRawProductJjyms_returnsRawProductMetadata() { + User user = User.builder().id(1L).build(); + RecommendFurniture recommendFurniture = RecommendFurniture.builder() + .id(20L) + .furnitureProductId(1000L) + .source(CurationSource.RAW) + .furnitureProductImageUrl("https://recommend-image") + .furnitureProductSiteUrl("https://recommend-site") + .furnitureProductName("추천 소파") + .build(); + Jjym jjym = Jjym.builder() + .id(30L) + .user(user) + .recommendFurniture(recommendFurniture) + .build(); + CurationRawProduct rawProduct = CurationRawProduct.builder() + .id(40L) + .source("soozip") + .category(SoozipCategory.FURNITURE) + .productId(1000L) + .productImageUrl("https://raw-image") + .productSiteUrl("https://raw-site") + .productName("패브릭 소파") + .brand("브랜드A") + .listPrice(100000L) + .discountRate(20) + .discountPrice(80000L) + .productMallName("수집몰") + .fetchedAt(LocalDateTime.now()) + .build(); + CurationRawProductColor firstColor = CurationRawProductColor.builder() + .curationRawProduct(rawProduct) + .rawColorName("오프화이트") + .clientColorName("화이트") + .build(); + CurationRawProductColor secondColor = CurationRawProductColor.builder() + .curationRawProduct(rawProduct) + .rawColorName("우드") + .clientColorName(null) + .build(); + + given(jjymRepository.findAllByUserIdWithFurnitureOrderByCreatedAtDesc(1L)).willReturn(List.of(jjym)); + given(curationRawProductRepository.findAllByProductIdIn(List.of(1000L))).willReturn(List.of(rawProduct)); + given(curationRawProductColorRepository.findAllByCurationRawProductIdIn(List.of(40L))) + .willReturn(List.of(firstColor, secondColor)); + given(jjymRepository.countByRecommendFurnitureIds(List.of(20L))).willReturn(Map.of(20L, 5L)); + + JjymV2ListResponse response = jjymService.getMyRawProductJjyms(1L); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).rawProductId()).isEqualTo(40L); + assertThat(response.items().get(0).productImageUrl()).isEqualTo("https://raw-image"); + assertThat(response.items().get(0).productSiteUrl()).isEqualTo("https://raw-site"); + assertThat(response.items().get(0).colors()).containsExactly("화이트", "우드"); + assertThat(response.items().get(0).brandName()).isEqualTo("브랜드A"); + assertThat(response.items().get(0).productName()).isEqualTo("패브릭 소파"); + assertThat(response.items().get(0).listPrice()).isEqualTo(100000L); + assertThat(response.items().get(0).discountRate()).isEqualTo(20); + assertThat(response.items().get(0).discountPrice()).isEqualTo(80000L); + assertThat(response.items().get(0).jjymCount()).isEqualTo(5L); + assertThat(response.items().get(0).isJjym()).isTrue(); + } +} From f0c1e809af28ed3023c0098d7e05646825ec8011 Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Tue, 24 Mar 2026 12:46:44 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:#453=20=EC=B0=9C=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B3=91=ED=95=A9=EA=B3=BC=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=83=80=EC=9E=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JjymController.java | 25 ++++++++-- .../controller/JjymV2Controller.java | 47 ------------------- .../furniture/service/JjymServiceImpl.java | 5 +- .../controller/JjymV2ControllerTest.java | 2 +- 4 files changed, 26 insertions(+), 53 deletions(-) delete mode 100644 src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java diff --git a/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymController.java b/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymController.java index 221f3eae..0ba7660d 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymController.java +++ b/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymController.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import or.sopt.houme.domain.furniture.presentation.dto.response.JjymListResponse; import or.sopt.houme.domain.furniture.presentation.dto.response.JjymToggleResponse; +import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; import or.sopt.houme.domain.furniture.service.facade.JjymOptimisticLockFacade; import or.sopt.houme.domain.furniture.service.JjymService; import or.sopt.houme.domain.user.presentation.controller.dto.CustomUserDetails; @@ -14,7 +15,6 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/v1") @RequiredArgsConstructor @Tag(name = "찜 관련 API") public class JjymController { @@ -25,7 +25,7 @@ public class JjymController { @Operation(summary = "추천 가구 찜 토글 API", description = "이미 찜이면 해제, 아니면 찜으로 저장합니다") - @PostMapping("/recommend-furnitures/{recommendFurnitureId}/jjym") + @PostMapping("/api/v1/recommend-furnitures/{recommendFurnitureId}/jjym") public ResponseEntity> toggleJjym( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long recommendFurnitureId @@ -37,11 +37,30 @@ public ResponseEntity> toggleJjym( @Operation(summary = "내가 찜한 가구 목록 조회 API", description = "찜한 가구의 이미지, 이름, 가구 식별자를 반환합니다.") - @GetMapping("/jjyms") + @GetMapping("/api/v1/jjyms") public ResponseEntity> getMyJjyms( @AuthenticationPrincipal CustomUserDetails userDetails ) { JjymListResponse response = jjymService.getMyJjyms(userDetails.getUser().getId()); return ResponseEntity.ok(ApiResponse.ok(response)); } + + @Operation(summary = "원천 상품 찜 토글 API v2", description = "curation_raw_product 기준으로 찜을 등록/해제합니다.") + @PostMapping("/api/v2/curation-raw-products/{rawProductId}/jjym") + public ResponseEntity> toggleRawProductJjym( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long rawProductId + ) { + boolean favorited = jjymOptimisticLockFacade.toggleRawProduct(userDetails.getUser(), rawProductId); + return ResponseEntity.ok(ApiResponse.ok(new JjymToggleResponse(favorited))); + } + + @Operation(summary = "내가 찜한 원천 상품 목록 조회 API v2", description = "찜한 원천 상품의 색상, 브랜드, 가격, 찜 개수를 포함해 반환합니다.") + @GetMapping("/api/v2/jjyms") + public ResponseEntity> getMyRawProductJjyms( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + JjymV2ListResponse response = jjymService.getMyRawProductJjyms(userDetails.getUser().getId()); + return ResponseEntity.ok(ApiResponse.ok(response)); + } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java b/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java deleted file mode 100644 index 89a355ce..00000000 --- a/src/main/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2Controller.java +++ /dev/null @@ -1,47 +0,0 @@ -package or.sopt.houme.domain.furniture.presentation.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import or.sopt.houme.domain.furniture.presentation.dto.response.JjymToggleResponse; -import or.sopt.houme.domain.furniture.presentation.dto.response.JjymV2ListResponse; -import or.sopt.houme.domain.furniture.service.JjymService; -import or.sopt.houme.domain.furniture.service.facade.JjymOptimisticLockFacade; -import or.sopt.houme.domain.user.presentation.controller.dto.CustomUserDetails; -import or.sopt.houme.global.api.ApiResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v2") -@RequiredArgsConstructor -@Tag(name = "찜 관련 API v2") -public class JjymV2Controller { - - private final JjymService jjymService; - private final JjymOptimisticLockFacade jjymOptimisticLockFacade; - - @Operation(summary = "원천 상품 찜 토글 API v2", description = "curation_raw_product 기준으로 찜을 등록/해제합니다.") - @PostMapping("/curation-raw-products/{rawProductId}/jjym") - public ResponseEntity> toggleRawProductJjym( - @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable Long rawProductId - ) { - boolean favorited = jjymOptimisticLockFacade.toggleRawProduct(userDetails.getUser(), rawProductId); - return ResponseEntity.ok(ApiResponse.ok(new JjymToggleResponse(favorited))); - } - - @Operation(summary = "내가 찜한 원천 상품 목록 조회 API v2", description = "찜한 원천 상품의 색상, 브랜드, 가격, 찜 개수를 포함해 반환합니다.") - @GetMapping("/jjyms") - public ResponseEntity> getMyRawProductJjyms( - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - JjymV2ListResponse response = jjymService.getMyRawProductJjyms(userDetails.getUser().getId()); - return ResponseEntity.ok(ApiResponse.ok(response)); - } -} diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java b/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java index 228b1f72..3d1f3692 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/JjymServiceImpl.java @@ -19,6 +19,7 @@ import or.sopt.houme.global.api.ErrorCode; import or.sopt.houme.global.api.GeneralException; import or.sopt.houme.global.api.handler.FurnitureException; +import or.sopt.houme.global.api.handler.UserException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,7 +47,7 @@ public class JjymServiceImpl implements JjymService { @Override public boolean jjymToggle(Long userId, Long recommendFurnitureId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new UserException(ErrorCode.USER_NOT_FOUND)); RecommendFurniture furniture = recommendFurnitureRepository.findById(recommendFurnitureId) .orElseThrow(() -> new GeneralException(ErrorCode.NOT_FOUND_FURNITURE)); @@ -66,7 +67,7 @@ public boolean jjymToggle(Long userId, Long recommendFurnitureId) { @Override public boolean rawProductJjymToggle(Long userId, Long rawProductId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new UserException(ErrorCode.USER_NOT_FOUND)); CurationRawProduct rawProduct = curationRawProductRepository.findById(rawProductId) .orElseThrow(() -> new FurnitureException(ErrorCode.NOT_FOUND_CURATION_RAW_PRODUCT)); diff --git a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java index 5bca2f0b..9f4a0a11 100644 --- a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java +++ b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java @@ -34,7 +34,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest( - controllers = JjymV2Controller.class, + controllers = JjymController.class, excludeAutoConfiguration = { SecurityAutoConfiguration.class, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration.class, From e2f6042469dff8f9f8095eeaa6179672fdc1a188 Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Tue, 24 Mar 2026 13:05:32 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:#453=20=EC=B0=9C=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=ED=97=AC=ED=8D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/controller/JjymV2ControllerTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java index 9f4a0a11..256eab76 100644 --- a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java +++ b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java @@ -72,7 +72,7 @@ void toggleRawProductJjym_success() throws Exception { given(jjymOptimisticLockFacade.toggleRawProduct(any(), anyLong())).willReturn(true); mockMvc.perform(post("/api/v2/curation-raw-products/10/jjym") - .with(authentication(authentication()))) + .with(authentication(authToken()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.favorited").value(true)); @@ -100,7 +100,7 @@ void getMyRawProductJjyms_success() throws Exception { ); mockMvc.perform(get("/api/v2/jjyms") - .with(authentication(authentication()))) + .with(authentication(authToken()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.items[0].rawProductId").value(10)) @@ -109,7 +109,7 @@ void getMyRawProductJjyms_success() throws Exception { .andExpect(jsonPath("$.data.items[0].jjymCount").value(5)); } - private UsernamePasswordAuthenticationToken authentication() { + private UsernamePasswordAuthenticationToken authToken() { User user = User.builder() .id(1L) .email("test@example.com") From 2cd4d4751bdb7f208ea01b5cdc6bddfce1c36121 Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Tue, 24 Mar 2026 13:40:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:#453=20=EC=B0=9C=20v2=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JjymV2ControllerTest.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java index 256eab76..848c87b7 100644 --- a/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java +++ b/src/test/java/or/sopt/houme/domain/furniture/presentation/controller/JjymV2ControllerTest.java @@ -21,9 +21,11 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.http.ResponseEntity; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; @@ -80,7 +82,7 @@ void toggleRawProductJjym_success() throws Exception { @Test @DisplayName("GET /api/v2/jjyms 요청 시 원천 상품 찜 목록을 반환한다") - void getMyRawProductJjyms_success() throws Exception { + void getMyRawProductJjyms_success() { given(jjymService.getMyRawProductJjyms(1L)).willReturn( JjymV2ListResponse.of(List.of( JjymV2ItemResponse.of( @@ -99,14 +101,20 @@ void getMyRawProductJjyms_success() throws Exception { )) ); - mockMvc.perform(get("/api/v2/jjyms") - .with(authentication(authToken()))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.items[0].rawProductId").value(10)) - .andExpect(jsonPath("$.data.items[0].isJjym").value(true)) - .andExpect(jsonPath("$.data.items[0].brandName").value("브랜드A")) - .andExpect(jsonPath("$.data.items[0].jjymCount").value(5)); + JjymController controller = new JjymController(jjymService, jjymOptimisticLockFacade); + + ResponseEntity> response = + controller.getMyRawProductJjyms((CustomUserDetails) authToken().getPrincipal()); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(200); + assertThat(response.getBody().data()).isNotNull(); + assertThat(response.getBody().data().items()).hasSize(1); + assertThat(response.getBody().data().items().getFirst().rawProductId()).isEqualTo(10L); + assertThat(response.getBody().data().items().getFirst().isJjym()).isTrue(); + assertThat(response.getBody().data().items().getFirst().brandName()).isEqualTo("브랜드A"); + assertThat(response.getBody().data().items().getFirst().jjymCount()).isEqualTo(5L); } private UsernamePasswordAuthenticationToken authToken() {