Skip to content

Commit

Permalink
refactor: 봉사 모집글 검색(봉사자) 로직의 페이지네이션을 커서 기반으로 개선한다. (#293)
Browse files Browse the repository at this point in the history
* feat: 봉사 모집글 조회(봉사자) 로직을 no offset으로 구현한다.

Co-Authored-By: hseong3243 <48748265+hseong3243@users.noreply.github.com>
Co-Authored-By: Seonheui Jeon <88873302+funnysunny08@users.noreply.github.com>
Co-Authored-By: minjungkim <97938489+pushedrumex@users.noreply.github.com>

* test: 봉사 모집글 조회(봉사자) no offset 로직을 테스트한다.

Co-Authored-By: hseong3243 <48748265+hseong3243@users.noreply.github.com>
Co-Authored-By: Seonheui Jeon <88873302+funnysunny08@users.noreply.github.com>
Co-Authored-By: minjungkim <97938489+pushedrumex@users.noreply.github.com>
Co-Authored-By: hseong3243 <48748265+hseong3243@users.noreply.github.com>
Co-Authored-By: Seonheui Jeon <88873302+funnysunny08@users.noreply.github.com>
Co-Authored-By: minjungkim <97938489+pushedrumex@users.noreply.github.com>

* fix: feat/#267 브랜치에 cd-dev를 적용하지 않는다.

* feat: count가 null인 경우 0으로 변경한다.

* test: 봉사 모집글 조회(봉사자) no offset 서비스 로직을 테스트한다.

* test: 봉사 모집글 조회(봉사자) no offset 컨트롤러 테스트 코드를 작성한다.

* fix: 봉사 모집글 조회(봉사자) 테스트 코드에서 봉사자 액세스 토큰을 제거한다.

* test: countFindRecruitmentsV2 레포지토리 테스트 코드를 작성한다.

---------

Co-authored-by: hseong3243 <48748265+hseong3243@users.noreply.github.com>
Co-authored-by: Seonheui Jeon <88873302+funnysunny08@users.noreply.github.com>
Co-authored-by: minjungkim <97938489+pushedrumex@users.noreply.github.com>
4 people authored Nov 21, 2023
1 parent 465066f commit 21d2659
Showing 9 changed files with 370 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.clova.anifriends.domain.recruitment.controller;

import com.clova.anifriends.domain.auth.LoginUser;
import com.clova.anifriends.domain.auth.authorization.ShelterOnly;
import com.clova.anifriends.domain.recruitment.dto.request.FindRecruitmentsByShelterRequest;
import com.clova.anifriends.domain.recruitment.dto.request.FindRecruitmentsRequest;
import com.clova.anifriends.domain.recruitment.dto.request.FindRecruitmentsRequestV2;
import com.clova.anifriends.domain.recruitment.dto.request.RegisterRecruitmentRequest;
import com.clova.anifriends.domain.recruitment.dto.request.UpdateRecruitmentRequest;
import com.clova.anifriends.domain.recruitment.dto.response.FindCompletedRecruitmentsResponse;
@@ -12,7 +14,6 @@
import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse;
import com.clova.anifriends.domain.recruitment.dto.response.RegisterRecruitmentResponse;
import com.clova.anifriends.domain.recruitment.service.RecruitmentService;
import com.clova.anifriends.domain.auth.authorization.ShelterOnly;
import jakarta.validation.Valid;
import java.net.URI;
import lombok.RequiredArgsConstructor;
@@ -88,6 +89,27 @@ public ResponseEntity<FindRecruitmentsResponse> findRecruitments(
));
}

@GetMapping("/v2/recruitments")
public ResponseEntity<FindRecruitmentsResponse> findRecruitmentsV2(
@ModelAttribute @Valid FindRecruitmentsRequestV2 findRecruitmentsRequestV2,
Pageable pageable) {
KeywordCondition keywordCondition = findRecruitmentsRequestV2.keywordFilter()
.getKeywordCondition();

return ResponseEntity.ok(recruitmentService.findRecruitmentsV2(
findRecruitmentsRequestV2.keyword(),
findRecruitmentsRequestV2.startDate(),
findRecruitmentsRequestV2.endDate(),
findRecruitmentsRequestV2.closedFilter().getIsClosed(),
keywordCondition.titleFilter(),
keywordCondition.contentFilter(),
keywordCondition.shelterNameFilter(),
findRecruitmentsRequestV2.createdAt(),
findRecruitmentsRequestV2.recruitmentId(),
pageable
));
}

