Skip to content

Commit

Permalink
[Feature] - 여행기 제목 기준 검색 기능 구현 (#302)
Browse files Browse the repository at this point in the history
* feat: 여행기 제목 키워드 기준 검색 기능 구현

* feat: 여행기 검색 기능을 위한 키워드 검증 예외 처리

* feat: QueryDSL 의존성 추가

* refactor: 예외 메시지 추출 로직 변경

* refactor: API 문서 설명 수정

* test: 여행기 제목 키워드 검색 기능 테스트 작성

* feat: 여행기 제목 키워드 기준 검색 기능 구현

* refactor: 추상화에 의존하도록 변경

* refactor: 필드 final 추가

* refactor: DTO 변환 과정 개선

* refactor: 필요 없어진 예외 처리 로직 제거

* refactor: 검색 키워드 request parameter DTO로 분리

* refactor: 검색 메소드 시그니처 리팩토링

* refactor: 여행기 조회 테스트 검증 대상 수정

* chore: 오타 수정

* refactor: pagination 관련 테스트 fixture 수정

* fix: conflict 해결

* refactor: 조회 쿼리에 정렬 및 페이지네이션 정보 추가
  • Loading branch information
hangillee authored Aug 14, 2024
1 parent e033a56 commit 116d079
Show file tree
Hide file tree
Showing 15 changed files with 260 additions and 26 deletions.
6 changes: 6 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ dependencies {
// cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
Expand Down
21 changes: 21 additions & 0 deletions backend/src/main/java/kr/touroot/global/config/QueryDslConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package kr.touroot.global.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@RequiredArgsConstructor
@Configuration
public class QueryDslConfig {

@PersistenceContext
private final EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import kr.touroot.global.auth.dto.MemberAuth;
import kr.touroot.global.exception.dto.ExceptionResponse;
import kr.touroot.travelogue.dto.request.TravelogueRequest;
import kr.touroot.travelogue.dto.request.TravelogueSearchRequest;
import kr.touroot.travelogue.dto.response.TravelogueResponse;
import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse;
import kr.touroot.travelogue.service.TravelogueFacadeService;
Expand Down Expand Up @@ -100,6 +101,30 @@ public ResponseEntity<Page<TravelogueSimpleResponse>> findMainPageTravelogues(
return ResponseEntity.ok(travelogueFacadeService.findSimpleTravelogues(pageable));
}

@Operation(summary = "여행기 검색")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "요청이 정상적으로 처리되었을 때"
),
@ApiResponse(
responseCode = "400",
description = "올바르지 않은 페이지네이션 옵션 또는 키워드로 요청했을 때",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
),
})
@PageableAsQueryParam
@GetMapping("/search")
public ResponseEntity<Page<TravelogueSimpleResponse>> findTraveloguesByKeyword(
@Parameter(hidden = true)
@PageableDefault(size = 5, sort = "id", direction = Direction.DESC)
Pageable pageable,
@Valid
TravelogueSearchRequest searchRequest
) {
return ResponseEntity.ok(travelogueFacadeService.findSimpleTravelogues(pageable, searchRequest));
}

