diff --git a/src/main/java/es/princip/getp/domain/project/query/dao/AppliedProjectDao.java b/src/main/java/es/princip/getp/domain/project/query/dao/AppliedProjectDao.java new file mode 100644 index 00000000..06f19aae --- /dev/null +++ b/src/main/java/es/princip/getp/domain/project/query/dao/AppliedProjectDao.java @@ -0,0 +1,11 @@ +package es.princip.getp.domain.project.query.dao; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import es.princip.getp.domain.project.query.dto.AppliedProjectCardResponse; + +public interface AppliedProjectDao { + + Page findPagedMyAppliedProjects(Pageable pageable, Long memberId); +} diff --git a/src/main/java/es/princip/getp/domain/project/query/dao/AppliedProjectQueryDslDao.java b/src/main/java/es/princip/getp/domain/project/query/dao/AppliedProjectQueryDslDao.java new file mode 100644 index 00000000..a3ab4944 --- /dev/null +++ b/src/main/java/es/princip/getp/domain/project/query/dao/AppliedProjectQueryDslDao.java @@ -0,0 +1,75 @@ +package es.princip.getp.domain.project.query.dao; + +import com.querydsl.jpa.impl.JPAQuery; +import es.princip.getp.domain.project.command.domain.Project; +import es.princip.getp.domain.project.query.dto.AppliedProjectCardResponse; +import es.princip.getp.infra.support.QueryDslSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; + +import static es.princip.getp.domain.people.command.domain.QPeople.people; +import static es.princip.getp.domain.project.command.domain.QProject.project; +import static es.princip.getp.domain.project.command.domain.QProjectApplication.projectApplication; +import static es.princip.getp.domain.project.query.dao.ProjectDaoUtil.toProjectIds; + +@Repository +@RequiredArgsConstructor +public class AppliedProjectQueryDslDao extends QueryDslSupport implements AppliedProjectDao{ + + private final ProjectApplicationDao projectApplicationDao; + + @Override + public Page findPagedMyAppliedProjects( + final Pageable pageable, + final Long memberId + ) { + final List projects = getAppliedProjects(pageable, memberId); + final Long[] projectIds = toProjectIds(projects); + final Map projectApplicationCounts = projectApplicationDao.countByProjectIds(projectIds); + final List content = assembleAppliedProjectCardResponse(projects, projectApplicationCounts); + + return applyPagination( + pageable, + content, + countQuery -> getAppliedProjectsCountQuery(memberId) + ); + } + + private List getAppliedProjects( + final Pageable pageable, + final Long memberId + ) { + return queryFactory.selectFrom(project) + .join(projectApplication).on(projectApplication.projectId.eq(project.projectId)) + .join(people).on(people.peopleId.eq(projectApplication.applicantId)) + .where(people.memberId.eq(memberId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + private JPAQuery getAppliedProjectsCountQuery(final Long memberId) { + return queryFactory.select(project.count()) + .from(project) + .join(projectApplication).on(projectApplication.projectId.eq(project.projectId)) + .join(people).on(people.peopleId.eq(projectApplication.applicantId)) + .where(people.memberId.eq(memberId)); + } + + private List assembleAppliedProjectCardResponse( + final List projects, + final Map projectApplicationCounts + ) { + return projects.stream() + .map(project -> AppliedProjectCardResponse.of( + project, + projectApplicationCounts.get(project.getProjectId()) + )) + .toList(); + } +} diff --git a/src/main/java/es/princip/getp/domain/project/query/dao/ProjectApplicationQueryDslDao.java b/src/main/java/es/princip/getp/domain/project/query/dao/ProjectApplicationQueryDslDao.java index 27b170c6..3f6fe827 100644 --- a/src/main/java/es/princip/getp/domain/project/query/dao/ProjectApplicationQueryDslDao.java +++ b/src/main/java/es/princip/getp/domain/project/query/dao/ProjectApplicationQueryDslDao.java @@ -1,6 +1,8 @@ package es.princip.getp.domain.project.query.dao; import es.princip.getp.infra.support.QueryDslSupport; +import lombok.RequiredArgsConstructor; + import org.springframework.stereotype.Repository; import java.util.Map; @@ -10,6 +12,7 @@ import static es.princip.getp.domain.project.command.domain.QProjectApplication.projectApplication; @Repository +@RequiredArgsConstructor public class ProjectApplicationQueryDslDao extends QueryDslSupport implements ProjectApplicationDao { @Override diff --git a/src/main/java/es/princip/getp/domain/project/query/dto/AppliedProjectCardResponse.java b/src/main/java/es/princip/getp/domain/project/query/dto/AppliedProjectCardResponse.java new file mode 100644 index 00000000..75966223 --- /dev/null +++ b/src/main/java/es/princip/getp/domain/project/query/dto/AppliedProjectCardResponse.java @@ -0,0 +1,33 @@ +package es.princip.getp.domain.project.query.dto; + +import es.princip.getp.domain.common.domain.Duration; +import es.princip.getp.domain.common.dto.HashtagsResponse; +import es.princip.getp.domain.project.command.domain.Project; +import es.princip.getp.domain.project.command.domain.ProjectStatus; + +public record AppliedProjectCardResponse( + Long projectId, + String title, + Long payment, + Long applicantsCount, + Long estimatedDays, + Duration applicationDuration, + HashtagsResponse hashtags, + String description, + ProjectStatus status +) { + + public static AppliedProjectCardResponse of(final Project project, final Long applicantsCount) { + return new AppliedProjectCardResponse( + project.getProjectId(), + project.getTitle(), + project.getPayment(), + applicantsCount, + project.getEstimatedDuration().days(), + project.getApplicationDuration(), + HashtagsResponse.from(project.getHashtags()), + project.getDescription(), + project.getStatus() + ); + } +} \ No newline at end of file diff --git a/src/main/java/es/princip/getp/domain/project/query/presentation/AppliedProjectQueryController.java b/src/main/java/es/princip/getp/domain/project/query/presentation/AppliedProjectQueryController.java new file mode 100644 index 00000000..996e6d57 --- /dev/null +++ b/src/main/java/es/princip/getp/domain/project/query/presentation/AppliedProjectQueryController.java @@ -0,0 +1,42 @@ +package es.princip.getp.domain.project.query.presentation; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import es.princip.getp.domain.project.query.dao.AppliedProjectDao; +import es.princip.getp.domain.project.query.dto.AppliedProjectCardResponse; +import es.princip.getp.infra.dto.response.ApiResponse; +import es.princip.getp.infra.dto.response.ApiResponse.ApiSuccessResult; +import es.princip.getp.infra.dto.response.PageResponse; +import es.princip.getp.infra.security.details.PrincipalDetails; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; + +@RestController +@RequestMapping("/people") +@RequiredArgsConstructor +public class AppliedProjectQueryController { + + private final AppliedProjectDao appliedProjectDao; + + @GetMapping("/me/projects") + @PreAuthorize("hasRole('PEOPLE') and isAuthenticated()") + public ResponseEntity>> getMyAppliedProjects( + @PageableDefault(sort = "projectId", direction = Sort.Direction.DESC) final Pageable pageable, + @AuthenticationPrincipal final PrincipalDetails principalDetails + ) { + final Long memberId = principalDetails.getMember().getMemberId(); + final Page page = appliedProjectDao.findPagedMyAppliedProjects(pageable, memberId); + final PageResponse response = PageResponse.from(page); + return ApiResponse.success(HttpStatus.OK, response); + } +} diff --git a/src/test/java/es/princip/getp/domain/common/description/PaginationDescription.java b/src/test/java/es/princip/getp/domain/common/description/PaginationDescription.java new file mode 100644 index 00000000..6500d8dc --- /dev/null +++ b/src/test/java/es/princip/getp/domain/common/description/PaginationDescription.java @@ -0,0 +1,17 @@ +package es.princip.getp.domain.common.description; + + +import org.springframework.restdocs.request.ParameterDescriptor; + +import static es.princip.getp.infra.util.ParameterDescriptorHelper.getDescriptor; + +public class PaginationDescription { + + public static ParameterDescriptor[] description(final int page, final int size, final String sort) { + return new ParameterDescriptor[] { + getDescriptor("page", "페이지 번호", "default", String.valueOf(page)), + getDescriptor("size", "페이지 크기", "default", String.valueOf(size)), + getDescriptor("sort", "정렬 방식", "default", sort) + }; + } +} diff --git a/src/test/java/es/princip/getp/domain/people/query/presentation/PeopleQueryControllerTest.java b/src/test/java/es/princip/getp/domain/people/query/presentation/PeopleQueryControllerTest.java index c2b66033..95ee9457 100644 --- a/src/test/java/es/princip/getp/domain/people/query/presentation/PeopleQueryControllerTest.java +++ b/src/test/java/es/princip/getp/domain/people/query/presentation/PeopleQueryControllerTest.java @@ -1,5 +1,6 @@ package es.princip.getp.domain.people.query.presentation; +import es.princip.getp.domain.common.description.PaginationDescription; import es.princip.getp.domain.people.command.domain.PeopleType; import es.princip.getp.domain.people.query.dao.PeopleDao; import es.princip.getp.domain.people.query.dto.people.CardPeopleResponse; @@ -64,8 +65,8 @@ private ResultActions perform() throws Exception { @Test public void getCardPeoplePage() throws Exception { - Pageable pageable = PageRequest.of(page, size, sort); - List content = List.of( + final Pageable pageable = PageRequest.of(page, size, sort); + final List content = List.of( new CardPeopleResponse( 1L, NICKNAME, @@ -80,21 +81,14 @@ public void getCardPeoplePage() throws Exception { ) ) ); - Page page = new PageImpl<>(content, pageable, content.size()); + final Page page = new PageImpl<>(content, pageable, content.size()); given(peopleDao.findCardPeoplePage(any(Pageable.class))).willReturn(page); perform() .andExpect(status().isOk()) .andDo( restDocs.document( - queryParameters( - parameterWithName("page").description("페이지 번호") - .optional().attributes(key("default").value("0")), - parameterWithName("size").description("페이지 크기") - .optional().attributes(key("default").value("10")), - parameterWithName("sort").description("정렬 방식") - .optional().attributes(key("default").value("peopleId,desc")) - ), + queryParameters(PaginationDescription.description(this.page, size, "peopleId,desc")), responseFields( getDescriptor("content[].peopleId", "피플 ID"), getDescriptor("content[].peopleType", "피플 유형") diff --git a/src/test/java/es/princip/getp/domain/project/query/dao/AppliedProjectDaoTest.java b/src/test/java/es/princip/getp/domain/project/query/dao/AppliedProjectDaoTest.java new file mode 100644 index 00000000..4842d342 --- /dev/null +++ b/src/test/java/es/princip/getp/domain/project/query/dao/AppliedProjectDaoTest.java @@ -0,0 +1,70 @@ +package es.princip.getp.domain.project.query.dao; + +import es.princip.getp.domain.people.query.infra.PeopleDataLoader; +import es.princip.getp.domain.project.query.dto.AppliedProjectCardResponse; +import es.princip.getp.domain.project.query.infra.ProjectApplicationDataLoader; +import es.princip.getp.domain.project.query.infra.ProjectDataLoader; +import es.princip.getp.infra.DataLoader; +import es.princip.getp.infra.support.DaoTest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AppliedProjectDaoTest extends DaoTest { + + private static final int TEST_SIZE = 20; + private static final int PAGE_SIZE = 10; + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private AppliedProjectDao appliedProjectDao; + + private List dataLoaders; + + @BeforeEach + void setUp() { + dataLoaders = List.of( + new PeopleDataLoader(entityManager), + new ProjectDataLoader(entityManager), + new ProjectApplicationDataLoader(entityManager) + ); + dataLoaders.forEach(dataLoader -> dataLoader.load(TEST_SIZE)); + } + + @AfterEach + void teardown() { + dataLoaders.forEach(DataLoader::teardown); + } + + @Nested + class 지원한_프로젝트_목록_조회 { + + final Pageable pageable = PageRequest.of(0, PAGE_SIZE); + + @Test + void 프로젝트_목록_조회() { + final Page response = appliedProjectDao.findPagedMyAppliedProjects( + pageable, + 1L + ); + + assertThat(response.getContent()).allSatisfy(content -> { + assertThat(content).usingRecursiveComparison().isNotNull(); + }); + assertThat(response.getNumberOfElements()).isGreaterThan(0); + } + } +} diff --git a/src/test/java/es/princip/getp/domain/project/query/presentation/AppliedProjectQueryControllerTest.java b/src/test/java/es/princip/getp/domain/project/query/presentation/AppliedProjectQueryControllerTest.java new file mode 100644 index 00000000..a9419d2e --- /dev/null +++ b/src/test/java/es/princip/getp/domain/project/query/presentation/AppliedProjectQueryControllerTest.java @@ -0,0 +1,91 @@ +package es.princip.getp.domain.project.query.presentation; + +import es.princip.getp.domain.common.description.PaginationDescription; +import es.princip.getp.domain.common.domain.Duration; +import es.princip.getp.domain.member.command.domain.model.MemberType; +import es.princip.getp.domain.project.command.domain.ProjectStatus; +import es.princip.getp.domain.project.query.dao.AppliedProjectDao; +import es.princip.getp.domain.project.query.dto.AppliedProjectCardResponse; +import es.princip.getp.domain.project.query.presentation.description.AppliedProjectCardResponseDescription; +import es.princip.getp.infra.annotation.WithCustomMockUser; +import es.princip.getp.infra.support.ControllerTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.*; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.util.List; + +import static es.princip.getp.domain.common.fixture.HashtagFixture.hashtagsResponse; +import static es.princip.getp.infra.util.HeaderDescriptorHelper.authorizationHeaderDescriptor; +import static es.princip.getp.infra.util.PageResponseDescriptor.pageResponseFieldDescriptors; +import static es.princip.getp.infra.util.PayloadDocumentationHelper.responseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class AppliedProjectQueryControllerTest extends ControllerTest { + @MockBean + private AppliedProjectDao appliedProjectDao; + + @DisplayName("피플은 자신이 지원한 프로젝트 목록을 조회할 수 있다.") + @Nested + class GetMyAppliedProjects { + + private final int page = 0; + private final int size = 10; + private final Sort sort = Sort.by(Sort.Order.desc("projectId")); + + private ResultActions perform() throws Exception { + return mockMvc.perform(get("/people/me/projects") + .header("Authorization", "Bearer ${ACCESS_TOKEN}") + .queryParam("page", String.valueOf(page)) + .queryParam("size", String.valueOf(size)) + .queryParam("sort", "projectId,desc")); + } + + @Test + @WithCustomMockUser(memberType = MemberType.ROLE_PEOPLE) + public void getMyAppliedProjects() throws Exception { + final Pageable pageable = PageRequest.of(page, size, sort); + final List content = List.of( + new AppliedProjectCardResponse( + 1L, + "프로젝트 제목", + 1_000_000L, + 5L, + 30L, + Duration.of( + LocalDate.of(2024, 7, 1), + LocalDate.of(2024, 7, 31) + ), + hashtagsResponse(), + "프로젝트 설명", + ProjectStatus.APPLYING + ) + ); + final Page page = new PageImpl<>(content, pageable, content.size()); + given(appliedProjectDao.findPagedMyAppliedProjects(any(Pageable.class), anyLong())) + .willReturn(page); + + perform() + .andExpect(status().isOk()) + .andDo( + restDocs.document( + requestHeaders(authorizationHeaderDescriptor()), + queryParameters(PaginationDescription.description(this.page, size, "projectId,desc")), + responseFields(AppliedProjectCardResponseDescription.description()) + .and(pageResponseFieldDescriptors()) + ) + ) + .andDo(print()); + } + } +} diff --git a/src/test/java/es/princip/getp/domain/project/query/presentation/description/AppliedProjectCardResponseDescription.java b/src/test/java/es/princip/getp/domain/project/query/presentation/description/AppliedProjectCardResponseDescription.java new file mode 100644 index 00000000..1d1d6a5d --- /dev/null +++ b/src/test/java/es/princip/getp/domain/project/query/presentation/description/AppliedProjectCardResponseDescription.java @@ -0,0 +1,23 @@ +package es.princip.getp.domain.project.query.presentation.description; + +import org.springframework.restdocs.payload.FieldDescriptor; + +import static es.princip.getp.infra.util.FieldDescriptorHelper.getDescriptor; + +public class AppliedProjectCardResponseDescription { + + public static FieldDescriptor[] description() { + return new FieldDescriptor[] { + getDescriptor("content[].projectId", "프로젝트 ID"), + getDescriptor("content[].title", "제목"), + getDescriptor("content[].payment", "금액"), + getDescriptor("content[].applicantsCount", "지원자 수"), + getDescriptor("content[].estimatedDays", "예상 작업 일수"), + getDescriptor("content[].applicationDuration.startDate", "지원자 모집 시작 기간"), + getDescriptor("content[].applicationDuration.endDate", "지원자 모집 종료 기간"), + getDescriptor("content[].hashtags[]", "해시태그"), + getDescriptor("content[].description", "상세 설명"), + getDescriptor("content[].status", "프로젝트 상태") + }; + } +} diff --git a/src/test/java/es/princip/getp/infra/support/ControllerTest.java b/src/test/java/es/princip/getp/infra/support/ControllerTest.java index 7e888d91..8633a819 100644 --- a/src/test/java/es/princip/getp/infra/support/ControllerTest.java +++ b/src/test/java/es/princip/getp/infra/support/ControllerTest.java @@ -17,13 +17,13 @@ import es.princip.getp.domain.project.command.application.ProjectMeetingService; import es.princip.getp.domain.project.command.presentation.ProjectCommandMapper; import es.princip.getp.domain.project.query.application.ProjectApplicantService; +import es.princip.getp.domain.project.query.dao.AppliedProjectDao; import es.princip.getp.domain.project.query.dao.MyCommissionedProjectDao; import es.princip.getp.domain.project.query.dao.ProjectDao; import es.princip.getp.domain.serviceTerm.application.ServiceTermService; import es.princip.getp.infra.config.SecurityConfig; import es.princip.getp.infra.config.SecurityTestConfig; import es.princip.getp.infra.config.SpringRestDocsConfig; -import es.princip.getp.infra.exception.ErrorCode; import es.princip.getp.infra.storage.application.FileUploadService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; @@ -43,7 +43,6 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; @@ -77,7 +76,8 @@ ProjectApplicantService.class, ProjectDao.class, ServiceTermService.class, - FileUploadService.class + FileUploadService.class, + AppliedProjectDao.class }) @Execution(ExecutionMode.SAME_THREAD) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -114,10 +114,6 @@ protected MockMultipartHttpServletRequestBuilder multipart(final String uri, fin .contextPath(contextPath); } - protected static ResultMatcher errorCode(final ErrorCode errorCode) { - return status().is(errorCode.status().value()); - } - @Autowired protected RestDocumentationResultHandler restDocs; @@ -132,7 +128,7 @@ void setUp(final WebApplicationContext context, final RestDocumentationContextPr this.mockMvc = MockMvcBuilders.webAppContextSetup(context) .apply(documentationConfiguration(restDocumentation).uris() .withScheme("https") - .withHost("api.princip.es") + .withHost("api.principes.xyz") .withPort(443) ) .alwaysDo(MockMvcResultHandlers.print()) diff --git a/src/test/java/es/princip/getp/infra/util/ParameterDescriptorHelper.java b/src/test/java/es/princip/getp/infra/util/ParameterDescriptorHelper.java new file mode 100644 index 00000000..39f205a0 --- /dev/null +++ b/src/test/java/es/princip/getp/infra/util/ParameterDescriptorHelper.java @@ -0,0 +1,20 @@ +package es.princip.getp.infra.util; + +import org.springframework.restdocs.request.ParameterDescriptor; + +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.snippet.Attributes.key; + +public class ParameterDescriptorHelper { + + public static ParameterDescriptor getDescriptor( + final String name, + final String description, + final String key, + final String value + ) { + return parameterWithName(name).description(description) + .optional() + .attributes(key(key).value(value)); + } +}