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
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ public class BookQueryController {
//책 검색결과 조회
@GetMapping("/books")
public BaseResponse<GetBookSearchListResponse> getBookSearchList(@RequestParam final String keyword,
@RequestParam final int page) {
return BaseResponse.ok(GetBookSearchListResponse.of(bookSearchUseCase.searchBooks(keyword, page), page));
@RequestParam final int page,
@UserId final Long userId) {
return BaseResponse.ok(GetBookSearchListResponse.of(bookSearchUseCase.searchBooks(keyword, page,userId), page));
}

//책 상세검색 결과 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@

public interface BookSearchUseCase {

NaverBookParseResult searchBooks(String keyword, int page);
NaverBookParseResult searchBooks(String keyword, int page,Long userId);
BookDetailSearchResult searchDetailBooks(String isbn,Long userId);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package konkuk.thip.book.application.service;

import jakarta.transaction.Transactional;
import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult;
import konkuk.thip.book.application.port.in.BookSearchUseCase;
Expand All @@ -9,6 +10,8 @@
import konkuk.thip.book.domain.Book;
import konkuk.thip.common.exception.BusinessException;
import konkuk.thip.feed.application.port.out.FeedQueryPort;
import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort;
import konkuk.thip.recentSearch.domain.RecentSearch;
import konkuk.thip.room.application.port.out.RoomQueryPort;
import konkuk.thip.saved.application.port.out.SavedQueryPort;
import konkuk.thip.user.application.port.out.UserCommandPort;
Expand All @@ -24,6 +27,7 @@

import static konkuk.thip.book.adapter.out.api.NaverApiUtil.PAGE_SIZE;
import static konkuk.thip.common.exception.code.ErrorCode.*;
import static konkuk.thip.recentSearch.adapter.out.jpa.SearchType.BOOK_SEARCH;

@Service
@RequiredArgsConstructor
Expand All @@ -34,12 +38,14 @@ public class BookSearchService implements BookSearchUseCase {
private final UserQueryPort userQueryPort;
private final FeedQueryPort feedQueryPort;
private final SavedQueryPort savedQueryPort;
private final RecentSearchCommandPort recentSearchCommandPort;
private final BookCommandPort bookCommandPort;
private final UserCommandPort userCommandPort;


@Override
public NaverBookParseResult searchBooks(String keyword, int page) {
@Transactional
public NaverBookParseResult searchBooks(String keyword, int page, Long userId) {

if (keyword == null || keyword.isBlank()) {
throw new BusinessException(BOOK_KEYWORD_REQUIRED);
Expand All @@ -49,7 +55,6 @@ public NaverBookParseResult searchBooks(String keyword, int page) {
throw new BusinessException(BOOK_PAGE_NUMBER_INVALID);
}

//유저의 최근검색어 로직 추가

int start = (page - 1) * PAGE_SIZE + 1; //검색 시작 위치
NaverBookParseResult result = bookApiQueryPort.findBooksByKeyword(keyword, start);
Expand All @@ -60,6 +65,14 @@ public NaverBookParseResult searchBooks(String keyword, int page) {
throw new BusinessException(BOOK_SEARCH_PAGE_OUT_OF_RANGE);
}

//최근검색어 추가
RecentSearch recentSearch = RecentSearch.builder()
.searchTerm(keyword)
.type(BOOK_SEARCH.getSearchType())
.userId(userId)
.build();
recentSearchCommandPort.save(userId,recentSearch);

return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ public enum ErrorCode implements ResponseCode {
BOOK_KEYWORD_REQUIRED(HttpStatus.BAD_REQUEST, 80007, "검색어는 필수 입력값입니다."),
BOOK_PAGE_NUMBER_INVALID(HttpStatus.BAD_REQUEST, 80008, "페이지 번호는 1 이상의 값이어야 합니다."),
BOOK_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80009, "ISBN으로 검색한 결과가 존재하지 않습니다."),
BOOK_NOT_FOUND(HttpStatus.BAD_REQUEST, 80010, "존재하지 않는 BOOK 입니다.");

BOOK_NOT_FOUND(HttpStatus.BAD_REQUEST, 80010, "존재하지 않는 BOOK 입니다."),



/**
* 90000 : recentSearch error
*/
INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 900000,"알맞은 검색어 타입을 찾을 수 없습니다.");


private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package konkuk.thip.recentSearch.adapter.out.jpa;


import konkuk.thip.common.exception.BusinessException;
import lombok.Getter;

import static konkuk.thip.common.exception.code.ErrorCode.INVALID_SEARCH_TYPE;

@Getter
public enum SearchType {

Expand All @@ -21,8 +24,6 @@ public static SearchType from(String searchType) {
return type;
}
}
//컨트롤러 어드바이스 추가하고 예외처리
//throw new GlobalException(INVALID_SEARCH_TYPE);
return null;
throw new BusinessException(INVALID_SEARCH_TYPE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
package konkuk.thip.recentSearch.adapter.out.persistence;

import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity;
import konkuk.thip.recentSearch.adapter.out.mapper.RecentSearchMapper;
import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort;
import konkuk.thip.recentSearch.domain.RecentSearch;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND;

@Repository
@RequiredArgsConstructor
public class RecentSearchCommandPersistenceAdapter implements RecentSearchCommandPort {

private final RecentSearchJpaRepository recentSearchJpaRepository;
private final UserJpaRepository userJpaRepository;

private final RecentSearchMapper recentSearchMapper;

@Override
public void save(Long userId,RecentSearch recentSearch) {

UserJpaEntity userJpaEntity = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

RecentSearchJpaEntity recentSearchJpaEntity =
recentSearchMapper.toJpaEntity(recentSearch, userJpaEntity);

recentSearchJpaRepository.save(recentSearchJpaEntity);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package konkuk.thip.recentSearch.application.port.out;


public interface RecentSearchCommandPort {
import konkuk.thip.recentSearch.domain.RecentSearch;

public interface RecentSearchCommandPort {
void save(Long userId, RecentSearch recentSearch);
Comment on lines +6 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

메서드 시그니처 개선으로 도메인 객체 활용

이전의 개별 파라미터들(String keyword, SearchType searchType)을 RecentSearch 도메인 객체로 캡슐화한 것은 좋은 설계 개선입니다. 이는 다음과 같은 이점을 제공합니다:

  • 관련 데이터의 응집성 향상
  • 인터페이스의 유지보수성 개선
  • 도메인 주도 설계 원칙 준수

다만, userId 파라미터가 RecentSearch 객체와 함께 전달되는 설계가 적절한지 검토해보시기 바랍니다.

다음 스크립트로 RecentSearch 도메인 객체에 사용자 정보가 포함되어 있는지 확인해보겠습니다:


🏁 Script executed:

#!/bin/bash
# RecentSearch 도메인 객체의 구조 확인
ast-grep --pattern $'class RecentSearch {
  $$$
}'

Length of output: 51


🏁 Script executed:

#!/bin/bash
# RecentSearch 도메인 객체 정의 위치 및 구조 확인
file=$(find . -type f -name "RecentSearch.java" | head -n 1)
if [ -z "$file" ]; then
  echo "RecentSearch.java 파일을 찾을 수 없습니다."
else
  echo "==> $file <=="
  sed -n '1,200p' "$file"
fi

Length of output: 785


메서드 시그니처 간소화 제안
RecentSearch 도메인 객체에 이미 userId 필드가 정의되어 있으므로, save 메서드에서 별도의 userId 파라미터 전달은 중복입니다. 다음과 같이 수정을 권장드립니다:

  • RecentSearchCommandPort 인터페이스
    public interface RecentSearchCommandPort {
  • void save(Long userId, RecentSearch recentSearch);
  • void save(RecentSearch recentSearch);
    }
- 호출부에서 RecentSearch.builder() 등을 통해 userId를 설정 후 전달
- 인터페이스 응집성·가독성 향상 및 중복 인자 제거

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
public interface RecentSearchCommandPort {
  void save(RecentSearch recentSearch);
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchCommandPort.java
at lines 6-7, simplify the save method signature by removing the separate userId
parameter since RecentSearch already contains userId. Modify the save method to
accept only a RecentSearch object, and ensure callers set the userId within
RecentSearch before calling save. This improves interface cohesion and
eliminates redundant parameters.

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
package konkuk.thip.book.adapter.in.web;

import konkuk.thip.common.exception.code.ErrorCode;
import konkuk.thip.common.security.util.JwtUtil;
import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity;
import konkuk.thip.recentSearch.adapter.out.jpa.SearchType;
import konkuk.thip.recentSearch.adapter.out.persistence.RecentSearchJpaRepository;
import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.jpa.UserRole;
import konkuk.thip.user.adapter.out.persistence.AliasJpaRepository;
import konkuk.thip.user.adapter.out.persistence.UserJpaRepository;
import org.junit.jupiter.api.AfterEach;
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;
Expand All @@ -10,25 +21,79 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class BookQueryControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private UserJpaRepository userJpaRepository;

@Autowired
private AliasJpaRepository aliasJpaRepository;

@Autowired
private RecentSearchJpaRepository recentSearchJpaRepository;

@Autowired
private JwtUtil jwtUtil;

private String testToken;

@BeforeEach
void setUp() {

AliasJpaEntity alias = aliasJpaRepository.save(AliasJpaEntity.builder()
.value("책벌레")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📖🐜

.color("blue")
.imageUrl("http://image.url")
.build());

UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder()
.oauth2Id("kakao_432708231")
.nickname("User1")
.imageUrl("https://avatar1.jpg")
.role(UserRole.USER)
.aliasForUserJpaEntity(alias)
.build());

RecentSearchJpaEntity recentSearch = recentSearchJpaRepository.save(RecentSearchJpaEntity.builder()
.searchTerm("테스트검색어")
.type(SearchType.BOOK_SEARCH)
.userJpaEntity(user)
.build());

testToken = jwtUtil.createAccessToken(user.getUserId());
}

@AfterEach
void tearDown() {
recentSearchJpaRepository.deleteAll();
userJpaRepository.deleteAll();
aliasJpaRepository.deleteAll();
}

@Test
@DisplayName("책 검색 API 정상 호출 - 키워드와 페이지 번호가 주어졌을 때")
void searchBooks_success() throws Exception {

// given
String keyword = "테스트";
int page = 1;

// when & then
mockMvc.perform(get("/books")
.param("keyword", "테스트")
.param("page", "1")
.header("Authorization", "Bearer " + testToken)
.param("keyword", keyword)
.param("page", String.valueOf(page))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
Expand All @@ -40,9 +105,16 @@ void searchBooks_success() throws Exception {
@Test
@DisplayName("책 검색 API 실패 - 페이지가 범위를 벗어났을 때 400 에러 발생")
void searchBooks_pageOutOfRange() throws Exception {

// given
String keyword = "테스트";
int page = 99999; // totalPages보다 큰 값으로 가정

// when & then
mockMvc.perform(get("/books")
.param("keyword", "테스트")
.param("page", "99999") // totalPages보다 큰 값으로 가정
.header("Authorization", "Bearer " + testToken)
.param("keyword", keyword)
.param("page", String.valueOf(page))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false))
Expand All @@ -54,9 +126,16 @@ void searchBooks_pageOutOfRange() throws Exception {
@Test
@DisplayName("책 검색 API 실패 - 키워드가 비어서 넘어올 때 400 에러 발생")
void searchBooks_keywordMissing_badRequest() throws Exception {

// given
String keyword = "";
int page = 1;

// when & then
mockMvc.perform(get("/books")
.param("page", "1")
.param("keyword", "")
.header("Authorization", "Bearer " + testToken)
.param("keyword", keyword)
.param("page", String.valueOf(page))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.BOOK_KEYWORD_REQUIRED.getCode()))
Expand All @@ -66,12 +145,51 @@ void searchBooks_keywordMissing_badRequest() throws Exception {
@Test
@DisplayName("책 검색 API 실패 - 페이지 번호가 1 미만일 때 400 에러 발생")
void searchBooks_pageInvalid_badRequest() throws Exception {

// given
String keyword = "테스트";
int page = 0;

// when & then
mockMvc.perform(get("/books")
.param("keyword", "테스트")
.param("page", "0")
.header("Authorization", "Bearer " + testToken)
.param("keyword", keyword)
.param("page", String.valueOf(page))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.BOOK_PAGE_NUMBER_INVALID.getCode()))
.andExpect(jsonPath("$.message", containsString("페이지 번호는 1 이상의 값이어야 합니다")));
}


@Test
@DisplayName("책 검색 성공 시 최근검색어 저장")
void searchBooks_savesRecentSearch() throws Exception {
Comment on lines +165 to +167
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM


// given
String keyword = "테스트";
int page = 1;

// when
mockMvc.perform(get("/books")
.header("Authorization", "Bearer " + testToken)
.param("keyword", keyword)
.param("page", String.valueOf(page))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());

// then
// 최근검색어가 저장되었는지 확인
UserJpaEntity user = userJpaRepository.findAll().get(0);
RecentSearchJpaEntity recentSearch = recentSearchJpaRepository.findAll().stream()
.filter(rs -> rs.getUserJpaEntity().getUserId().equals(user.getUserId()))
.filter(rs -> rs.getSearchTerm().equals(keyword))
.findFirst()
.orElse(null);

assertThat(recentSearch).isNotNull();
assertThat(recentSearch.getSearchTerm()).isEqualTo(keyword);
assertThat(recentSearch.getType()).isEqualTo(SearchType.BOOK_SEARCH);
assertThat(recentSearch.getUserJpaEntity().getUserId()).isEqualTo(user.getUserId());
}
}