Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ dependencies {
// Spring Batch Test
testImplementation("org.springframework.batch:spring-batch-test")

// Mockito
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")

// Netty Resolver DNS (macOS)
val isMac = System.getProperty("os.name").startsWith("Mac OS X")
val architecture = System.getProperty("os.arch")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.eodigo.domain.product.controller
import com.eodigo.common.exception.ErrorResponse
import com.eodigo.domain.product.dto.CategoryInfo
import com.eodigo.domain.product.dto.ProductRankingResponse
import com.eodigo.domain.product.dto.ProductSearchResponse
import com.eodigo.domain.product.dto.ProductTrendResponse
import com.eodigo.domain.product.service.ProductService
import io.swagger.v3.oas.annotations.Operation
Expand All @@ -16,6 +17,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@Tag(name = "물가 API", description = "물가 정보 관련 API")
Expand Down Expand Up @@ -106,4 +108,41 @@ class ProductController(private val productService: ProductService) {
val trendData = productService.getProductTrend(productId)
return ResponseEntity.ok(trendData)
}

@Operation(summary = "상품 검색", description = "키워드가 포함된 상품 목록을 조회합니다.")
@ApiResponses(
value =
[
ApiResponse(
responseCode = "200",
description = "상품 검색 성공. 검색 결과가 없거나, 검색어가 비어있는 경우 빈 배열을 반환합니다.",
),
ApiResponse(
responseCode = "400",
description = "필수 파라미터가 누락된 경우",
content =
[
Content(
schema = Schema(implementation = ErrorResponse::class),
examples =
[
ExampleObject(
name = "Invalid Input Value",
description = "쿼리 파라미터 'keyword'가 누락됨",
value =
"{\"status\": 400, \"code\": \"C001\", \"message\": \"필수 파라미터 'keyword'(이)가 누락되었습니다.\"}",
)
],
)
],
),
]
)
@GetMapping("/search")
fun searchProducts(
@RequestParam("keyword") keyword: String
): ResponseEntity<List<ProductSearchResponse>> {
val searchResult = productService.searchProducts(keyword)
return ResponseEntity.ok(searchResult)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.eodigo.domain.product.dto

import com.eodigo.domain.product.entity.Product
import io.swagger.v3.oas.annotations.media.Schema

data class ProductSearchResponse(
@Schema(description = "상품의 ID", example = "101") val productId: Long,
@Schema(description = "상품의 이름", example = "대파") val name: String,
@Schema(description = "상품의 품목명", example = "파") val itemName: String,
) {
companion object {
fun from(product: Product): ProductSearchResponse {
return ProductSearchResponse(
productId =
requireNotNull(product.id) { "ProductSearchResponse: product.id가 null입니다." },
name = product.name,
itemName = product.itemName,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ interface ProductRepository : JpaRepository<Product, Long> {
fun findAllBySource(source: ProductSource): List<Product>

fun findAllByOrderByIdAsc(): List<Product>

fun findByNameContaining(keyword: String): List<Product>
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,14 @@ class ProductService(
private fun findProductById(productId: Long): Product {
return productRepository.findById(productId).orElseThrow { ProductNotFoundException() }
}

fun searchProducts(keyword: String): List<ProductSearchResponse> {
if (keyword.isBlank()) {
return emptyList()
}

return productRepository.findByNameContaining(keyword).map { product ->
ProductSearchResponse.from(product)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.eodigo.common.exception.GlobalExceptionHandler
import com.eodigo.domain.product.dto.AnnualPriceInfo
import com.eodigo.domain.product.dto.CategoryInfo
import com.eodigo.domain.product.dto.ProductRankingResponse
import com.eodigo.domain.product.dto.ProductSearchResponse
import com.eodigo.domain.product.dto.ProductTrendResponse
import com.eodigo.domain.product.dto.RegionalPriceInfo
import com.eodigo.domain.product.exception.ProductNotFoundException
Expand Down Expand Up @@ -139,4 +140,36 @@ internal class ProductControllerTest {
.andExpect(status().isNotFound)
.andExpect(jsonPath("$.code").value(ErrorCode.PRODUCT_RANKING_NOT_FOUND.code))
}

@Test
@DisplayName("GET /api/v1/products/search: keyword로 상품 검색 요청 시 성공(200 OK)과 함께 결과를 반환한다")
fun searchProducts_Success_ReturnsOkWithResults() {
// given
val keyword = "배추"
val responseDtos =
listOf(
ProductSearchResponse(productId = 1L, name = "봄배추", itemName = "배추"),
ProductSearchResponse(productId = 2L, name = "가을배추", itemName = "배추"),
)
given(productService.searchProducts(keyword)).willReturn(responseDtos)

// when & then
mockMvc
.perform(get("/api/v1/products/search").param("keyword", keyword))
.andExpect(status().isOk) // HTTP 상태가 200 OK 인지
.andExpect(jsonPath("$.length()").value(2)) // JSON 배열의 길이가 2인지
.andExpect(jsonPath("$[0].productId").value(1L)) // 첫 번째 객체의 필드 검증
.andExpect(jsonPath("$[0].name").value("봄배추"))
.andDo(print()) // 요청/응답 전체 내용 출력
}

@Test
@DisplayName("GET /api/v1/products/search: keyword 없이 요청하면 실패(400 Bad Request)한다")
fun searchProducts_ThrowsException_WhenKeywordIsMissing() {
// when & then
mockMvc
.perform(get("/api/v1/products/search")) // keyword 파라미터 없이 요청
.andExpect(status().isBadRequest) // HTTP 상태가 400 Bad Request 인지
.andDo(print())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.eodigo.domain.product.repository

import com.eodigo.domain.product.entity.Product
import com.eodigo.domain.product.enums.ProductSource
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.test.context.ActiveProfiles

@DataJpaTest
@ActiveProfiles("test")
class ProductRepositoryTest {
@Autowired private lateinit var productRepository: ProductRepository

@BeforeEach
fun setUp() {
productRepository.deleteAll()

val products =
listOf(
Product(
name = "대추방울토마토",
categoryCode = "200",
categoryName = "채소",
itemCode = "210",
itemName = "토마토",
source = ProductSource.KAMIS,
kindCode = null,
kindName = null,
),
Product(
name = "토마토",
categoryCode = "200",
categoryName = "채소",
itemCode = "210",
itemName = "토마토",
source = ProductSource.KAMIS,
kindCode = null,
kindName = null,
),
Product(
name = "배추",
categoryCode = "200",
categoryName = "채소",
itemCode = "211",
itemName = "배추",
source = ProductSource.KAMIS,
kindCode = null,
kindName = null,
),
)
productRepository.saveAll(products)
}

@Test
@DisplayName("findByNameContaining: '토마토'가 포함된 상품 2개를 반환한다")
fun findByNameContaining_Success_WhenKeywordMatches() {
// given
val keyword = "토마토"

// when
val results = productRepository.findByNameContaining(keyword)

// then
assertThat(results).hasSize(2)
assertThat(results).extracting("name").containsExactlyInAnyOrder("대추방울토마토", "토마토")
}

@Test
@DisplayName("findByNameContaining: '방울'이 포함된 상품 1개를 반환한다")
fun findByNameContaining_Success_WhenKeywordPartiallyMatches() {
// given
val keyword = "방울"

// when
val results = productRepository.findByNameContaining(keyword)

// then
assertThat(results).hasSize(1)
assertThat(results.first().name).isEqualTo("대추방울토마토")
}

@Test
@DisplayName("findByNameContaining: 일치하는 상품이 없으면 빈 리스트를 반환한다")
fun findByNameContaining_ReturnsEmptyList_WhenKeywordDoesNotMatch() {
// given
val keyword = "없는상품"

// when
val results = productRepository.findByNameContaining(keyword)

// then
assertThat(results).isEmpty()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.eodigo.domain.product.service

import com.eodigo.domain.product.dto.ProductSearchResponse
import com.eodigo.domain.product.dto.RegionalPriceInfo
import com.eodigo.domain.product.entity.AnnualNationalPrice
import com.eodigo.domain.product.entity.DailyRegionalPrice
Expand All @@ -19,10 +20,11 @@ import org.assertj.core.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.BDDMockito.* // BDDMockito를 사용하면 given-when-then과 더 잘 어울립니다.
import org.mockito.BDDMockito.*
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any

@ExtendWith(MockitoExtension::class)
internal class ProductServiceTest {
Expand Down Expand Up @@ -323,4 +325,51 @@ internal class ProductServiceTest {
assertThatThrownBy { productService.getProductTrend(productId) }
.isInstanceOf(ProductTrendNotFoundException::class.java)
}

@Test
@DisplayName("searchProducts: 검색 결과가 있을 때 ProductSearchResponse DTO 목록으로 변환하여 반환한다")
fun searchProducts_Success_ReturnsDtoList() {
// given
val keyword = "토마토"
val product1 =
Product(
name = "토마토",
categoryCode = "200",
categoryName = "채소",
itemCode = "210",
itemName = "토마토",
source = ProductSource.KAMIS,
kindCode = null,
kindName = null,
)

product1.javaClass.getDeclaredField("id").apply {
isAccessible = true
set(product1, 1L)
}
given(productRepository.findByNameContaining(keyword)).willReturn(listOf(product1))

// when
val results = productService.searchProducts(keyword)

// then
assertThat(results).hasSize(1)
assertThat(results.first()).isInstanceOf(ProductSearchResponse::class.java)
assertThat(results.first().name).isEqualTo("토마토")
assertThat(results.first().productId).isEqualTo(1L)
}

@Test
@DisplayName("searchProducts: 검색어가 비어있거나 공백이면, DB 조회를 하지 않고 빈 리스트를 반환한다")
fun searchProducts_ReturnsEmptyList_WhenKeywordIsBlank() {
// given
val keyword = " "

// when
val results = productService.searchProducts(keyword)

// then
assertThat(results).isEmpty()
verify(productRepository, never()).findByNameContaining(any())
}
}