diff --git a/build.gradle b/build.gradle index ebb137d36..935966b7a 100644 --- a/build.gradle +++ b/build.gradle @@ -115,6 +115,7 @@ def jacocoExcludePatterns = [ '**/auth/**', '**/domain/*', '**/domains/*', + '**/*Fixture.class' ] def jacocoExcludePatternsForVerify = [ @@ -129,6 +130,7 @@ def jacocoExcludePatternsForVerify = [ '*.auth.*', '*.domain.*', '*.domains.*', + '*.*Fixture*' ] jacocoTestReport { diff --git a/src/main/java/com/somemore/center/dto/response/CenterInfoResponse.java b/src/main/java/com/somemore/center/dto/response/CenterInfoResponse.java new file mode 100644 index 000000000..c96bc74e2 --- /dev/null +++ b/src/main/java/com/somemore/center/dto/response/CenterInfoResponse.java @@ -0,0 +1,33 @@ +package com.somemore.center.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.center.domain.Center; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; +import lombok.Builder; + +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "기관 정보 응답 DTO") +public record CenterInfoResponse( + @Schema(description = "기관 아이디", example = "123e4567-e89b-12d3-a456-426614174000") + UUID id, + @Schema(description = "기관 이름", example = "환경 봉사 센터") + String name +) { + + public static CenterInfoResponse from(Center center) { + return CenterInfoResponse.builder() + .id(center.getId()) + .name(center.getName()) + .build(); + } + + public static CenterInfoResponse of(UUID centerId, String name) { + return CenterInfoResponse.builder() + .id(centerId) + .name(name) + .build(); + } +} diff --git a/src/main/java/com/somemore/global/common/utils/GeoUtils.java b/src/main/java/com/somemore/global/common/utils/GeoUtils.java new file mode 100644 index 000000000..3b83f8d5d --- /dev/null +++ b/src/main/java/com/somemore/global/common/utils/GeoUtils.java @@ -0,0 +1,24 @@ +package com.somemore.global.common.utils; + +public class GeoUtils { + + private static final double EARTH_RADIUS = 6371.0; + + public static double[] calculateMaxMinCoordinates(double latitude, double longitude, + double radius) { + double latRad = Math.toRadians(latitude); + double latDiff = radius / EARTH_RADIUS; + double maxLatRad = latRad + latDiff; + double minLatRad = latRad - latDiff; + + double maxLat = Math.toDegrees(maxLatRad); + double minLat = Math.toDegrees(minLatRad); + + double lonDiff = radius / (EARTH_RADIUS * Math.cos(latRad)); + double maxLon = longitude + Math.toDegrees(lonDiff); + double minLon = longitude - Math.toDegrees(lonDiff); + + return new double[]{minLat, minLon, maxLat, maxLon}; + } + +} diff --git a/src/main/java/com/somemore/global/exception/ExceptionMessage.java b/src/main/java/com/somemore/global/exception/ExceptionMessage.java index 1e5b3f8b2..4038436e1 100644 --- a/src/main/java/com/somemore/global/exception/ExceptionMessage.java +++ b/src/main/java/com/somemore/global/exception/ExceptionMessage.java @@ -14,12 +14,12 @@ public enum ExceptionMessage { NOT_EXISTS_COMMUNITY_COMMENT("존재하지 않는 댓글 입니다."), NOT_EXISTS_LOCATION("존재하지 않는 위치 ID 입니다."), NOT_EXISTS_RECRUIT_BOARD("존재하지 않는 봉사 모집글 ID 입니다."), - UNAUTHORIZED_RECRUIT_BOARD("자신이 작성한 봉사 모집글이 아닙니다."), + UNAUTHORIZED_RECRUIT_BOARD("해당 봉사 모집글에 권한이 없습니다."), UPLOAD_FAILED("파일 업로드에 실패했습니다."), INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."), EMPTY_FILE("파일이 존재하지 않습니다."), - INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다.") + INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다."), ; private final String message; diff --git a/src/main/java/com/somemore/location/dto/response/LocationResponseDto.java b/src/main/java/com/somemore/location/dto/response/LocationResponseDto.java new file mode 100644 index 000000000..204a15ff2 --- /dev/null +++ b/src/main/java/com/somemore/location/dto/response/LocationResponseDto.java @@ -0,0 +1,38 @@ +package com.somemore.location.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.location.domain.Location; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import lombok.Builder; + +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "위치 조회 응답 DTO") +public record LocationResponseDto( + @Schema(description = "주소", example = "서울특별시 강남구 테헤란로 123") + String address, + @Schema(description = "위도", example = "37.5665") + BigDecimal latitude, + @Schema(description = "경도", example = "126.9780") + BigDecimal longitude +) { + + public static LocationResponseDto from(Location location) { + return LocationResponseDto.builder() + .address(location.getAddress()) + .latitude(location.getLatitude()) + .longitude(location.getLongitude()) + .build(); + } + + public static LocationResponseDto of(String address, BigDecimal latitude, + BigDecimal longitude) { + return LocationResponseDto.builder() + .address(address) + .latitude(latitude) + .longitude(longitude) + .build(); + } +} diff --git a/src/main/java/com/somemore/recruitboard/controller/RecruitBoardQueryController.java b/src/main/java/com/somemore/recruitboard/controller/RecruitBoardQueryController.java new file mode 100644 index 000000000..cf41d5cce --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/controller/RecruitBoardQueryController.java @@ -0,0 +1,127 @@ +package com.somemore.recruitboard.controller; + +import static org.springframework.data.domain.Sort.Direction.DESC; + +import com.somemore.global.common.response.ApiResponse; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.VolunteerType; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import com.somemore.recruitboard.dto.response.RecruitBoardDetailResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithCenterResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithLocationResponseDto; +import com.somemore.recruitboard.usecase.query.RecruitBoardQueryUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Recruit Board Query API", description = "봉사 활동 모집 조회 관련 API") +@RequiredArgsConstructor +@RequestMapping("/api") +@RestController +public class RecruitBoardQueryController { + + private final RecruitBoardQueryUseCase recruitBoardQueryUseCase; + + @GetMapping("/recruit-board/{id}") + @Operation(summary = "봉사 모집글 상세 조회", description = "특정 모집글의 상세 정보를 조회합니다.") + public ApiResponse getById( + @PathVariable Long id + ) { + return ApiResponse.ok( + 200, + recruitBoardQueryUseCase.getWithLocationById(id), + "봉사 활동 모집 상세 조회 성공" + ); + } + + @GetMapping("/recruit-boards") + @Operation(summary = "전체 모집글 조회", description = "모든 봉사 모집글 목록을 조회합니다.") + public ApiResponse> getAll( + @PageableDefault(size = 10, page = 0, sort = "created_at", direction = DESC) + Pageable pageable + ) { + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .pageable(pageable) + .build(); + + return ApiResponse.ok( + 200, + recruitBoardQueryUseCase.getAllWithCenter(condition), + "봉사 활동 모집글 리스트 조회 성공" + ); + } + + @GetMapping("/recruit-boards/search") + @Operation(summary = "모집글 검색 조회", description = "검색 조건을 기반으로 모집글을 조회합니다.") + public ApiResponse> getAllBySearch( + @PageableDefault(size = 10, page = 0, sort = "created_at", direction = DESC) Pageable pageable, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) VolunteerType type, + @RequestParam(required = false) String region, + @RequestParam(required = false) Boolean admitted, + @RequestParam(required = false) RecruitStatus status + ) { + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .keyword(keyword) + .type(type) + .region(region) + .admitted(admitted) + .status(status) + .pageable(pageable) + .build(); + + return ApiResponse.ok( + 200, + recruitBoardQueryUseCase.getAllWithCenter(condition), + "봉사 활동 모집글 검색 조회 성공" + ); + } + + @GetMapping("/recruit-boards/nearby") + @Operation(summary = "근처 모집글 조회", description = "주변 반경 내의 봉사 모집글을 조회합니다.") + public ApiResponse> getNearby( + @RequestParam double latitude, + @RequestParam double longitude, + @RequestParam(required = false, defaultValue = "5") double radius, + @RequestParam(required = false) String keyword, + @PageableDefault(sort = "created_at", direction = DESC) Pageable pageable + ) { + RecruitBoardNearByCondition condition = RecruitBoardNearByCondition.builder() + .latitude(latitude) + .longitude(longitude) + .radius(radius) + .keyword(keyword) + .pageable(pageable) + .build(); + + return ApiResponse.ok( + 200, + recruitBoardQueryUseCase.getRecruitBoardsNearby(condition), + "근처 봉사 활동 모집글 조회 성공" + ); + } + + @GetMapping("/recruit-boards/center/{centerId}") + @Operation(summary = "특정 기관 모집글 조회", description = "특정 기관의 봉사 모집글을 조회합니다.") + public ApiResponse> getRecruitBoardsByCenterId( + @PathVariable UUID centerId, + @PageableDefault(sort = "created_at", direction = DESC) Pageable pageable + ) { + return ApiResponse.ok( + 200, + recruitBoardQueryUseCase.getRecruitBoardsByCenterId(centerId, pageable), + "기관 봉사 활동 모집글 조회 성공" + ); + } +} diff --git a/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardDetail.java b/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardDetail.java new file mode 100644 index 000000000..8338b4fab --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardDetail.java @@ -0,0 +1,14 @@ +package com.somemore.recruitboard.domain.mapping; + +import com.somemore.recruitboard.domain.RecruitBoard; +import java.math.BigDecimal; + +public record RecruitBoardDetail( + RecruitBoard recruitBoard, + String address, + BigDecimal latitude, + BigDecimal longitude, + String centerName +) { + +} diff --git a/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardWithCenter.java b/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardWithCenter.java new file mode 100644 index 000000000..45e356ca9 --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardWithCenter.java @@ -0,0 +1,10 @@ +package com.somemore.recruitboard.domain.mapping; + +import com.somemore.recruitboard.domain.RecruitBoard; + +public record RecruitBoardWithCenter( + RecruitBoard recruitBoard, + String centerName +) { + +} diff --git a/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardWithLocation.java b/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardWithLocation.java new file mode 100644 index 000000000..5a3140e7c --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/domain/mapping/RecruitBoardWithLocation.java @@ -0,0 +1,13 @@ +package com.somemore.recruitboard.domain.mapping; + +import com.somemore.recruitboard.domain.RecruitBoard; +import java.math.BigDecimal; + +public record RecruitBoardWithLocation( + RecruitBoard recruitBoard, + String address, + BigDecimal latitude, + BigDecimal longitude +) { + +} diff --git a/src/main/java/com/somemore/recruitboard/dto/condition/RecruitBoardNearByCondition.java b/src/main/java/com/somemore/recruitboard/dto/condition/RecruitBoardNearByCondition.java new file mode 100644 index 000000000..7091b7f6b --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/dto/condition/RecruitBoardNearByCondition.java @@ -0,0 +1,15 @@ +package com.somemore.recruitboard.dto.condition; + +import lombok.Builder; +import org.springframework.data.domain.Pageable; + +@Builder +public record RecruitBoardNearByCondition( + Double latitude, + Double longitude, + Double radius, + String keyword, + Pageable pageable +) { + +} diff --git a/src/main/java/com/somemore/recruitboard/dto/condition/RecruitBoardSearchCondition.java b/src/main/java/com/somemore/recruitboard/dto/condition/RecruitBoardSearchCondition.java new file mode 100644 index 000000000..5f3e8c775 --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/dto/condition/RecruitBoardSearchCondition.java @@ -0,0 +1,18 @@ +package com.somemore.recruitboard.dto.condition; + +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.VolunteerType; +import lombok.Builder; +import org.springframework.data.domain.Pageable; + +@Builder +public record RecruitBoardSearchCondition( + String keyword, + VolunteerType type, + String region, + Boolean admitted, + RecruitStatus status, + Pageable pageable +) { + +} diff --git a/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardDetailResponseDto.java b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardDetailResponseDto.java new file mode 100644 index 000000000..e43ae76cd --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardDetailResponseDto.java @@ -0,0 +1,85 @@ +package com.somemore.recruitboard.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.center.dto.response.CenterInfoResponse; +import com.somemore.location.dto.response.LocationResponseDto; +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.domain.VolunteerType; +import com.somemore.recruitboard.domain.mapping.RecruitBoardDetail; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.time.LocalTime; +import lombok.Builder; + + +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "봉사 모집글 기관 및 위치 포함 응답 DTO") +public record RecruitBoardDetailResponseDto( + @Schema(description = "봉사 모집글 ID", example = "123") + Long id, + @Schema(description = "모집글 생성 일시", example = "2024-12-01T09:00:00") + LocalDateTime createdAt, + @Schema(description = "모집글 수정 일시", example = "2024-12-01T09:00:00") + LocalDateTime updatedAt, + @Schema(description = "모집글 제목", example = "환경 정화 봉사") + String title, + @Schema(description = "모집글 내용", example = "도시 공원에서 환경 정화 활동") + String content, + @Schema(description = "지역 정보", example = "서울특별시") + String region, + @Schema(description = "모집 상태", example = "RECRUITING") + RecruitStatus recruitStatus, + @Schema(description = "모집 인원 수", example = "15") + Integer recruitmentCount, + @Schema(description = "봉사 시작 일시", example = "2024-12-01T09:00:00") + LocalDateTime volunteerStartDateTime, + @Schema(description = "봉사 종료 일시", example = "2024-12-01T13:00:00") + LocalDateTime volunteerEndDateTime, + @Schema(description = "봉사 유형", example = "LIVING_SUPPORT") + VolunteerType volunteerType, + @Schema(description = "봉사 시간", example = "04:00:00") + LocalTime volunteerTime, + @Schema(description = "시간 인정 여부", example = "true") + Boolean admitted, + @Schema(description = "이미지 URL", example = "https://image.domain.com/links") + String imgUrl, + @Schema(description = "센터 간단 정보") + CenterInfoResponse center, + @Schema(description = "위치 정보 DTO") + LocationResponseDto location +) { + + public static RecruitBoardDetailResponseDto from(RecruitBoardDetail recruitBoardDetail) { + RecruitBoard board = recruitBoardDetail.recruitBoard(); + RecruitmentInfo info = board.getRecruitmentInfo(); + CenterInfoResponse center = CenterInfoResponse.of(board.getCenterId(), + recruitBoardDetail.centerName()); + LocationResponseDto location = LocationResponseDto.of( + recruitBoardDetail.address(), recruitBoardDetail.latitude(), + recruitBoardDetail.longitude()); + + return RecruitBoardDetailResponseDto.builder() + .id(board.getId()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .title(board.getTitle()) + .content(board.getContent()) + .region(info.getRegion()) + .recruitStatus(board.getRecruitStatus()) + .recruitmentCount(info.getRecruitmentCount()) + .volunteerStartDateTime(info.getVolunteerStartDateTime()) + .volunteerEndDateTime(info.getVolunteerEndDateTime()) + .volunteerType(info.getVolunteerType()) + .volunteerTime(info.calculateVolunteerTime()) + .admitted(info.getAdmitted()) + .imgUrl(board.getImgUrl()) + .location(location) + .center(center) + .build(); + } + +} diff --git a/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardResponseDto.java b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardResponseDto.java new file mode 100644 index 000000000..8b57a1b24 --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardResponseDto.java @@ -0,0 +1,74 @@ +package com.somemore.recruitboard.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.domain.VolunteerType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.UUID; +import lombok.Builder; + +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "봉사 모집글 응답 DTO") +public record RecruitBoardResponseDto( + @Schema(description = "봉사 모집글 ID", example = "123") + Long id, + @Schema(description = "센터 ID", example = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d") + UUID centerId, + @Schema(description = "위치 ID", example = "1") + Long locationId, + @Schema(description = "모집글 생성 일시", example = "2024-12-01T09:00:00") + LocalDateTime createdAt, + @Schema(description = "모집글 수정 일시", example = "2024-12-01T09:00:00") + LocalDateTime updatedAt, + @Schema(description = "모집글 제목", example = "환경 정화 봉사") + String title, + @Schema(description = "모집글 내용", example = "도시 공원에서 환경 정화 활동") + String content, + @Schema(description = "지역 정보", example = "서울특별시") + String region, + @Schema(description = "모집 상태", example = "RECRUITING") + RecruitStatus recruitStatus, + @Schema(description = "모집 인원 수", example = "15") + Integer recruitmentCount, + @Schema(description = "봉사 시작 일시", example = "2024-12-01T09:00:00") + LocalDateTime volunteerStartDateTime, + @Schema(description = "봉사 종료 일시", example = "2024-12-01T13:00:00") + LocalDateTime volunteerEndDateTime, + @Schema(description = "봉사 유형", example = "LIVING_SUPPORT") + VolunteerType volunteerType, + @Schema(description = "봉사 시간", example = "04:00:00") + LocalTime volunteerTime, + @Schema(description = "시간 인정 여부", example = "true") + Boolean admitted, + @Schema(description = "이미지 URL", example = "https://image.domain.com/links") + String imgUrl +) { + + public static RecruitBoardResponseDto from(RecruitBoard board) { + RecruitmentInfo info = board.getRecruitmentInfo(); + + return RecruitBoardResponseDto.builder() + .id(board.getId()) + .centerId(board.getCenterId()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .title(board.getTitle()) + .content(board.getContent()) + .region(info.getRegion()) + .recruitStatus(board.getRecruitStatus()) + .recruitmentCount(info.getRecruitmentCount()) + .volunteerStartDateTime(info.getVolunteerStartDateTime()) + .volunteerEndDateTime(info.getVolunteerEndDateTime()) + .volunteerType(info.getVolunteerType()) + .volunteerTime(info.calculateVolunteerTime()) + .admitted(info.getAdmitted()) + .imgUrl(board.getImgUrl()) + .build(); + } +} diff --git a/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardWithCenterResponseDto.java b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardWithCenterResponseDto.java new file mode 100644 index 000000000..edc64afba --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardWithCenterResponseDto.java @@ -0,0 +1,79 @@ +package com.somemore.recruitboard.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.center.dto.response.CenterInfoResponse; +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.domain.VolunteerType; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithCenter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.time.LocalTime; +import lombok.Builder; + +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "봉사 모집글 기관 포함 응답 DTO") +public record RecruitBoardWithCenterResponseDto( + @Schema(description = "봉사 모집글 ID", example = "123") + Long id, + @Schema(description = "위치 ID", example = "1") + Long locationId, + @Schema(description = "모집글 생성 일시", example = "2024-12-01T09:00:00") + LocalDateTime createdAt, + @Schema(description = "모집글 수정 일시", example = "2024-12-01T09:00:00") + LocalDateTime updatedAt, + @Schema(description = "모집글 제목", example = "환경 정화 봉사") + String title, + @Schema(description = "모집글 내용", example = "도시 공원에서 환경 정화 활동") + String content, + @Schema(description = "지역 정보", example = "서울특별시") + String region, + @Schema(description = "모집 상태", example = "RECRUITING") + RecruitStatus recruitStatus, + @Schema(description = "모집 인원 수", example = "15") + Integer recruitmentCount, + @Schema(description = "봉사 시작 일시", example = "2024-12-01T09:00:00") + LocalDateTime volunteerStartDateTime, + @Schema(description = "봉사 종료 일시", example = "2024-12-01T13:00:00") + LocalDateTime volunteerEndDateTime, + @Schema(description = "봉사 유형", example = "LIVING_SUPPORT") + VolunteerType volunteerType, + @Schema(description = "봉사 시간", example = "04:00:00") + LocalTime volunteerTime, + @Schema(description = "시간 인정 여부", example = "true") + Boolean admitted, + @Schema(description = "이미지 URL", example = "https://image.domain.com/links") + String imgUrl, + @Schema(description = "센터 간단 정보") + CenterInfoResponse center +) { + + public static RecruitBoardWithCenterResponseDto from( + RecruitBoardWithCenter recruitBoardWithCenter) { + RecruitBoard board = recruitBoardWithCenter.recruitBoard(); + RecruitmentInfo info = board.getRecruitmentInfo(); + + return RecruitBoardWithCenterResponseDto.builder() + .id(board.getId()) + .locationId(board.getLocationId()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .title(board.getTitle()) + .content(board.getContent()) + .region(info.getRegion()) + .recruitStatus(board.getRecruitStatus()) + .recruitmentCount(info.getRecruitmentCount()) + .volunteerStartDateTime(info.getVolunteerStartDateTime()) + .volunteerEndDateTime(info.getVolunteerEndDateTime()) + .volunteerType(info.getVolunteerType()) + .volunteerTime(info.calculateVolunteerTime()) + .admitted(info.getAdmitted()) + .imgUrl(board.getImgUrl()) + .center(CenterInfoResponse.of(board.getCenterId(), recruitBoardWithCenter.centerName())) + .build(); + } +} + diff --git a/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardWithLocationResponseDto.java b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardWithLocationResponseDto.java new file mode 100644 index 000000000..aab38a873 --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/dto/response/RecruitBoardWithLocationResponseDto.java @@ -0,0 +1,83 @@ +package com.somemore.recruitboard.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.location.dto.response.LocationResponseDto; +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.domain.VolunteerType; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithLocation; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.UUID; +import lombok.Builder; + +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "봉사 모집글 위치 포함 응답 DTO") +public record RecruitBoardWithLocationResponseDto( + @Schema(description = "봉사 모집글 ID", example = "123") + Long id, + @Schema(description = "기관 ID", example = "123e4567-e89b-12d3-a456-426614174000") + UUID centerId, + @Schema(description = "모집글 생성 일시", example = "2024-12-01T09:00:00") + LocalDateTime createdAt, + @Schema(description = "모집글 수정 일시", example = "2024-12-01T09:00:00") + LocalDateTime updatedAt, + @Schema(description = "모집글 제목", example = "환경 정화 봉사") + String title, + @Schema(description = "모집글 내용", example = "도시 공원에서 환경 정화 활동") + String content, + @Schema(description = "지역 정보", example = "서울특별시") + String region, + @Schema(description = "모집 상태", example = "RECRUITING") + RecruitStatus recruitStatus, + @Schema(description = "모집 인원 수", example = "15") + Integer recruitmentCount, + @Schema(description = "봉사 시작 일시", example = "2024-12-01T09:00:00") + LocalDateTime volunteerStartDateTime, + @Schema(description = "봉사 종료 일시", example = "2024-12-01T13:00:00") + LocalDateTime volunteerEndDateTime, + @Schema(description = "봉사 유형", example = "LIVING_SUPPORT") + VolunteerType volunteerType, + @Schema(description = "봉사 시간", example = "04:00:00") + LocalTime volunteerTime, + @Schema(description = "시간 인정 여부", example = "true") + Boolean admitted, + @Schema(description = "이미지 URL", example = "https://image.domain.com/links") + String imgUrl, + @Schema(description = "위치 정보 DTO") + LocationResponseDto location +) { + + public static RecruitBoardWithLocationResponseDto from( + RecruitBoardWithLocation recruitBoardWithLocation) { + RecruitBoard board = recruitBoardWithLocation.recruitBoard(); + RecruitmentInfo info = board.getRecruitmentInfo(); + + return RecruitBoardWithLocationResponseDto.builder() + .id(board.getId()) + .centerId(board.getCenterId()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .title(board.getTitle()) + .content(board.getContent()) + .region(info.getRegion()) + .recruitStatus(board.getRecruitStatus()) + .recruitmentCount(info.getRecruitmentCount()) + .volunteerStartDateTime(info.getVolunteerStartDateTime()) + .volunteerEndDateTime(info.getVolunteerEndDateTime()) + .volunteerType(info.getVolunteerType()) + .volunteerTime(info.calculateVolunteerTime()) + .admitted(info.getAdmitted()) + .imgUrl(board.getImgUrl()) + .location( + LocationResponseDto.of(recruitBoardWithLocation.address(), + recruitBoardWithLocation.latitude(), + recruitBoardWithLocation.longitude())) + .build(); + } + +} diff --git a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java index ffc340b6c..e3f243e41 100644 --- a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java +++ b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java @@ -1,16 +1,30 @@ package com.somemore.recruitboard.repository; import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.mapping.RecruitBoardDetail; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithCenter; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithLocation; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import java.util.List; import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface RecruitBoardRepository { - RecruitBoard save(RecruitBoard recruitBoard); - RecruitBoard saveAndFlush(RecruitBoard recruitBoard); + List saveAll(List recruitBoards); Optional findById(Long id); - void deleteAllInBatch(); + Optional findWithLocationById(Long id); + + Page findAllWithCenter(RecruitBoardSearchCondition condition); + + Page findAllNearby(RecruitBoardNearByCondition condition); + + Page findAllByCenterId(UUID centerId, Pageable pageable); } diff --git a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java index 3db97c1b6..c02f49fa4 100644 --- a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java +++ b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java @@ -1,11 +1,35 @@ package com.somemore.recruitboard.repository; +import static com.somemore.location.domain.QLocation.location; +import static com.somemore.recruitboard.domain.QRecruitBoard.recruitBoard; + +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.somemore.center.domain.QCenter; +import com.somemore.global.common.utils.GeoUtils; +import com.somemore.location.domain.QLocation; import com.somemore.recruitboard.domain.QRecruitBoard; import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.VolunteerType; +import com.somemore.recruitboard.domain.mapping.RecruitBoardDetail; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithCenter; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithLocation; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import java.util.List; import java.util.Optional; +import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; @RequiredArgsConstructor @@ -21,8 +45,8 @@ public RecruitBoard save(RecruitBoard recruitBoard) { } @Override - public RecruitBoard saveAndFlush(RecruitBoard recruitBoard) { - return recruitBoardJpaRepository.saveAndFlush(recruitBoard); + public List saveAll(List recruitBoards) { + return recruitBoardJpaRepository.saveAll(recruitBoards); } @Override @@ -31,19 +55,194 @@ public Optional findById(Long id) { RecruitBoard result = queryFactory .selectFrom(recruitBoard) - .where(isNotDeleted().and(recruitBoard.id.eq(id))) + .where(isNotDeleted(recruitBoard).and(recruitBoard.id.eq(id))) .fetchOne(); return Optional.ofNullable(result); } - + + @Override + public Optional findWithLocationById(Long id) { + QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; + QLocation location = QLocation.location; + + return Optional.ofNullable( + queryFactory.select( + getRecruitBoardWithLocationConstructorExpression(recruitBoard, location)) + .from(recruitBoard) + .join(location).on(recruitBoard.locationId.eq(location.id)) + .where(isNotDeleted(recruitBoard).and(recruitBoard.id.eq(id))) + .fetchOne()); + } + @Override - public void deleteAllInBatch() { - recruitBoardJpaRepository.deleteAllInBatch(); + public Page findAllWithCenter(RecruitBoardSearchCondition condition) { + QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; + QCenter center = QCenter.center; + + Pageable pageable = condition.pageable(); + BooleanExpression predicate = isNotDeleted(recruitBoard) + .and(keywordEq(condition.keyword())) + .and(volunteerTypeEq(condition.type())) + .and(regionEq(condition.region())) + .and(admittedEq(condition.admitted())) + .and(statusEq(condition.status())); + + List content = queryFactory + .select(getRecruitBoardWithCenterConstructorExpression(recruitBoard, center)) + .from(recruitBoard) + .where(predicate) + .join(center).on(recruitBoard.centerId.eq(center.id)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(toOrderSpecifiers(pageable.getSort())) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(recruitBoard.count()) + .from(recruitBoard) + .where(predicate); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } - private BooleanExpression isNotDeleted() { + @Override + public Page findAllNearby(RecruitBoardNearByCondition condition) { QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; + QLocation location = QLocation.location; + QCenter center = QCenter.center; + + Pageable pageable = condition.pageable(); + + BooleanExpression predicate = isNotDeleted(recruitBoard) + .and(locationBetween(condition)) + .and(keywordEq(condition.keyword())); + + List content = queryFactory + .select(getRecruitBoardDetailConstructorExpression(recruitBoard, location, center)) + .from(recruitBoard) + .join(location).on(recruitBoard.locationId.eq(location.id)) + .join(center).on(recruitBoard.centerId.eq(center.id)) + .where(predicate) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(toOrderSpecifiers(pageable.getSort())) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(recruitBoard.count()) + .from(recruitBoard) + .join(location).on(recruitBoard.locationId.eq(location.id)) + .join(center).on(recruitBoard.centerId.eq(center.id)) + .where(predicate); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + @Override + public Page findAllByCenterId(UUID centerId, Pageable pageable) { + QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; + BooleanExpression predicate = isNotDeleted(recruitBoard) + .and(recruitBoard.centerId.eq(centerId)); + + List content = queryFactory + .selectFrom(recruitBoard) + .where(predicate) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(toOrderSpecifiers(pageable.getSort())) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(recruitBoard.count()) + .from(recruitBoard) + .where(predicate); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + private BooleanExpression isNotDeleted(QRecruitBoard recruitBoard) { return recruitBoard.deleted.eq(false); } + + private static ConstructorExpression getRecruitBoardWithCenterConstructorExpression( + QRecruitBoard recruitBoard, QCenter center) { + return Projections.constructor(RecruitBoardWithCenter.class, + recruitBoard, center.name); + } + + private static ConstructorExpression getRecruitBoardWithLocationConstructorExpression( + QRecruitBoard recruitBoard, QLocation location) { + return Projections.constructor(RecruitBoardWithLocation.class, + recruitBoard, location.address, location.latitude, location.longitude); + } + + private static ConstructorExpression getRecruitBoardDetailConstructorExpression( + QRecruitBoard recruitBoard, QLocation location, QCenter center) { + return Projections.constructor(RecruitBoardDetail.class, + recruitBoard, location.address, location.latitude, location.longitude, center.name); + } + + private OrderSpecifier[] toOrderSpecifiers(Sort sort) { + QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; + + return sort.stream() + .map(order -> { + String property = order.getProperty(); + + if ("created_at".equals(property)) { + return order.isAscending() + ? recruitBoard.createdAt.asc() + : recruitBoard.createdAt.desc(); + } else if ("volunteer_start_date_time".equals(property)) { + return order.isAscending() + ? recruitBoard.recruitmentInfo.volunteerStartDateTime.asc() + : recruitBoard.recruitmentInfo.volunteerStartDateTime.desc(); + } else { + throw new IllegalStateException("Invalid sort property: " + property); + } + }) + .toArray(OrderSpecifier[]::new); + } + + private BooleanExpression keywordEq(String keyword) { + return StringUtils.isNotBlank(keyword) + ? recruitBoard.title.containsIgnoreCase( + keyword) : null; + } + + private BooleanExpression volunteerTypeEq(VolunteerType type) { + return type != null ? recruitBoard.recruitmentInfo.volunteerType.eq(type) + : null; + } + + private BooleanExpression regionEq(String region) { + return StringUtils.isNotBlank(region) + ? recruitBoard.recruitmentInfo.region.eq( + region) : null; + } + + private BooleanExpression admittedEq(Boolean admitted) { + return admitted != null ? recruitBoard.recruitmentInfo.admitted.eq(admitted) + : null; + } + + private BooleanExpression statusEq(RecruitStatus status) { + return status != null ? recruitBoard.recruitStatus.eq(status) : null; + } + + private BooleanExpression locationBetween(RecruitBoardNearByCondition condition) { + double[] coordinates = GeoUtils.calculateMaxMinCoordinates( + condition.latitude(), + condition.longitude(), + condition.radius()); + + double minLatitude = coordinates[0]; + double minLongitude = coordinates[1]; + double maxLatitude = coordinates[2]; + double maxLongitude = coordinates[3]; + + return location.latitude.between(minLatitude, maxLatitude) + .and(location.longitude.between(minLongitude, maxLongitude)); + } } diff --git a/src/main/java/com/somemore/recruitboard/service/query/RecruitBoardQueryService.java b/src/main/java/com/somemore/recruitboard/service/query/RecruitBoardQueryService.java index e3c9dffd1..77c9e7987 100644 --- a/src/main/java/com/somemore/recruitboard/service/query/RecruitBoardQueryService.java +++ b/src/main/java/com/somemore/recruitboard/service/query/RecruitBoardQueryService.java @@ -1,10 +1,24 @@ package com.somemore.recruitboard.service.query; +import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_RECRUIT_BOARD; + +import com.somemore.global.exception.BadRequestException; import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.mapping.RecruitBoardDetail; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithCenter; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithLocation; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import com.somemore.recruitboard.dto.response.RecruitBoardDetailResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithCenterResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithLocationResponseDto; import com.somemore.recruitboard.repository.RecruitBoardRepository; import com.somemore.recruitboard.usecase.query.RecruitBoardQueryUseCase; -import java.util.Optional; +import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,8 +30,41 @@ public class RecruitBoardQueryService implements RecruitBoardQueryUseCase { private final RecruitBoardRepository recruitBoardRepository; @Override - public Optional findById(Long id) { - return recruitBoardRepository.findById(id); + public RecruitBoardResponseDto getById(Long id) { + RecruitBoard recruitBoard = recruitBoardRepository.findById(id).orElseThrow( + () -> new BadRequestException(NOT_EXISTS_RECRUIT_BOARD.getMessage()) + ); + return RecruitBoardResponseDto.from(recruitBoard); + } + + @Override + public RecruitBoardWithLocationResponseDto getWithLocationById(Long id) { + RecruitBoardWithLocation recruitBoardWithLocation = recruitBoardRepository.findWithLocationById( + id).orElseThrow( + () -> new BadRequestException(NOT_EXISTS_RECRUIT_BOARD.getMessage()) + ); + return RecruitBoardWithLocationResponseDto.from(recruitBoardWithLocation); + } + + @Override + public Page getAllWithCenter( + RecruitBoardSearchCondition condition) { + Page boards = recruitBoardRepository.findAllWithCenter(condition); + return boards.map(RecruitBoardWithCenterResponseDto::from); + } + + @Override + public Page getRecruitBoardsNearby( + RecruitBoardNearByCondition condition) { + Page boards = recruitBoardRepository.findAllNearby(condition); + return boards.map(RecruitBoardDetailResponseDto::from); + } + + @Override + public Page getRecruitBoardsByCenterId(UUID centerId, + Pageable pageable) { + Page boards = recruitBoardRepository.findAllByCenterId(centerId, pageable); + return boards.map(RecruitBoardResponseDto::from); } } diff --git a/src/main/java/com/somemore/recruitboard/usecase/query/RecruitBoardQueryUseCase.java b/src/main/java/com/somemore/recruitboard/usecase/query/RecruitBoardQueryUseCase.java index b98576d79..3630dbd3f 100644 --- a/src/main/java/com/somemore/recruitboard/usecase/query/RecruitBoardQueryUseCase.java +++ b/src/main/java/com/somemore/recruitboard/usecase/query/RecruitBoardQueryUseCase.java @@ -1,10 +1,25 @@ package com.somemore.recruitboard.usecase.query; -import com.somemore.recruitboard.domain.RecruitBoard; -import java.util.Optional; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import com.somemore.recruitboard.dto.response.RecruitBoardDetailResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithCenterResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithLocationResponseDto; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface RecruitBoardQueryUseCase { - Optional findById(Long id); + RecruitBoardResponseDto getById(Long id); + RecruitBoardWithLocationResponseDto getWithLocationById(Long id); + + Page getAllWithCenter(RecruitBoardSearchCondition condition); + + Page getRecruitBoardsNearby( + RecruitBoardNearByCondition condition); + + Page getRecruitBoardsByCenterId(UUID centerId, Pageable pageable); } diff --git a/src/test/java/com/somemore/common/fixture/CenterFixture.java b/src/test/java/com/somemore/common/fixture/CenterFixture.java new file mode 100644 index 000000000..869800380 --- /dev/null +++ b/src/test/java/com/somemore/common/fixture/CenterFixture.java @@ -0,0 +1,31 @@ +package com.somemore.common.fixture; + +import com.somemore.center.domain.Center; + +public class CenterFixture { + + public static Center createCenter() { + return Center.builder() + .name("센터 이름") + .contactNumber("010-1111-1111") + .imgUrl("https://image.domain.com/center-img") + .introduce("센터 소개") + .homepageLink("https://www.centerhomepage.com") + .accountId("center_account") + .accountPw("password123") + .build(); + } + + public static Center createCenter(String name) { + return Center.builder() + .name(name) + .contactNumber("010-1111-1111") + .imgUrl("https://image.domain.com/center-img") + .introduce("센터 소개") + .homepageLink("https://www.centerhomepage.com") + .accountId("center_account") + .accountPw("password123") + .build(); + } + +} diff --git a/src/test/java/com/somemore/common/fixture/LocationFixture.java b/src/test/java/com/somemore/common/fixture/LocationFixture.java new file mode 100644 index 000000000..9aa1ac4c4 --- /dev/null +++ b/src/test/java/com/somemore/common/fixture/LocationFixture.java @@ -0,0 +1,40 @@ +package com.somemore.common.fixture; + +import com.somemore.location.domain.Location; +import java.math.BigDecimal; + +public class LocationFixture { + + public static Location createLocation() { + return Location.builder() + .address("주소주소") + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + } + + public static Location createLocation(String address) { + return Location.builder() + .address(address) + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + } + + public static Location createLocation(BigDecimal latitude, BigDecimal longitude) { + return Location.builder() + .address("주소주소") + .latitude(latitude) + .longitude(longitude) + .build(); + } + + public static Location createLocation(String address, BigDecimal latitude, + BigDecimal longitude) { + return Location.builder() + .address(address) + .latitude(latitude) + .longitude(longitude) + .build(); + } +} diff --git a/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java b/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java new file mode 100644 index 000000000..7617b388e --- /dev/null +++ b/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java @@ -0,0 +1,267 @@ +package com.somemore.common.fixture; + +import static com.somemore.common.fixture.LocalDateTimeFixture.createStartDateTime; +import static com.somemore.recruitboard.domain.VolunteerType.OTHER; + +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.domain.VolunteerType; +import java.time.LocalDateTime; +import java.util.UUID; + +public class RecruitBoardFixture { + + public static RecruitBoard createRecruitBoard() { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(UUID.randomUUID()) + .locationId(1L) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(String title) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(UUID.randomUUID()) + .locationId(1L) + .title(title) + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(String title, UUID centerId, Long locationId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(centerId) + .locationId(locationId) + .title(title) + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(String title, UUID centerId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(centerId) + .locationId(1L) + .title(title) + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(VolunteerType type, UUID centerId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(type) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(centerId) + .locationId(1L) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(Boolean admitted, UUID centerId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(admitted) + .build(); + + return RecruitBoard.builder() + .centerId(centerId) + .locationId(1L) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + + public static RecruitBoard createRecruitBoard(Long locationId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(UUID.randomUUID()) + .locationId(locationId) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(UUID centerId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(centerId) + .locationId(1L) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(UUID centerId, Long locationId) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(centerId) + .locationId(locationId) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(String region, VolunteerType volunteerType) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region(region) + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(volunteerType) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(UUID.randomUUID()) + .locationId(1L) + .title("봉사모집제목") + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } + + public static RecruitBoard createRecruitBoard(Long locationId, String title) { + LocalDateTime startDateTime = createStartDateTime(); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() + .region("경기") + .recruitmentCount(1) + .volunteerStartDateTime(startDateTime) + .volunteerEndDateTime(endDateTime) + .volunteerType(OTHER) + .admitted(true) + .build(); + + return RecruitBoard.builder() + .centerId(UUID.randomUUID()) + .locationId(locationId) + .title(title) + .content("봉사모집내용") + .imgUrl("https://image.domain.com/links") + .recruitmentInfo(recruitmentInfo) + .build(); + } +} diff --git a/src/test/java/com/somemore/recruitboard/controller/RecruitBoardQueryControllerTest.java b/src/test/java/com/somemore/recruitboard/controller/RecruitBoardQueryControllerTest.java new file mode 100644 index 000000000..9d05046a8 --- /dev/null +++ b/src/test/java/com/somemore/recruitboard/controller/RecruitBoardQueryControllerTest.java @@ -0,0 +1,144 @@ +package com.somemore.recruitboard.controller; + +import static com.somemore.recruitboard.domain.VolunteerType.ADMINISTRATIVE_SUPPORT; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.somemore.IntegrationTestSupport; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import com.somemore.recruitboard.dto.response.RecruitBoardDetailResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithCenterResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithLocationResponseDto; +import com.somemore.recruitboard.usecase.query.RecruitBoardQueryUseCase; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class RecruitBoardQueryControllerTest extends IntegrationTestSupport { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private RecruitBoardQueryUseCase recruitBoardQueryUseCase; + + @Test + @DisplayName("모집글 ID로 상세 조회할 수 있다.") + void getById() throws Exception { + Long recruitBoardId = 1L; + var responseDto = RecruitBoardWithLocationResponseDto.builder().build(); + + when(recruitBoardQueryUseCase.getWithLocationById(recruitBoardId)).thenReturn(responseDto); + + mockMvc.perform(get("/api/recruit-board/{id}", recruitBoardId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.message").value("봉사 활동 모집 상세 조회 성공")); + + verify(recruitBoardQueryUseCase, times(1)).getWithLocationById(recruitBoardId); + } + + @Test + @DisplayName("모집글 페이징 처리하여 전체 조회 할 수 있다.") + void getAll() throws Exception { + Page page = new PageImpl<>(Collections.emptyList()); + + when(recruitBoardQueryUseCase.getAllWithCenter(any(RecruitBoardSearchCondition.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/recruit-boards") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.message").value("봉사 활동 모집글 리스트 조회 성공")); + + verify(recruitBoardQueryUseCase, times(1)).getAllWithCenter( + any(RecruitBoardSearchCondition.class)); + } + + @Test + @DisplayName("모집글을 검색 조건으로 페이징 조회할 수 있다.") + void getAllBySearch() throws Exception { + Page page = new PageImpl<>(Collections.emptyList()); + + when(recruitBoardQueryUseCase.getAllWithCenter(any(RecruitBoardSearchCondition.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/recruit-boards/search") + .param("keyword", "volunteer") + .param("type", ADMINISTRATIVE_SUPPORT.name()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.message").value("봉사 활동 모집글 검색 조회 성공")); + + verify(recruitBoardQueryUseCase, times(1)).getAllWithCenter( + any(RecruitBoardSearchCondition.class)); + } + + @Test + @DisplayName("위치 기반으로 근처 있는 모집글 페이징 조회할 수 있다.") + void getNearby() throws Exception { + Page page = new PageImpl<>(Collections.emptyList()); + + when( + recruitBoardQueryUseCase.getRecruitBoardsNearby(any(RecruitBoardNearByCondition.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/recruit-boards/nearby") + .param("latitude", "37.5665") + .param("longitude", "126.9780") + .param("radius", "10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.message").value("근처 봉사 활동 모집글 조회 성공")); + + verify(recruitBoardQueryUseCase, times(1)).getRecruitBoardsNearby( + any(RecruitBoardNearByCondition.class)); + } + + @Test + @DisplayName("기관 ID로 모집글 페이징 조회할 수 있다.") + void getRecruitBoardsByCenterId() throws Exception { + UUID centerId = UUID.randomUUID(); + Page page = new PageImpl<>(Collections.emptyList()); + + when(recruitBoardQueryUseCase.getRecruitBoardsByCenterId(eq(centerId), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/recruit-boards/center/{centerId}", centerId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.message").value("기관 봉사 활동 모집글 조회 성공")); + + verify(recruitBoardQueryUseCase, times(1)).getRecruitBoardsByCenterId(eq(centerId), + any(Pageable.class)); + } +} diff --git a/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java b/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java index bc4383d9a..e8cc84dba 100644 --- a/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java +++ b/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java @@ -1,46 +1,80 @@ package com.somemore.recruitboard.repository; -import static com.somemore.common.fixture.LocalDateTimeFixture.createStartDateTime; -import static com.somemore.recruitboard.domain.VolunteerType.OTHER; +import static com.somemore.common.fixture.CenterFixture.createCenter; +import static com.somemore.common.fixture.LocalDateTimeFixture.createCurrentDateTime; +import static com.somemore.common.fixture.LocationFixture.createLocation; +import static com.somemore.common.fixture.RecruitBoardFixture.createRecruitBoard; +import static com.somemore.recruitboard.domain.RecruitStatus.CLOSED; +import static com.somemore.recruitboard.domain.VolunteerType.ADMINISTRATIVE_SUPPORT; import static org.assertj.core.api.Assertions.assertThat; import com.somemore.IntegrationTestSupport; +import com.somemore.center.domain.Center; +import com.somemore.center.repository.CenterRepository; +import com.somemore.location.domain.Location; +import com.somemore.location.repository.LocationRepository; import com.somemore.recruitboard.domain.RecruitBoard; -import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.domain.VolunteerType; +import com.somemore.recruitboard.domain.mapping.RecruitBoardDetail; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithCenter; +import com.somemore.recruitboard.domain.mapping.RecruitBoardWithLocation; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.UUID; -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; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; +@Transactional class RecruitBoardRepositoryImplTest extends IntegrationTestSupport { @Autowired private RecruitBoardRepositoryImpl recruitBoardRepository; - private RecruitBoard recruitBoard; + @Autowired + private CenterRepository centerRepository; + + @Autowired + private LocationRepository locationRepository; + + private final List boards = new ArrayList<>(); @BeforeEach void setUp() { - recruitBoard = createRecruitBoard(); - recruitBoardRepository.saveAndFlush(recruitBoard); - recruitBoard.markAsDeleted(); - recruitBoardRepository.saveAndFlush(recruitBoard); - } + Location location = createLocation(); + locationRepository.save(location); + + Center center = createCenter(); + centerRepository.save(center); - @AfterEach - void tearDown() { - recruitBoardRepository.deleteAllInBatch(); + for (int i = 1; i <= 100; i++) { + String title = "제목" + i; + RecruitBoard board = createRecruitBoard(title, center.getId(), location.getId()); + boards.add(board); + } + recruitBoardRepository.saveAll(boards); } @DisplayName("논리 삭제된 데이터를 id로 조회시 빈 Optional 반환된다") @Test void findById() { // given - Long deletedId = recruitBoard.getId(); + RecruitBoard deletedBoard = createRecruitBoard(); + deletedBoard.markAsDeleted(); + recruitBoardRepository.save(deletedBoard); + + Long deletedId = deletedBoard.getId(); // when Optional findBoard = recruitBoardRepository.findById(deletedId); @@ -49,27 +83,294 @@ void findById() { assertThat(findBoard).isEmpty(); } - private static RecruitBoard createRecruitBoard() { + @DisplayName("존재하지 않는 아이디로 봉사 모집글과 작성기관을 조회하면 Optional.empty()가 반환된다.") + @Test + void findWithCenterByIdWithNotExistId() { + // given + Location location = createLocation("특별한주소"); + locationRepository.save(location); + + RecruitBoard deletedRecruitBoard = createRecruitBoard(location.getId()); + deletedRecruitBoard.markAsDeleted(); + recruitBoardRepository.save(deletedRecruitBoard); + + // when + Optional findOne = recruitBoardRepository.findWithLocationById( + deletedRecruitBoard.getId()); + + // then + assertThat(findOne).isEmpty(); + } + + @DisplayName("조건 없이 모집 게시글을 조회한다. (정렬 포함)") + @Test + void findAllWithCenterWithoutCriteria() { + // given + Pageable pageable = getPageable(); + + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardRepository.findAllWithCenter(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(boards.size()); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getContent()).hasSize(5); + + assertThat(result.getContent().get(0).recruitBoard().getCreatedAt()) + .isAfterOrEqualTo(result.getContent().get(1).recruitBoard().getCreatedAt()); + } + + @DisplayName("키워드로 조회할 수 있다") + @Test + void findAllWithCenterByKeyword() { + // given + Center center = createCenter(); + centerRepository.save(center); + String keyword = "키워드"; + RecruitBoard recruitBoard = createRecruitBoard("키워드 조회 제목", center.getId()); + recruitBoardRepository.save(recruitBoard); + + Pageable pageable = getPageable(); + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .keyword(keyword) + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardRepository.findAllWithCenter(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getContent()).hasSize(1); + + assertThat(result.getContent().getFirst().recruitBoard().getTitle()) + .isEqualTo("키워드 조회 제목"); + } + + @DisplayName("봉사활동 유형으로 조회할 수 있다") + @Test + void findAllWithCenterByType() { + // given + Center center = createCenter(); + centerRepository.save(center); + + RecruitBoard recruitBoard = createRecruitBoard(ADMINISTRATIVE_SUPPORT, center.getId()); + recruitBoardRepository.save(recruitBoard); + + Pageable pageable = getPageable(); + VolunteerType type = ADMINISTRATIVE_SUPPORT; + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .type(type) + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardRepository.findAllWithCenter(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getContent()).hasSize(1); + + assertThat(result.getContent().getFirst().recruitBoard().getRecruitmentInfo() + .getVolunteerType()).isEqualTo(type); + } + + @DisplayName("지역으로 조회할 수 있다") + @Test + void findAllWithCenterByRegion() { + // given + Center center = createCenter(); + centerRepository.save(center); + + String region = "특수지역"; + RecruitBoard recruitBoard = createRecruitBoard(center.getId()); + recruitBoard.updateWith(region); + recruitBoardRepository.save(recruitBoard); + + Pageable pageable = getPageable(); + + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .region(region) + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardRepository.findAllWithCenter(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getContent()).hasSize(1); + + assertThat(result.getContent().getFirst().recruitBoard().getRecruitmentInfo() + .getRegion()).isEqualTo(region); + } + + @DisplayName("시간 인증 여부로 조회할 수 있다") + @Test + void findAllWithCenterByAdmitted() { + // given + Center center = createCenter(); + centerRepository.save(center); + + Boolean admitted = false; + RecruitBoard recruitBoard = createRecruitBoard(admitted, center.getId()); + recruitBoardRepository.save(recruitBoard); - LocalDateTime startDateTime = createStartDateTime(); - LocalDateTime endDateTime = startDateTime.plusHours(1); + Pageable pageable = getPageable(); - RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() - .region("경기") - .recruitmentCount(1) - .volunteerStartDateTime(startDateTime) - .volunteerEndDateTime(endDateTime) - .volunteerType(OTHER) - .admitted(true) + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .admitted(false) + .pageable(pageable) .build(); - return RecruitBoard.builder() - .centerId(UUID.randomUUID()) - .locationId(1L) - .title("봉사모집제목") - .content("봉사모집내용") - .imgUrl("https://image.domain.com/links") - .recruitmentInfo(recruitmentInfo) + // when + Page result = recruitBoardRepository.findAllWithCenter(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getContent()).hasSize(1); + + assertThat(result.getContent().getFirst().recruitBoard().getRecruitmentInfo() + .getAdmitted()).isFalse(); + } + + @DisplayName("모집글 상태로 조회할 수 있다.") + @Test + void findAllWithCenterByStatus() { + // given + Center center = createCenter(); + centerRepository.save(center); + + RecruitStatus status = CLOSED; + RecruitBoard recruitBoard = createRecruitBoard(center.getId()); + LocalDateTime currentDateTime = createCurrentDateTime(); + recruitBoard.changeRecruitStatus(status, currentDateTime); + recruitBoardRepository.save(recruitBoard); + + Pageable pageable = getPageable(); + + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .status(status) + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardRepository.findAllWithCenter(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getContent()).hasSize(1); + + assertThat(result.getContent().getFirst().recruitBoard().getRecruitStatus()).isEqualTo( + status); + } + + @DisplayName("위치 기반으로 반경 내에 모집글을 반환한다") + @Test + void findAllNearByLocation() { + // given + Pageable pageable = getPageable(); + + RecruitBoardNearByCondition condition = RecruitBoardNearByCondition.builder() + .latitude(37.5935) + .longitude(126.9780) + .radius(5.0) + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardRepository.findAllNearby(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(boards.size()); + assertThat(result.getContent()).isNotEmpty(); + } + + @DisplayName("위치 기반으로 반경 내에 모집글이 없으면 빈 페이지를 반환한다") + @Test + void findAllNearByLocation_noResult() { + // given + Pageable pageable = getPageable(); + + RecruitBoardNearByCondition condition = RecruitBoardNearByCondition.builder() + .latitude(37.6115) + .longitude(127.034) + .radius(5.0) + .pageable(pageable) .build(); + + // when + Page result = recruitBoardRepository.findAllNearby(condition); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getContent()).isEmpty(); + } + + @DisplayName("기관 아이디로 모집글 리스트를 조회할 수 있다.") + @Test + void findAllByCenterId() { + // given + Center center = createCenter(); + centerRepository.save(center); + + RecruitBoard recruitBoard = createRecruitBoard(center.getId()); + recruitBoardRepository.save(recruitBoard); + + Pageable pageable = getPageable(); + + // when + Page boards = recruitBoardRepository.findAllByCenterId(center.getId(), + pageable); + + // then + assertThat(boards).isNotEmpty(); + assertThat(boards.getTotalElements()).isEqualTo(1); + assertThat(boards.getContent()).hasSize(1); + } + + @DisplayName("잘못된 기관 아이디로 모집글 리스트를 조회하면 빈 리스트가 반환된다.") + @Test + void findAllByCenterIdWhenWrongCenterId() { + // given + UUID centerId = UUID.randomUUID(); + Pageable pageable = getPageable(); + + // when + Page boards = recruitBoardRepository.findAllByCenterId(centerId, + pageable); + + // then + assertThat(boards).isEmpty(); + } + + private Pageable getPageable() { + Sort sort = Sort.by(Sort.Order.desc("created_at")); + return PageRequest.of(0, 5, sort); + } + } diff --git a/src/test/java/com/somemore/recruitboard/service/command/CreateRecruitBoardServiceTest.java b/src/test/java/com/somemore/recruitboard/service/command/CreateRecruitBoardServiceTest.java index 894aa05c7..766a7ea98 100644 --- a/src/test/java/com/somemore/recruitboard/service/command/CreateRecruitBoardServiceTest.java +++ b/src/test/java/com/somemore/recruitboard/service/command/CreateRecruitBoardServiceTest.java @@ -9,7 +9,7 @@ import com.somemore.recruitboard.domain.RecruitBoard; import com.somemore.recruitboard.domain.VolunteerType; import com.somemore.recruitboard.dto.request.RecruitBoardCreateRequestDto; -import com.somemore.recruitboard.repository.RecruitBoardRepository; +import com.somemore.recruitboard.repository.RecruitBoardJpaRepository; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Optional; @@ -25,14 +25,14 @@ class CreateRecruitBoardServiceTest extends IntegrationTestSupport { private CreateRecruitBoardService createRecruitBoardService; @Autowired - private RecruitBoardRepository recruitBoardRepository; + private RecruitBoardJpaRepository recruitBoardJpaRepository; @Autowired private LocationRepository locationRepository; @AfterEach void tearDown() { - recruitBoardRepository.deleteAllInBatch(); + recruitBoardJpaRepository.deleteAllInBatch(); locationRepository.deleteAllInBatch(); } @@ -68,7 +68,7 @@ void createRecruitBoardWithDto() { Long saveId = createRecruitBoardService.createRecruitBoard(dto, centerId, imgUrl); // then - Optional recruitBoard = recruitBoardRepository.findById(saveId); + Optional recruitBoard = recruitBoardJpaRepository.findById(saveId); assertThat(recruitBoard).isPresent(); assertThat(recruitBoard.get().getId()).isEqualTo(saveId); @@ -76,4 +76,4 @@ void createRecruitBoardWithDto() { assertThat(recruitBoard.get().getImgUrl()).isEqualTo(imgUrl); } -} \ No newline at end of file +} diff --git a/src/test/java/com/somemore/recruitboard/service/command/DeleteRecruitBoardServiceTest.java b/src/test/java/com/somemore/recruitboard/service/command/DeleteRecruitBoardServiceTest.java index 563026e53..731b7a0ff 100644 --- a/src/test/java/com/somemore/recruitboard/service/command/DeleteRecruitBoardServiceTest.java +++ b/src/test/java/com/somemore/recruitboard/service/command/DeleteRecruitBoardServiceTest.java @@ -9,6 +9,7 @@ import com.somemore.global.exception.BadRequestException; import com.somemore.recruitboard.domain.RecruitBoard; import com.somemore.recruitboard.domain.RecruitmentInfo; +import com.somemore.recruitboard.repository.RecruitBoardJpaRepository; import com.somemore.recruitboard.repository.RecruitBoardRepository; import java.time.LocalDateTime; import java.util.Optional; @@ -27,17 +28,20 @@ class DeleteRecruitBoardServiceTest extends IntegrationTestSupport { @Autowired private RecruitBoardRepository recruitBoardRepository; + @Autowired + private RecruitBoardJpaRepository recruitBoardJpaRepository; + private RecruitBoard recruitBoard; @BeforeEach void setUp() { recruitBoard = createRecruitBoard(); - recruitBoardRepository.saveAndFlush(recruitBoard); + recruitBoardRepository.save(recruitBoard); } @AfterEach void tearDown() { - recruitBoardRepository.deleteAllInBatch(); + recruitBoardJpaRepository.deleteAllInBatch(); } @DisplayName("봉사 모집글 식별값으로 모집글을 삭제할 수 있다") diff --git a/src/test/java/com/somemore/recruitboard/service/command/UpdateRecruitBoardServiceTest.java b/src/test/java/com/somemore/recruitboard/service/command/UpdateRecruitBoardServiceTest.java index ab7b0f3ba..3aa975971 100644 --- a/src/test/java/com/somemore/recruitboard/service/command/UpdateRecruitBoardServiceTest.java +++ b/src/test/java/com/somemore/recruitboard/service/command/UpdateRecruitBoardServiceTest.java @@ -16,7 +16,7 @@ import com.somemore.recruitboard.domain.RecruitmentInfo; import com.somemore.recruitboard.dto.request.RecruitBoardLocationUpdateRequestDto; import com.somemore.recruitboard.dto.request.RecruitBoardUpdateRequestDto; -import com.somemore.recruitboard.repository.RecruitBoardRepository; +import com.somemore.recruitboard.repository.RecruitBoardJpaRepository; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.UUID; @@ -33,7 +33,7 @@ class UpdateRecruitBoardServiceTest extends IntegrationTestSupport { private UpdateRecruitBoardService updateRecruitBoardService; @Autowired - private RecruitBoardRepository recruitBoardRepository; + private RecruitBoardJpaRepository recruitBoardJpaRepository; @Autowired private LocationRepository locationRepository; @@ -47,12 +47,12 @@ void setUp() { locationRepository.saveAndFlush(location); centerId = UUID.randomUUID(); recruitBoard = createRecruitBoard(centerId, location.getId()); - recruitBoardRepository.saveAndFlush(recruitBoard); + recruitBoardJpaRepository.saveAndFlush(recruitBoard); } @AfterEach void tearDown() { - recruitBoardRepository.deleteAllInBatch(); + recruitBoardJpaRepository.deleteAllInBatch(); locationRepository.deleteAllInBatch(); } @@ -78,7 +78,7 @@ void updateRecruitBoard() { newImgUrl); // then - RecruitBoard updatedRecruitBoard = recruitBoardRepository.findById(recruitBoard.getId()) + RecruitBoard updatedRecruitBoard = recruitBoardJpaRepository.findById(recruitBoard.getId()) .orElseThrow(); assertThat(updatedRecruitBoard.getTitle()).isEqualTo(dto.title()); @@ -111,7 +111,7 @@ void updateRecruitBoardLocation() { updateRecruitBoardService.updateRecruitBoardLocation(dto, recruitBoard.getId(), centerId); // then - RecruitBoard updateRecruitBoard = recruitBoardRepository.findById(recruitBoard.getId()) + RecruitBoard updateRecruitBoard = recruitBoardJpaRepository.findById(recruitBoard.getId()) .orElseThrow(); Location updateLocation = locationRepository.findById(recruitBoard.getLocationId()) .orElseThrow(); @@ -164,7 +164,7 @@ void updateRecruitBoardStatus() { currentDateTime); // then - RecruitBoard findBoard = recruitBoardRepository.findById(recruitBoardId).orElseThrow(); + RecruitBoard findBoard = recruitBoardJpaRepository.findById(recruitBoardId).orElseThrow(); assertThat(findBoard.getRecruitStatus()).isEqualTo(newStatus); } diff --git a/src/test/java/com/somemore/recruitboard/service/query/RecruitBoardQueryServiceTest.java b/src/test/java/com/somemore/recruitboard/service/query/RecruitBoardQueryServiceTest.java new file mode 100644 index 000000000..0840fd5d5 --- /dev/null +++ b/src/test/java/com/somemore/recruitboard/service/query/RecruitBoardQueryServiceTest.java @@ -0,0 +1,202 @@ +package com.somemore.recruitboard.service.query; + +import static com.somemore.common.fixture.CenterFixture.createCenter; +import static com.somemore.common.fixture.LocationFixture.createLocation; +import static com.somemore.common.fixture.RecruitBoardFixture.createRecruitBoard; +import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_RECRUIT_BOARD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.somemore.IntegrationTestSupport; +import com.somemore.center.domain.Center; +import com.somemore.center.repository.CenterRepository; +import com.somemore.global.exception.BadRequestException; +import com.somemore.location.domain.Location; +import com.somemore.location.repository.LocationRepository; +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; +import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; +import com.somemore.recruitboard.dto.response.RecruitBoardDetailResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithCenterResponseDto; +import com.somemore.recruitboard.dto.response.RecruitBoardWithLocationResponseDto; +import com.somemore.recruitboard.repository.RecruitBoardRepository; +import java.util.List; +import java.util.UUID; +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; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +class RecruitBoardQueryServiceTest extends IntegrationTestSupport { + + @Autowired + private RecruitBoardQueryService recruitBoardQueryService; + + @Autowired + private RecruitBoardRepository recruitBoardRepository; + + @Autowired + private CenterRepository centerRepository; + + @Autowired + private LocationRepository locationRepository; + + private RecruitBoard recruitBoard; + + @BeforeEach + void setUp() { + recruitBoard = createRecruitBoard(); + recruitBoardRepository.save(recruitBoard); + } + + @DisplayName("존재하는 ID가 주어지면 RecruitBoard 엔티티를 조회할 수 있다") + @Test + void getByIdWithExistsId() { + // given + Long id = recruitBoard.getId(); + + // when + RecruitBoardResponseDto dto = recruitBoardQueryService.getById(id); + + // then + assertThat(dto.id()).isEqualTo(recruitBoard.getId()); + } + + @DisplayName("존재하지 않는 ID가 주어지면 에러가 발생한다.") + @Test + void getByIdWithDoesNotExistId() { + // given + Long wrongId = 999L; + + // when + // then + assertThatThrownBy( + () -> recruitBoardQueryService.getById(wrongId) + ).isInstanceOf(BadRequestException.class) + .hasMessage(NOT_EXISTS_RECRUIT_BOARD.getMessage()); + } + + @DisplayName("아이디로 모집글과 기관를 조회할 수 있다.") + @Test + void getWithCenterById() { + // given + Location location = createLocation("특별한 주소"); + locationRepository.save(location); + + RecruitBoard board = createRecruitBoard(location.getId()); + recruitBoardRepository.save(board); + + // when + RecruitBoardWithLocationResponseDto responseDto = recruitBoardQueryService.getWithLocationById( + board.getId()); + + // then + assertThat(responseDto.id()).isEqualTo(board.getId()); + assertThat(responseDto.location().address()).isEqualTo(location.getAddress()); + } + + @DisplayName("존재하지 않는 아이디로 모집글과 센터를 조회하면 에러가 발생한다.") + @Test + void getWithCenterByIdWhenNotExistId() { + // given + Long wrongId = 9999L; + + // when + // then + assertThatThrownBy( + () -> recruitBoardQueryService.getWithLocationById(wrongId) + ).isInstanceOf(BadRequestException.class) + .hasMessage(NOT_EXISTS_RECRUIT_BOARD.getMessage()); + } + + @DisplayName("모집글과 기관 정보 리스트를 페이징 처리하여 받을 수 있다") + @Test + void getAllWithCenter() { + // given + String name = "특별한 기관"; + Center center = createCenter(name); + centerRepository.save(center); + + RecruitBoard recruitBoard1 = createRecruitBoard(center.getId()); + recruitBoardRepository.save(recruitBoard1); + + Pageable pageable = getPageable(); + RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder() + .pageable(pageable) + .build(); + + // when + Page dtos = recruitBoardQueryService.getAllWithCenter( + condition); + + // then + assertThat(dtos).isNotEmpty(); + assertThat(dtos.getTotalElements()).isEqualTo(1); + assertThat(dtos.getContent().getFirst().center().name()).isEqualTo(name); + } + + @DisplayName("위치 기반으로 주변 모집글을 페이징하여 조회할 수 있다") + @Test + void getRecruitBoardsNearBy() { + // given + Center center = createCenter(); + centerRepository.save(center); + Location location = createLocation(); + locationRepository.save(location); + + RecruitBoard board = createRecruitBoard(center.getId(), location.getId()); + recruitBoardRepository.save(board); + + Pageable pageable = getPageable(); + RecruitBoardNearByCondition condition = RecruitBoardNearByCondition.builder() + .latitude(location.getLatitude().doubleValue()) + .longitude(location.getLongitude().doubleValue()) + .radius(3.0) + .pageable(pageable) + .build(); + + // when + Page result = recruitBoardQueryService.getRecruitBoardsNearby( + condition); + + // then + assertThat(result).isNotEmpty(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().getFirst().id()).isEqualTo(board.getId()); + } + + @DisplayName("기관 아이디로 모집글을 페이징하여 조회할 수 있다") + @Test + void getRecruitBoardsByCenterId() { + // given + UUID centerId = UUID.randomUUID(); + RecruitBoard one = createRecruitBoard(centerId); + RecruitBoard two = createRecruitBoard(centerId); + RecruitBoard three = createRecruitBoard(centerId); + recruitBoardRepository.saveAll(List.of(one, two, three)); + + Pageable pageable = getPageable(); + // when + Page result = recruitBoardQueryService.getRecruitBoardsByCenterId( + centerId, pageable); + + // then + assertThat(result).isNotEmpty(); + assertThat(result.getTotalElements()).isEqualTo(3); + } + + + private Pageable getPageable() { + Sort sort = Sort.by(Sort.Order.desc("created_at")); + return PageRequest.of(0, 5, sort); + } + + +} diff --git a/src/test/java/com/somemore/recruitboard/service/query/RecruitQueryServiceTest.java b/src/test/java/com/somemore/recruitboard/service/query/RecruitQueryServiceTest.java deleted file mode 100644 index d001cc182..000000000 --- a/src/test/java/com/somemore/recruitboard/service/query/RecruitQueryServiceTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.somemore.recruitboard.service.query; - -import static com.somemore.common.fixture.LocalDateTimeFixture.createStartDateTime; -import static com.somemore.recruitboard.domain.VolunteerType.OTHER; -import static org.assertj.core.api.Assertions.assertThat; - -import com.somemore.IntegrationTestSupport; -import com.somemore.recruitboard.domain.RecruitBoard; -import com.somemore.recruitboard.domain.RecruitmentInfo; -import com.somemore.recruitboard.repository.RecruitBoardRepository; -import java.time.LocalDateTime; -import java.util.Optional; -import java.util.UUID; -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; - -class RecruitQueryServiceTest extends IntegrationTestSupport { - - @Autowired - private RecruitBoardQueryService recruitQueryService; - - @Autowired - private RecruitBoardRepository recruitBoardRepository; - - private RecruitBoard recruitBoard; - - @BeforeEach - void setUp() { - recruitBoard = createRecruitBoard(); - recruitBoardRepository.saveAndFlush(recruitBoard); - } - - @AfterEach - void tearDown() { - recruitBoardRepository.deleteAllInBatch(); - } - - @DisplayName("존재하는 ID가 주어지면 RecruitBoard 엔티티를 조회할 수 있다") - @Test - void findByIdWithExistsId() { - // given - Long id = recruitBoard.getId(); - - // when - Optional findBoard = recruitQueryService.findById(id); - - // then - assertThat(findBoard).isPresent(); - } - - @DisplayName("존재하지 않는 ID가 주어지면 빈 Optional 반환한다.") - @Test - void findByIdWithDoesNotExistId() { - // given - Long wrongId = 999L; - - // when - Optional findBoard = recruitQueryService.findById(wrongId); - - // then - assertThat(findBoard).isEmpty(); - } - - private static RecruitBoard createRecruitBoard() { - - LocalDateTime startDateTime = createStartDateTime(); - LocalDateTime endDateTime = startDateTime.plusHours(1); - - RecruitmentInfo recruitmentInfo = RecruitmentInfo.builder() - .region("경기") - .recruitmentCount(1) - .volunteerStartDateTime(startDateTime) - .volunteerEndDateTime(endDateTime) - .volunteerType(OTHER) - .admitted(true) - .build(); - - return RecruitBoard.builder() - .centerId(UUID.randomUUID()) - .locationId(1L) - .title("봉사모집제목") - .content("봉사모집내용") - .imgUrl("https://image.domain.com/links") - .recruitmentInfo(recruitmentInfo) - .build(); - } -}