Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/main/java/apptive/fin/search/controller/SearchController.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
package apptive.fin.search.controller;

import apptive.fin.search.dto.DynamicFormResponseDto;
import apptive.fin.search.dto.ProductNameSearchDto;
import apptive.fin.search.dto.ProductSearchResultDto;
import apptive.fin.search.dto.SearchRequestDto;
import apptive.fin.search.service.DynamicFormService;
import apptive.fin.search.service.SearchService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
Expand All @@ -31,4 +31,11 @@ public ResponseEntity<ProductSearchResultDto> search(@Valid @RequestBody SearchR
return ResponseEntity.ok(searchService.search(searchRequestDto));
}

@GetMapping("/products")
public ResponseEntity<List<ProductNameSearchDto>> searchByName(
@RequestParam String searchInput
){
return ResponseEntity.ok(searchService.searchByName(searchInput));
}

}
14 changes: 14 additions & 0 deletions src/main/java/apptive/fin/search/dto/ProductNameSearchDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package apptive.fin.search.dto;

import lombok.Builder;

@Builder
public record ProductNameSearchDto(
Long productId,
String productName,
String source,
String providerName,
Double baseRate,
Double maxRate
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ List<Product> findEligibleProducts(
""")
List<Product> findByKeywords(@Param("keywords") List<KeywordValueEnum> keywords);

//
// 상품명 검색
@Query("""
SELECT DISTINCT p FROM Product p
LEFT JOIN FETCH p.properties pp
WHERE LOWER(p.productName) LIKE LOWER(CONCAT('%',:searchInput,'%'))
AND pp.isJoinable = TRUE
""")
List<Product> findByProductNameContaining(@Param("searchInput") String searchInput);

}
32 changes: 27 additions & 5 deletions src/main/java/apptive/fin/search/service/SearchService.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
package apptive.fin.search.service;

import apptive.fin.search.KeywordValueEnum;
import apptive.fin.search.dto.ProductMatchDto;
import apptive.fin.search.dto.ProductRateDto;
import apptive.fin.search.dto.ProductSearchResultDto;
import apptive.fin.search.dto.ResolvedKeywords;
import apptive.fin.search.dto.SearchRequestDto;
import apptive.fin.search.dto.*;
import apptive.fin.search.entity.Product;
import apptive.fin.search.entity.ProductKeyword;
import apptive.fin.search.entity.ProductProperty;
import apptive.fin.search.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -27,6 +24,7 @@ public class SearchService {
private final MatchScoreService matchScoreService;
private final RateCalculatorService rateCalculatorService;
private final ResolveKeywordService resolveKeywordService;
private final ProductRepository productRepository;

public ProductSearchResultDto search(SearchRequestDto request) {
ResolvedKeywords resolvedKeywords = resolveKeywordService.resolveKeywords(request.options());
Expand Down Expand Up @@ -75,6 +73,30 @@ public ProductSearchResultDto search(SearchRequestDto request) {
.build();
}

public List<ProductNameSearchDto> searchByName(String searchInput){
return productRepository.findByProductNameContaining(searchInput)
.stream()
.map(p -> {
ProductProperty bestProperty = p.getProperties().stream()
.max(Comparator.comparingDouble(pp ->
pp.getMaxRate() != null ? pp.getMaxRate().doubleValue(): 0.0 ))
.orElse(null);

return ProductNameSearchDto.builder()
.productId(p.getId())
.productName(p.getProductName())
.source(p.getSource().getCode())
.providerName(bestProperty != null && bestProperty.getProvider() != null
? bestProperty.getProvider().getName() : null)
.baseRate(bestProperty != null && bestProperty.getBaseRate() != null
? bestProperty.getBaseRate().doubleValue() : null)
.maxRate(bestProperty != null && bestProperty.getMaxRate() != null
? bestProperty.getMaxRate().doubleValue() : null)
.build();
})
.toList();
}

private boolean hasMatchingRegion(Product product, List<KeywordValueEnum> selectedRegions) {
List<KeywordValueEnum> productRegions = product.getProperties().stream()
.flatMap(property -> property.getKeywords().stream())
Expand Down
240 changes: 240 additions & 0 deletions src/test/java/apptive/fin/search/SearchServiceByNameTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package apptive.fin.search;

import apptive.fin.search.dto.ProductNameSearchDto;
import apptive.fin.search.entity.Product;
import apptive.fin.search.entity.ProductProperty;
import apptive.fin.search.entity.ProductSource;
import apptive.fin.search.entity.Provider;
import apptive.fin.search.repository.ProductRepository;
import apptive.fin.search.service.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
public class SearchServiceByNameTest {
@InjectMocks
private SearchService searchService;

@Mock
private ProductRepository productRepository;

@Mock
private EligibilityFilterService eligibilityFilterService;

@Mock
private MatchScoreService matchScoreService;

@Mock
private RateCalculatorService rateCalculatorService;

@Mock
private ResolveKeywordService resolveKeywordService;

// ────────────────────────────────────────────────
// 헬퍼: ReflectionTestUtils로 엔티티 생성
// ────────────────────────────────────────────────

private ProductSource createSource(String code) {
ProductSource source = new ProductSource();
ReflectionTestUtils.setField(source, "code", code);
return source;
}

private Provider createProvider(String name, ProductSource source) {
Provider provider = new Provider();
ReflectionTestUtils.setField(provider, "name", name);
ReflectionTestUtils.setField(provider, "source", source);
return provider;
}

private ProductProperty createProperty(Provider provider, double baseRate, double maxRate) {
ProductProperty property = new ProductProperty();
ReflectionTestUtils.setField(property, "provider", provider);
ReflectionTestUtils.setField(property, "baseRate", BigDecimal.valueOf(baseRate));
ReflectionTestUtils.setField(property, "maxRate", BigDecimal.valueOf(maxRate));
ReflectionTestUtils.setField(property, "keywords", Collections.emptyList());
return property;
}

private Product createProduct(Long id, String productName, String sourceCode,
String providerName, double baseRate, double maxRate) {
ProductSource source = createSource(sourceCode);
Provider provider = createProvider(providerName, source);
ProductProperty property = createProperty(provider, baseRate, maxRate);

Product product = new Product();
ReflectionTestUtils.setField(product, "id", id);
ReflectionTestUtils.setField(product, "productName", productName);
ReflectionTestUtils.setField(product, "source", source);
ReflectionTestUtils.setField(product, "type", ProductType.SAVING);
ReflectionTestUtils.setField(product, "properties", List.of(property));
return product;
}

// ────────────────────────────────────────────────
// 정상 케이스
// ────────────────────────────────────────────────

@Test
@DisplayName("상품명 검색 - '청년' 키워드로 3개 반환")
void searchByName_withKeyword_returnsMatchingProducts() {
// given
List<Product> mockProducts = List.of(
createProduct(1L, "청년내일채움공제", "ONTONG", "금융위원회", 10.0, 10.0),
createProduct(2L, "청년우대형 청약통장", "ONTONG", "금융위원회", 4.5, 6.0),
createProduct(3L, "청년우대적금", "FSS", "국민은행", 3.8, 4.5)
);
given(productRepository.findByProductNameContaining("청년")).willReturn(mockProducts);

// when
List<ProductNameSearchDto> result = searchService.searchByName("청년");

// then
assertThat(result).hasSize(3);
assertThat(result)
.extracting(ProductNameSearchDto::productName)
.containsExactlyInAnyOrder("청년내일채움공제", "청년우대형 청약통장", "청년우대적금");

verify(productRepository).findByProductNameContaining("청년");
}

@Test
@DisplayName("상품명 검색 - maxRate가 가장 높은 property가 bestProperty로 선택됨")
void searchByName_selectsBestPropertyByMaxRate() {
// given
ProductSource source = createSource("FSS");
Provider provider = createProvider("국민은행", source);

ProductProperty lowRate = createProperty(provider, 3.5, 4.2); // 낮은 maxRate
ProductProperty highRate = createProperty(provider, 3.8, 4.5); // 높은 maxRate → bestProperty

Product product = new Product();
ReflectionTestUtils.setField(product, "id", 1L);
ReflectionTestUtils.setField(product, "productName", "청년우대적금");
ReflectionTestUtils.setField(product, "source", source);
ReflectionTestUtils.setField(product, "type", ProductType.SAVING);
ReflectionTestUtils.setField(product, "properties", List.of(lowRate, highRate));

given(productRepository.findByProductNameContaining("청년우대적금"))
.willReturn(List.of(product));

// when
List<ProductNameSearchDto> result = searchService.searchByName("청년우대적금");

// then
assertThat(result).hasSize(1);
assertThat(result.get(0).maxRate()).isEqualTo(4.5);
assertThat(result.get(0).baseRate()).isEqualTo(3.8);
}

@Test
@DisplayName("상품명 검색 - DTO 필드 매핑이 올바르게 됨")
void searchByName_mapsFieldsCorrectly() {
// given
Product product = createProduct(10L, "청년우대적금", "FSS", "국민은행", 3.8, 4.5);
given(productRepository.findByProductNameContaining("청년우대적금"))
.willReturn(List.of(product));

// when
List<ProductNameSearchDto> result = searchService.searchByName("청년우대적금");

// then
ProductNameSearchDto dto = result.get(0);
assertThat(dto.productId()).isEqualTo(10L);
assertThat(dto.productName()).isEqualTo("청년우대적금");
assertThat(dto.source()).isEqualTo("FSS");
assertThat(dto.providerName()).isEqualTo("국민은행");
assertThat(dto.baseRate()).isEqualTo(3.8);
assertThat(dto.maxRate()).isEqualTo(4.5);
}

// ────────────────────────────────────────────────
// 엣지 케이스
// ────────────────────────────────────────────────

@Test
@DisplayName("상품명 검색 - 결과 없을 때 빈 리스트 반환")
void searchByName_noResult_returnsEmptyList() {
// given
given(productRepository.findByProductNameContaining("없는상품"))
.willReturn(Collections.emptyList());

// when
List<ProductNameSearchDto> result = searchService.searchByName("없는상품");

// then
assertThat(result).isEmpty();
}

@Test
@DisplayName("상품명 검색 - properties가 없을 때 null 안전하게 처리")
void searchByName_noProperties_returnsNullSafeDto() {
// given
ProductSource source = createSource("FSS");

Product product = new Product();
ReflectionTestUtils.setField(product, "id", 1L);
ReflectionTestUtils.setField(product, "productName", "청년우대적금");
ReflectionTestUtils.setField(product, "source", source);
ReflectionTestUtils.setField(product, "type", ProductType.SAVING);
ReflectionTestUtils.setField(product, "properties", Collections.emptyList()); // 빈 properties

given(productRepository.findByProductNameContaining("청년우대적금"))
.willReturn(List.of(product));

// when
List<ProductNameSearchDto> result = searchService.searchByName("청년우대적금");

// then
assertThat(result).hasSize(1);
ProductNameSearchDto dto = result.get(0);
assertThat(dto.providerName()).isNull();
assertThat(dto.baseRate()).isNull();
assertThat(dto.maxRate()).isNull();
}

@Test
@DisplayName("상품명 검색 - maxRate가 null인 property 처리")
void searchByName_nullMaxRate_treatedAsZero() {
// given
ProductSource source = createSource("FSS");
Provider provider = createProvider("국민은행", source);

ProductProperty nullRateProperty = new ProductProperty();
ReflectionTestUtils.setField(nullRateProperty, "provider", provider);
ReflectionTestUtils.setField(nullRateProperty, "baseRate", null);
ReflectionTestUtils.setField(nullRateProperty, "maxRate", null); // null → 0.0으로 처리
ReflectionTestUtils.setField(nullRateProperty, "keywords", Collections.emptyList());

Product product = new Product();
ReflectionTestUtils.setField(product, "id", 1L);
ReflectionTestUtils.setField(product, "productName", "청년우대적금");
ReflectionTestUtils.setField(product, "source", source);
ReflectionTestUtils.setField(product, "type", ProductType.SAVING);
ReflectionTestUtils.setField(product, "properties", List.of(nullRateProperty));

given(productRepository.findByProductNameContaining("청년우대적금"))
.willReturn(List.of(product));

// when & then (NPE 없이 정상 동작 확인)
List<ProductNameSearchDto> result = searchService.searchByName("청년우대적금");

assertThat(result).hasSize(1);
assertThat(result.get(0).baseRate()).isNull();
assertThat(result.get(0).maxRate()).isNull();
}

}