Skip to content
Merged
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
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,22 @@
package com.eodigo.domain.product.dto

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

@Schema(name = "ProductSearchResponse", description = "상품 검색 결과 DTO")
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())
}
}