Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] - 여행기 제목 기준 검색 기능 구현 #302

Merged
merged 19 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf4068e
feat: 여행기 제목 키워드 기준 검색 기능 구현
hangillee Aug 13, 2024
3db9e0f
feat: 여행기 검색 기능을 위한 키워드 검증 예외 처리
hangillee Aug 13, 2024
f82b05e
feat: QueryDSL 의존성 추가
hangillee Aug 13, 2024
1e06495
refactor: 예외 메시지 추출 로직 변경
hangillee Aug 13, 2024
500f981
refactor: API 문서 설명 수정
hangillee Aug 13, 2024
4da7057
test: 여행기 제목 키워드 검색 기능 테스트 작성
hangillee Aug 13, 2024
ff064a5
feat: 여행기 제목 키워드 기준 검색 기능 구현
hangillee Aug 13, 2024
947e7dd
refactor: 추상화에 의존하도록 변경
hangillee Aug 14, 2024
26ef51c
refactor: 필드 final 추가
hangillee Aug 14, 2024
6cbfff7
refactor: DTO 변환 과정 개선
hangillee Aug 14, 2024
50221e2
refactor: 필요 없어진 예외 처리 로직 제거
hangillee Aug 14, 2024
2d103a6
refactor: 검색 키워드 request parameter DTO로 분리
hangillee Aug 14, 2024
0d1c8c4
refactor: 검색 메소드 시그니처 리팩토링
hangillee Aug 14, 2024
9483715
refactor: 여행기 조회 테스트 검증 대상 수정
hangillee Aug 14, 2024
72e6149
Merge branch 'develop/be' into feature/be/#254
hangillee Aug 14, 2024
b0f0d4f
chore: 오타 수정
hangillee Aug 14, 2024
74b5bef
refactor: pagination 관련 테스트 fixture 수정
hangillee Aug 14, 2024
7dd62ca
fix: conflict 해결
hangillee Aug 14, 2024
dab43fb
refactor: 조회 쿼리에 정렬 및 페이지네이션 정보 추가
hangillee Aug 14, 2024
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
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"
}
Comment on lines +55 to 60
Copy link

Choose a reason for hiding this comment

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

확인 👍


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);

Choose a reason for hiding this comment

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

😓 오타의 주범 정말 죄송합니다

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
Loading