@Operation(summary = "여행기 삭제")
@ApiResponses(value = {
@ApiResponse(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.touroot.travelogue.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record TravelogueSearchRequest(
@NotBlank(message = "검색어는 2글자 이상이어야 합니다.")
@Size(min = 2, message = "검색어는 2글자 이상이어야 합니다.")
String keyword
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.touroot.travelogue.repository;

import kr.touroot.travelogue.domain.Travelogue;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface TravelogueQueryRepository {

Page<Travelogue> findByTitleContaining(String keyword, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package kr.touroot.travelogue.repository;

import static kr.touroot.travelogue.domain.QTravelogue.travelogue;

import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import kr.touroot.travelogue.domain.Travelogue;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

@RequiredArgsConstructor
@Repository
public class TravelogueQueryRepositoryImpl implements TravelogueQueryRepository {

private final JPAQueryFactory jpaQueryFactory;

@Override
public Page<Travelogue> findByTitleContaining(String keyword, Pageable pageable) {
List<Travelogue> results = jpaQueryFactory.selectFrom(travelogue)
.where(Expressions.stringTemplate("replace({0}, ' ', '')", travelogue.title)
.containsIgnoreCase(keyword.replace(" ", "")))
.orderBy(travelogue.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

return new PageImpl<>(results, pageable, results.size());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
import kr.touroot.travelogue.dto.request.TraveloguePhotoRequest;
import kr.touroot.travelogue.dto.request.TraveloguePlaceRequest;
import kr.touroot.travelogue.dto.request.TravelogueRequest;
import kr.touroot.travelogue.dto.request.TravelogueSearchRequest;
import kr.touroot.travelogue.dto.response.TravelogueDayResponse;
import kr.touroot.travelogue.dto.response.TraveloguePlaceResponse;
import kr.touroot.travelogue.dto.response.TravelogueResponse;
import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -108,9 +108,14 @@ private List<String> findPhotoUrlsOfTraveloguePlace(TraveloguePlace place) {
public Page<TravelogueSimpleResponse> findSimpleTravelogues(final Pageable pageable) {
Page<Travelogue> travelogues = travelogueService.findAll(pageable);

return new PageImpl<>(travelogues.stream()
.map(this::getTravelogueSimpleResponse)
.toList());
return travelogues.map(this::getTravelogueSimpleResponse);
}

@Transactional(readOnly = true)
public Page<TravelogueSimpleResponse> findSimpleTravelogues(Pageable pageable, TravelogueSearchRequest request) {
Page<Travelogue> travelogues = travelogueService.findByKeyword(request.keyword(), pageable);

return travelogues.map(this::getTravelogueSimpleResponse);
}

private TravelogueSimpleResponse getTravelogueSimpleResponse(Travelogue travelogue) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import kr.touroot.member.domain.Member;
import kr.touroot.travelogue.domain.Travelogue;
import kr.touroot.travelogue.dto.request.TravelogueRequest;
import kr.touroot.travelogue.repository.TravelogueQueryRepository;
import kr.touroot.travelogue.repository.TravelogueRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand All @@ -18,6 +19,7 @@ public class TravelogueService {

private final TravelogueRepository travelogueRepository;
private final AwsS3Provider s3Provider;
private final TravelogueQueryRepository travelogueQueryRepository;

public Travelogue createTravelogue(Member author, TravelogueRequest request) {
String url = s3Provider.copyImageToPermanentStorage(request.thumbnail());
Expand All @@ -38,6 +40,10 @@ public Page<Travelogue> findAllByMember(Member member, Pageable pageable) {
return travelogueRepository.findAllByAuthor(member, pageable);
}

public Page<Travelogue> findByKeyword(String keyword, Pageable pageable) {
return travelogueQueryRepository.findByTitleContaining(keyword, pageable);
}

public void delete(Travelogue travelogue, Member author) {
validateDeleteByAuthor(travelogue, author);
travelogueRepository.delete(travelogue);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package kr.touroot.global.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import kr.touroot.travelogue.repository.TravelogueQueryRepository;
import kr.touroot.travelogue.repository.TravelogueQueryRepositoryImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

@RequiredArgsConstructor
@TestConfiguration
public class TestQueryDslConfig {

@PersistenceContext
private final EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}

@Bean
public TravelogueQueryRepository travelogueQueryRepository() {
return new TravelogueQueryRepositoryImpl(jpaQueryFactory());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ void setUp() {
@Test
void readTravelogues() {
// given
travelogueTestHelper.initTravelogueTestDate(member);
travelogueTestHelper.initTravelogueTestDate(member);
travelogueTestHelper.initTravelogueTestData(member);
travelogueTestHelper.initTravelogueTestData(member);
travelogueTestHelper.initTravelogueTestData();

// when & then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
Expand Down Expand Up @@ -234,8 +237,7 @@ void findTravelogueWithTags() throws JsonProcessingException {
@DisplayName("메인 페이지 조회 시, 최신 작성 순으로 여행기를 조회한다.")
@Test
void findMainPageTravelogues() throws JsonProcessingException {
testHelper.initTravelogueTestData();
testHelper.initTravelogueTestDataWithTag(member);
testHelper.initAllTravelogueTestData();
Page<TravelogueSimpleResponse> responses = TravelogueResponseFixture.getTravelogueSimpleResponses();

RestAssured.given().log().all()
Expand All @@ -257,6 +259,53 @@ void findNotExistTravelogueThrowException() {
.body("message", is("존재하지 않는 여행기입니다."));
}

@DisplayName("제목 키워드를 기준으로 여행기를 조회할 수 있다.")
@Test
void findTraveloguesByTitleKeyword() throws JsonProcessingException {
testHelper.initAllTravelogueTestData();
Page<TravelogueSimpleResponse> responses = TravelogueResponseFixture.getTravelogueSimpleResponses();

RestAssured.given().param("keyword", "제주")
.log().all()
.accept(ContentType.JSON)
.when().get("/api/v1/travelogues/search")
.then().log().all()
.statusCode(200).assertThat()
.body(is(objectMapper.writeValueAsString(responses)));
}

@DisplayName("제목 키워드는 2글자 이상이어야 한다.")
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", " "})
void findTraveloguesKeywordNotBlank(String keyword) {
testHelper.initTravelogueTestData();

RestAssured.given().param("keyword", keyword)
.log().all()
.accept(ContentType.JSON)
.when().get("/api/v1/travelogues/search")
.then().log().all()
.statusCode(400).assertThat()
.body("message", is("검색어는 2글자 이상이어야 합니다."));
}

@DisplayName("제목 키워드는 중간 공백 상관 없이 검색되어야 한다.")
@ParameterizedTest
@ValueSource(strings = {"제 주", "제주 에하영옵 서"})
void findTraveloguesKeywordWithMiddleBlank(String keyword) throws JsonProcessingException {
testHelper.initAllTravelogueTestData();
Page<TravelogueSimpleResponse> responses = TravelogueResponseFixture.getTravelogueSimpleResponses();

RestAssured.given().param("keyword", keyword)
.log().all()
.accept(ContentType.JSON)
.when().get("/api/v1/travelogues/search")
.then().log().all()
.statusCode(200).assertThat()
.body(is(objectMapper.writeValueAsString(responses)));
}

@DisplayName("여행기를 삭제한다.")
@Test
void deleteTravelogue() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.stereotype.Component;

@Component
Expand Down Expand Up @@ -47,7 +50,7 @@ public static TravelogueResponse getTravelogueResponseWithTag() {
}

public static Page<TravelogueSimpleResponse> getTravelogueSimpleResponses() {
return new PageImpl<>(List.of(
List<TravelogueSimpleResponse> responses = List.of(
TravelogueSimpleResponse.builder()
.id(2L)
.title("제주에 하영 옵서")
Expand All @@ -64,7 +67,9 @@ public static Page<TravelogueSimpleResponse> getTravelogueSimpleResponses() {
.thumbnail("https://dev.touroot.kr/temporary/jeju_thumbnail.png")
.tags(List.of())
.build()
));
);

return new PageImpl<>(responses, PageRequest.of(0, 5, Sort.by(Direction.DESC, "id")), responses.size());
}

public static List<TravelogueDayResponse> getTravelogueDayResponses() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ public TravelogueTestHelper(
this.travelogueTagRepository = travelogueTagRepository;
}

public void initAllTravelogueTestData() {
Member author = persistMember();
initTravelogueTestData(author);
initTravelogueTestDataWithTag(author);
}

public Travelogue initTravelogueTestData() {
Member author = persistMember();
return initTravelogueTestData(author);
Expand Down Expand Up @@ -92,14 +98,6 @@ private void persisTravelogueTag(Travelogue travelogue) {
travelogueTagRepository.save(new TravelogueTag(travelogue, tag));
}

public void initTravelogueTestDate(Member author) {
Travelogue travelogue = persistTravelogue(author);
TravelogueDay day = persistTravelogueDay(travelogue);
Place position = persistPlace();
TraveloguePlace place = persistTraveloguePlace(position, day);
persistTraveloguePhoto(place);
}

public Member persistMember() {
Member author = MEMBER_KAKAO.getMember();

Expand Down
Loading

0 comments on commit 116d079

Please sign in to comment.