@ShelterOnly
@GetMapping("/shelters/recruitments")
public ResponseEntity<FindRecruitmentsByShelterResponse> findRecruitmentsByShelter(
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.clova.anifriends.domain.recruitment.dto.request;

import com.clova.anifriends.domain.recruitment.controller.KeywordFilter;
import com.clova.anifriends.domain.recruitment.controller.RecruitmentStatusFilter;
import java.time.LocalDate;
import java.time.LocalDateTime;

public record FindRecruitmentsRequestV2(
String keyword,
LocalDate startDate,
LocalDate endDate,
RecruitmentStatusFilter closedFilter,
KeywordFilter keywordFilter,
Long recruitmentId,
LocalDateTime createdAt
) {

public FindRecruitmentsRequestV2(
String keyword,
LocalDate startDate,
LocalDate endDate,
RecruitmentStatusFilter closedFilter,
KeywordFilter keywordFilter,
Long recruitmentId,
LocalDateTime createdAt
) {
this.keyword = keyword;
this.startDate = startDate;
this.endDate = endDate;
this.closedFilter = closedFilter == null ? RecruitmentStatusFilter.ALL : closedFilter;
this.keywordFilter = keywordFilter == null ? KeywordFilter.ALL : keywordFilter;
this.recruitmentId = recruitmentId;
this.createdAt = createdAt;
}
}
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Slice;

public record FindRecruitmentsResponse(
List<FindRecruitmentResponse> recruitments,
@@ -44,4 +45,13 @@ public static FindRecruitmentsResponse from(Page<Recruitment> recruitments) {
.toList();
return new FindRecruitmentsResponse(content, pageInfo);
}

public static FindRecruitmentsResponse fromV2(Slice<Recruitment> recruitments, Long count) {
PageInfo pageInfo = PageInfo.of(count, recruitments.hasNext());
List<FindRecruitmentResponse> content = recruitments.getContent()
.stream()
.map(FindRecruitmentResponse::from)
.toList();
return new FindRecruitmentsResponse(content, pageInfo);
}
}
Original file line number Diff line number Diff line change
@@ -2,15 +2,26 @@

import com.clova.anifriends.domain.recruitment.Recruitment;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

public interface RecruitmentRepositoryCustom {

Page<Recruitment> findRecruitments(String keyword, LocalDate startDate,
LocalDate endDate, Boolean isClosed, boolean titleContains, boolean contentContains,
boolean shelterNameContains, Pageable pageable);

Slice<Recruitment> findRecruitmentsV2(String keyword, LocalDate startDate,
LocalDate endDate, Boolean isClosed, boolean titleContains, boolean contentContains,
boolean shelterNameContains, LocalDateTime createdAt, Long recruitmentId,
Pageable pageable);

Long countFindRecruitmentsV2(String keyword, LocalDate startDate,
LocalDate endDate, Boolean isClosed, boolean titleContains, boolean contentContains,
boolean shelterNameContains);

Page<Recruitment> findRecruitmentsByShelterOrderByCreatedAt(long shelterId, String keyword,
LocalDate startDate, LocalDate endDate, Boolean isClosed, Boolean content, Boolean title,
Pageable pageable);
Original file line number Diff line number Diff line change
@@ -8,13 +8,16 @@
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.stereotype.Repository;

@Repository
@@ -55,6 +58,68 @@ public Page<Recruitment> findRecruitments(String keyword, LocalDate startDate,
return new PageImpl<>(content, pageable, count);
}

@Override
public Slice<Recruitment> findRecruitmentsV2(String keyword, LocalDate startDate,
LocalDate endDate, Boolean isClosed, boolean titleContains, boolean contentContains,
boolean shelterNameContains, LocalDateTime createdAt, Long recruitmentId,
Pageable pageable) {
List<Recruitment> content = query.select(recruitment)
.from(recruitment)
.join(recruitment.shelter)
.leftJoin(recruitment.applicants)
.where(
keywordSearch(keyword, titleContains, contentContains, shelterNameContains),
recruitmentIsClosed(isClosed),
recruitmentStartTimeGoe(startDate),
recruitmentStartTimeLoe(endDate),
cursorId(recruitmentId, createdAt)
)
.orderBy(recruitment.createdAt.desc())
.limit(pageable.getPageSize() + 1L)
.offset(pageable.getOffset())
.fetch();

boolean hasNext = false;

if (content.size() > pageable.getPageSize()) {
content.remove(pageable.getPageSize());
hasNext = true;
}

return new SliceImpl<>(content, pageable, hasNext);
}

@Override
public Long countFindRecruitmentsV2(String keyword, LocalDate startDate,
LocalDate endDate, Boolean isClosed, boolean titleContains, boolean contentContains,
boolean shelterNameContains) {

Long count = query.select(recruitment.count())
.from(recruitment)
.join(recruitment.shelter)
.where(
keywordSearch(keyword, titleContains, contentContains, shelterNameContains),
recruitmentIsClosed(isClosed),
recruitmentStartTimeGoe(startDate),
recruitmentStartTimeLoe(endDate)
).fetchOne();

return count != null ? count : 0;
}

private BooleanExpression cursorId(Long recruitmentId, LocalDateTime createdAt) {
if (recruitmentId == null || createdAt == null) {
return null;
}

return recruitment.createdAt.lt(createdAt)
.or(
recruitment.recruitmentId.lt(recruitmentId)
.and(recruitment.createdAt.eq(createdAt)
)
);
}

private BooleanBuilder keywordSearch(String keyword, boolean titleFilter,
boolean contentFilter, boolean shelterNameFilter) {
return nullSafeBuilder(() -> recruitmentTitleContains(keyword, titleFilter))
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@@ -127,6 +128,44 @@ public FindRecruitmentsResponse findRecruitments(
return FindRecruitmentsResponse.from(recruitments);
}

@Transactional(readOnly = true)
public FindRecruitmentsResponse findRecruitmentsV2(
String keyword,
LocalDate startDate,
LocalDate endDate,
Boolean isClosed,
Boolean titleContains,
Boolean contentContains,
Boolean shelterNameContains,
LocalDateTime createdAt,
Long recruitmentId,
Pageable pageable
) {
Slice<Recruitment> recruitments = recruitmentRepository.findRecruitmentsV2(
keyword,
startDate,
endDate,
isClosed,
titleContains,
contentContains,
shelterNameContains,
createdAt,
recruitmentId,
pageable);

Long count = recruitmentRepository.countFindRecruitmentsV2(
keyword,
startDate,
endDate,
isClosed,
titleContains,
contentContains,
shelterNameContains
);

return FindRecruitmentsResponse.fromV2(recruitments, count);
}

@Transactional
public void closeRecruitment(Long shelterId, Long recruitmentId) {
Recruitment recruitment = getRecruitmentByShelter(shelterId, recruitmentId);
Original file line number Diff line number Diff line change
@@ -199,15 +199,88 @@ void findRecruitments() throws Exception {

//when
ResultActions resultActions = mockMvc.perform(get("/api/recruitments")
.header(AUTHORIZATION, volunteerAccessToken)
.params(params));

//then
resultActions.andExpect(status().isOk())
.andDo(restDocs.document(
requestHeaders(
headerWithName(AUTHORIZATION).description("봉사자 액세스 토큰")
queryParameters(
parameterWithName("keyword").description("검색어").optional(),
parameterWithName("startDate").description("검색 시작일").optional()
.attributes(DocumentationFormatGenerator.getDateConstraint()),
parameterWithName("endDate").description("검색 종료일").optional()
.attributes(DocumentationFormatGenerator.getDateConstraint()),
parameterWithName("closedFilter").description("마감 여부").optional()
.attributes(
DocumentationFormatGenerator.getConstraint("IS_OPENED, IS_CLOSED")),
parameterWithName("keywordFilter").description("검색 필터").optional()
.attributes(DocumentationFormatGenerator.getConstraint(
String.join(", ", Arrays.stream(KeywordFilter.values())
.map(KeywordFilter::name)
.toArray(String[]::new)))),
parameterWithName("pageNumber").description("페이지 번호"),
parameterWithName("pageSize").description("페이지 사이즈")
),
responseFields(
fieldWithPath("recruitments").type(ARRAY).description("봉사 모집글 리스트"),
fieldWithPath("recruitments[].recruitmentId").type(NUMBER)
.description("봉사 모집글 ID"),
fieldWithPath("recruitments[].recruitmentTitle").type(STRING)
.description("봉사 모집글 제목"),
fieldWithPath("recruitments[].recruitmentStartTime").type(STRING)
.description("봉사 시작 시간"),
fieldWithPath("recruitments[].recruitmentEndTime").type(STRING)
.description("봉사 종료 시간"),
fieldWithPath("recruitments[].recruitmentIsClosed").type(BOOLEAN)
.description("봉사 모집 마감 여부"),
fieldWithPath("recruitments[].recruitmentApplicantCount").type(NUMBER)
.description("봉사 신청 인원"),
fieldWithPath("recruitments[].recruitmentCapacity").type(NUMBER)
.description("봉사 정원"),
fieldWithPath("recruitments[].shelterName").type(STRING).description("보호소 이름"),
fieldWithPath("recruitments[].shelterImageUrl").type(STRING)
.description("보호소 이미지 url").optional(),
fieldWithPath("pageInfo").type(OBJECT).description("페이지 정보"),
fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 요소 개수"),
fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지 여부")
)
));
}

@Test
@DisplayName("성공: 봉사 모집글 조회, 검색 V2 API 호출")
void findRecruitmentsV2() throws Exception {
//given
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("keyword", "겅색어");
params.add("startDate", LocalDate.now().toString());
params.add("endDate", LocalDate.now().toString());
params.add("closedFilter", "IS_OPENED");
params.add("keywordFilter", KeywordFilter.IS_CONTENT.getName());
params.add("recruitmentId", "1");
params.add("createdAt", String.valueOf(LocalDateTime.now()));
params.add("pageNumber", "0");
params.add("pageSize", "10");
Shelter shelter = shelter();
Recruitment recruitment = recruitment(shelter);
ReflectionTestUtils.setField(recruitment, "recruitmentId", 1L);
FindRecruitmentResponse findRecruitmentResponse
= FindRecruitmentResponse.from(recruitment);
PageInfo pageInfo = new PageInfo(1, false);
FindRecruitmentsResponse response = new FindRecruitmentsResponse(
List.of(findRecruitmentResponse), pageInfo);

given(recruitmentService.findRecruitmentsV2(anyString(), any(), any(),
any(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyLong(), any()))
.willReturn(response);

//when
ResultActions resultActions = mockMvc.perform(get("/api/v2/recruitments")
.params(params));

//then
resultActions.andExpect(status().isOk())
.andDo(restDocs.document(
queryParameters(
parameterWithName("keyword").description("검색어").optional(),
parameterWithName("startDate").description("검색 시작일").optional()
@@ -222,6 +295,8 @@ void findRecruitments() throws Exception {
String.join(", ", Arrays.stream(KeywordFilter.values())
.map(KeywordFilter::name)
.toArray(String[]::new)))),
parameterWithName("recruitmentId").description("보호소 ID"),
parameterWithName("createdAt").description("보호소 생성 날짜"),
parameterWithName("pageNumber").description("페이지 번호"),
parameterWithName("pageSize").description("페이지 사이즈")
),
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.test.util.ReflectionTestUtils;

class RecruitmentRepositoryTest extends BaseRepositoryTest {
@@ -116,6 +117,57 @@ void findRecruitmentsWhenArgsAreNotNull() {
}
}

@Nested
@DisplayName("findRecruitmentsV2 메서드 실행 시")
class FindRecruitmentsV2Test {

//todo: 다양한 케이스에 대한 테스트를 작성할 것
@Test
@DisplayName("성공: 모든 인자가 null")
void findRecruitmentsV2WhenArgsAreNull() {
//given
Shelter shelter = ShelterFixture.shelter();
Recruitment recruitment = RecruitmentFixture.recruitment(shelter);
PageRequest pageRequest = PageRequest.of(0, 10);
shelterRepository.save(shelter);
recruitmentRepository.save(recruitment);

//when
Slice<Recruitment> recruitments = recruitmentRepository.findRecruitmentsV2(null, null,
null, null, false, false, false, LocalDateTime.now(),
recruitment.getRecruitmentId(),
pageRequest);

//then
assertThat(recruitments.getContent().size()).isEqualTo(1);
}
}

@Nested
@DisplayName("countFindRecruitmentsV2 메서드 실행 시")
class CountFindRecruitmentsV2Test {

//todo: 다양한 케이스에 대한 테스트를 작성할 것
@Test
@DisplayName("성공: 모든 인자가 null")
void countFindRecruitmentsV2WhenArgsAreNull() {
//given
Shelter shelter = ShelterFixture.shelter();
Recruitment recruitment = RecruitmentFixture.recruitment(shelter);
PageRequest pageRequest = PageRequest.of(0, 10);
shelterRepository.save(shelter);
recruitmentRepository.save(recruitment);

//when
Long count = recruitmentRepository.countFindRecruitmentsV2(null, null,
null, null, false, false, false
);

//then
assertThat(count).isEqualTo(1);
}
}

@Nested
@DisplayName("findRecruitmentsByShelterOrderByCreatedAt 메서드 실행 시")
class FindRecruitmentsByShelterOrderByCreatedAtTest {
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@
import com.clova.anifriends.domain.common.PageInfo;
import com.clova.anifriends.domain.common.event.ImageDeletionEvent;
import com.clova.anifriends.domain.recruitment.Recruitment;
import com.clova.anifriends.domain.recruitment.controller.RecruitmentStatusFilter;
import com.clova.anifriends.domain.recruitment.dto.response.FindCompletedRecruitmentsResponse;
import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentDetailResponse;
import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsByShelterIdResponse;
@@ -30,7 +31,6 @@
import com.clova.anifriends.domain.recruitment.exception.RecruitmentNotFoundException;
import com.clova.anifriends.domain.recruitment.repository.RecruitmentRepository;
import com.clova.anifriends.domain.recruitment.support.fixture.RecruitmentFixture;
import com.clova.anifriends.domain.recruitment.controller.RecruitmentStatusFilter;
import com.clova.anifriends.domain.shelter.Shelter;
import com.clova.anifriends.domain.shelter.exception.ShelterNotFoundException;
import com.clova.anifriends.domain.shelter.repository.ShelterRepository;
@@ -51,6 +51,7 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.SliceImpl;
import org.springframework.test.util.ReflectionTestUtils;

@ExtendWith(MockitoExtension.class)
@@ -190,6 +191,61 @@ void findRecruitments() {
}
}

@Nested
@DisplayName("findRecruitmentsV2 실행 시")
class FindRecruitmentsV2Test {

@Test
@DisplayName("성공")
void findRecruitments() {
//give
String keyword = "keyword";
LocalDate startDate = LocalDate.now();
LocalDate endDate = LocalDate.now();
String isClosed = "IS_CLOSED";
boolean title = false;
boolean content = false;
boolean shelterName = false;
LocalDateTime createdAt = LocalDateTime.now();
Long recruitmentId = 1L;
PageRequest pageRequest = PageRequest.of(0, 10);
Shelter shelter = shelter();
Recruitment recruitment = recruitment(shelter);
ReflectionTestUtils.setField(recruitment, "recruitmentId", recruitmentId);
SliceImpl<Recruitment> recruitments = new SliceImpl<>(List.of(recruitment));

given(recruitmentRepository.findRecruitmentsV2(keyword, startDate, endDate,
RecruitmentStatusFilter.valueOf(isClosed).getIsClosed(),
title, content, shelterName, createdAt, recruitmentId, pageRequest)).willReturn(
recruitments);
given(recruitmentRepository.countFindRecruitmentsV2(keyword, startDate, endDate,
RecruitmentStatusFilter.valueOf(isClosed).getIsClosed(),
title, content, shelterName)).willReturn(Long.valueOf(recruitments.getSize()));

//when
FindRecruitmentsResponse recruitmentsByVolunteer
= recruitmentService.findRecruitmentsV2(keyword, startDate, endDate,
RecruitmentStatusFilter.valueOf(isClosed).getIsClosed(), title, content,
shelterName, createdAt, recruitmentId, pageRequest);

//then
PageInfo pageInfo = recruitmentsByVolunteer.pageInfo();
assertThat(pageInfo.totalElements()).isEqualTo(recruitments.getSize());
FindRecruitmentResponse findRecruitment = recruitmentsByVolunteer.recruitments()
.get(0);
assertThat(findRecruitment.recruitmentTitle()).isEqualTo(recruitment.getTitle());
assertThat(findRecruitment.recruitmentStartTime()).isEqualTo(
recruitment.getStartTime());
assertThat(findRecruitment.recruitmentEndTime()).isEqualTo(recruitment.getEndTime());
assertThat(findRecruitment.recruitmentApplicantCount()).isEqualTo(
recruitment.getApplicantCount());
assertThat(findRecruitment.recruitmentCapacity()).isEqualTo(recruitment.getCapacity());
assertThat(findRecruitment.shelterName()).isEqualTo(recruitment.getShelter().getName());
assertThat(findRecruitment.shelterImageUrl())
.isEqualTo(recruitment.getShelter().getImage());
}
}

@Nested
@DisplayName("findRecruitmentsByShelter 메서드 실행 시")
class FindRecruitmentsByShelterTest {

0 comments on commit 21d2659

Please sign in to comment.