diff --git a/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterApiSpec.java b/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterApiSpec.java index 0cacf0d7..012232a3 100644 --- a/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterApiSpec.java +++ b/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterApiSpec.java @@ -1,6 +1,10 @@ package org.ject.support.admin.member.controller; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse; +import org.ject.support.common.response.CursorPageResponse; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestBody; import io.swagger.v3.oas.annotations.Operation; @@ -14,4 +18,12 @@ public interface AdminMemberSemesterApiSpec { description = "일반 구성원을 추가합니다." ) void createAdminMemberSemester(@RequestBody @Valid CreateMemberSemesterRequest request); + + @Operation( + summary = "일반 구성원 리스트 조회", + description = "일반 구성원 목록을 커서기반 페이징과 필터를 적용해 조회합니다." + ) + CursorPageResponse searchAdminMemberSemesterList( + @ModelAttribute @Valid MemberSemesterSearchCondition request + ); } diff --git a/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterController.java b/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterController.java index eb3f6806..120d101f 100644 --- a/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterController.java +++ b/src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterController.java @@ -1,11 +1,18 @@ package org.ject.support.admin.member.controller; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse; import org.ject.support.admin.member.service.AdminMemberUseCase; +import org.ject.support.common.response.CursorPageResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -15,10 +22,19 @@ public class AdminMemberSemesterController implements AdminMemberSemesterApiSpec private final AdminMemberUseCase adminMemberUsecase; - //일반 구성원 추가 + // 일반 구성원 추가 @Override @PostMapping - public void createAdminMemberSemester(CreateMemberSemesterRequest request) { + public void createAdminMemberSemester( + @RequestBody @Valid CreateMemberSemesterRequest request) { adminMemberUsecase.createMemberSemester(request); } + + // 일반 구성원 목록 조회 + @Override + @GetMapping + public CursorPageResponse searchAdminMemberSemesterList( + @ModelAttribute @Valid MemberSemesterSearchCondition request) { + return adminMemberUsecase.searchMemberSemester(request); + } } diff --git a/src/main/java/org/ject/support/admin/member/dto/projection/SearchMemberSemesterProjection.java b/src/main/java/org/ject/support/admin/member/dto/projection/SearchMemberSemesterProjection.java new file mode 100644 index 00000000..646c7d45 --- /dev/null +++ b/src/main/java/org/ject/support/admin/member/dto/projection/SearchMemberSemesterProjection.java @@ -0,0 +1,17 @@ +package org.ject.support.admin.member.dto.projection; + +import org.ject.support.domain.member.ActivityStatus; +import org.ject.support.domain.member.CareerDetails; +import org.ject.support.domain.member.ExperiencePeriod; +import org.ject.support.domain.member.JobFamily; + +public record SearchMemberSemesterProjection( + Long memberActivityId, + String name, + JobFamily jobFamily, + String phoneNumber, + CareerDetails careerDetails, + ExperiencePeriod experiencePeriod, + ActivityStatus status +) { +} diff --git a/src/main/java/org/ject/support/admin/member/dto/request/MemberSemesterSearchCondition.java b/src/main/java/org/ject/support/admin/member/dto/request/MemberSemesterSearchCondition.java new file mode 100644 index 00000000..08ed662f --- /dev/null +++ b/src/main/java/org/ject/support/admin/member/dto/request/MemberSemesterSearchCondition.java @@ -0,0 +1,47 @@ +package org.ject.support.admin.member.dto.request; + +import java.util.List; + +import org.ject.support.domain.member.ActivityStatus; +import org.ject.support.domain.member.CareerDetails; +import org.ject.support.domain.member.JobFamily; +import org.ject.support.domain.recruit.domain.RecruitTypeDetail; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; + +public record MemberSemesterSearchCondition( + + //-- 페이징 -- + + //MemberActivityId 기준 내림차순 + @Positive + Long cursor, + + // 정책 변경에 대비해 여유롭게 설정 + @Min(1) + @Max(100) + Integer size, + + //-- 필터 -- + + // 기수 + @Positive + Long semesterId, + // 직군 (다중 선택) + List jobFamilies, + // 모집 단위 (다중 선택) + List recruitTypeDetails, + // 신분 (다중 선택) + List careerDetails, + // 팀 (다중 선택) + List<@Positive Long> teamIds, + // 활동 상태 (다중 선택) + List statuses +) { + //size가 없으면 기본 30개 + public int getSizeOrDefault() { + return size == null ? 30 : size; + } +} diff --git a/src/main/java/org/ject/support/admin/member/dto/response/SearchMemberSemesterResponse.java b/src/main/java/org/ject/support/admin/member/dto/response/SearchMemberSemesterResponse.java new file mode 100644 index 00000000..d6fda8f6 --- /dev/null +++ b/src/main/java/org/ject/support/admin/member/dto/response/SearchMemberSemesterResponse.java @@ -0,0 +1,29 @@ +package org.ject.support.admin.member.dto.response; + +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; +import org.ject.support.domain.member.ActivityStatus; +import org.ject.support.domain.member.CareerDetails; +import org.ject.support.domain.member.ExperiencePeriod; +import org.ject.support.domain.member.JobFamily; + +public record SearchMemberSemesterResponse( + Long memberActivityId, + String name, + JobFamily jobFamily, + String phoneNumber, + CareerDetails careerDetails, + ExperiencePeriod experiencePeriod, + ActivityStatus status +) { + public static SearchMemberSemesterResponse from(SearchMemberSemesterProjection projection) { + return new SearchMemberSemesterResponse( + projection.memberActivityId(), + projection.name(), + projection.jobFamily(), + projection.phoneNumber(), + projection.careerDetails(), + projection.experiencePeriod(), + projection.status() + ); + } +} diff --git a/src/main/java/org/ject/support/admin/member/dto/result/SearchMemberSemesterPageResult.java b/src/main/java/org/ject/support/admin/member/dto/result/SearchMemberSemesterPageResult.java new file mode 100644 index 00000000..50bc0fed --- /dev/null +++ b/src/main/java/org/ject/support/admin/member/dto/result/SearchMemberSemesterPageResult.java @@ -0,0 +1,11 @@ +package org.ject.support.admin.member.dto.result; + +import java.util.List; + +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; + +public record SearchMemberSemesterPageResult( + List content, + long totalCount +) { +} diff --git a/src/main/java/org/ject/support/admin/member/service/AdminMemberActivityService.java b/src/main/java/org/ject/support/admin/member/service/AdminMemberActivityService.java index d2415ed7..623b693c 100644 --- a/src/main/java/org/ject/support/admin/member/service/AdminMemberActivityService.java +++ b/src/main/java/org/ject/support/admin/member/service/AdminMemberActivityService.java @@ -1,16 +1,22 @@ package org.ject.support.admin.member.service; +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.admin.member.dto.result.SearchMemberSemesterPageResult; import org.ject.support.domain.member.MemberType; import org.ject.support.domain.member.entity.MemberActivity; import org.ject.support.domain.member.exception.MemberException; import org.ject.support.domain.member.repository.MemberActivityRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import static org.ject.support.domain.member.exception.MemberErrorCode.ALREADY_EXIST_MEMBER_SEMESTER_ACTIVITY; +import java.util.List; + @Service @RequiredArgsConstructor public class AdminMemberActivityService { @@ -37,6 +43,20 @@ public void createMemberSemesterActivity(CreateMemberSemesterRequest request, Lo memberActivityRepository.save(memberActivity); } + // 동적 필터로 일반 구성원 목록 조회 + public SearchMemberSemesterPageResult searchMemberSemesterList(MemberSemesterSearchCondition condition) { + // size+1로 조회 (다음 페이지 유무 확인) + List projections = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + long totalCount = memberActivityRepository.countMemberSemesters(condition); + return new SearchMemberSemesterPageResult(projections, totalCount); + } + + + /* + 유틸, 검증 함수 + */ + // 동일 기수 일반 구성원 활동 중복 검증 private void validateDuplicateSemesterActivity(Long memberId, Long semesterId) { if (memberActivityRepository.existsSemesterActivity(memberId, MemberType.SEMESTER, semesterId)) { diff --git a/src/main/java/org/ject/support/admin/member/service/AdminMemberService.java b/src/main/java/org/ject/support/admin/member/service/AdminMemberService.java index e8c8d3b9..f78cc277 100644 --- a/src/main/java/org/ject/support/admin/member/service/AdminMemberService.java +++ b/src/main/java/org/ject/support/admin/member/service/AdminMemberService.java @@ -17,7 +17,7 @@ public class AdminMemberService { (2026.06.23) 1. email로 삭제 포함 구성원 신상 전부 조회 2. 삭제된 구성원 존재시: isDeleted를 복구하고 새로 입력받은 값으로 덮어쓰기 - 3. 활성 구성원 존재시: 기존 값 재사용 + 3. 활성 구성원 존재시: 기존 값 재사용 Todo: 대체 가능한 값은 새로운 입력으로 대체 4. 미 존재시: 새로 생성 */ diff --git a/src/main/java/org/ject/support/admin/member/service/AdminMemberUseCase.java b/src/main/java/org/ject/support/admin/member/service/AdminMemberUseCase.java index 8aeb3c08..0795286c 100644 --- a/src/main/java/org/ject/support/admin/member/service/AdminMemberUseCase.java +++ b/src/main/java/org/ject/support/admin/member/service/AdminMemberUseCase.java @@ -1,8 +1,19 @@ package org.ject.support.admin.member.service; -import static org.ject.support.domain.member.exception.MemberErrorCode.NOT_FOUND_TEAM_OF_SEMESTER; +import static org.ject.support.domain.member.exception.MemberErrorCode.*; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse; +import org.ject.support.admin.member.dto.result.SearchMemberSemesterPageResult; +import org.ject.support.common.response.CursorPageResponse; +import org.ject.support.domain.member.ActivityStatus; +import org.ject.support.domain.member.MemberType; import org.ject.support.domain.member.exception.MemberException; import org.ject.support.domain.recruit.service.SemesterInquiryUsecase; import org.springframework.stereotype.Service; @@ -22,7 +33,7 @@ public class AdminMemberUseCase { // 일반 구성원 생성 흐름 처리 @Transactional public void createMemberSemester(CreateMemberSemesterRequest request) { - // 기수 유효성 검증 + // 유효성 검증 semesterInquiryUsecase.getSemester(request.semesterId()); validateTeam(request.semesterId(), request.teamId()); @@ -33,6 +44,56 @@ public void createMemberSemester(CreateMemberSemesterRequest request) { adminMemberActivityService.createMemberSemesterActivity(request, memberId); } + + // 일반 구성원 목록 조회 (커서 기반 페이징) + @Transactional(readOnly=true) + public CursorPageResponse searchMemberSemester( + MemberSemesterSearchCondition condition + ) { + // 유효성 검증 + validateSemester(condition.semesterId()); + validateTeams(condition.semesterId(), condition.teamIds()); + validateStatuses(condition.statuses(), MemberType.SEMESTER); + // size만큼 조회 + 전체 행 수 조회 + SearchMemberSemesterPageResult pageResult = adminMemberActivityService.searchMemberSemesterList(condition); + // 페이징 값 처리 + boolean hasNext = pageResult.content().size() > condition.getSizeOrDefault(); + long totalCount = pageResult.totalCount(); + List content = hasNext + ? pageResult.content().subList(0,condition.getSizeOrDefault()) + : pageResult.content(); + + List responses = content.stream() + .map(SearchMemberSemesterResponse::from) + .toList(); + + Long nextCursor = hasNext && !content.isEmpty() + ? content.get(content.size()-1).memberActivityId() + : null; + + // 응답 + return CursorPageResponse.of( + responses, + condition.getSizeOrDefault(), + hasNext, + nextCursor, + totalCount + ); + } + + /* + 유틸 함수 + */ + + // 존재하는 기수인지 검증 + private void validateSemester(Long semesterId) { + if(semesterId == null){ + return; + } + // 존재하지 않으면 semester 도메인에서 예외 처리 + semesterInquiryUsecase.getSemester(semesterId); + } + // 선택한 팀의 기수 소속 검증 private void validateTeam(Long semesterId, Long teamId) { if (teamId == null) { @@ -43,4 +104,32 @@ private void validateTeam(Long semesterId, Long teamId) { throw new MemberException(NOT_FOUND_TEAM_OF_SEMESTER); } } + + // 넘겨받은 팀 목록 전체의 기수 소속 및 유효성 검증 + private void validateTeams(Long semesterId, List teamIds) { + if(teamIds == null || teamIds.isEmpty()){ + return; + } + + // 팀이 있는데 기수가 없으면 잘못된 요청 + if (semesterId == null) { + throw new MemberException(REQUIRED_SEMESTER_FOR_TEAM_FILTER); + } + + Set validTeamIds = new HashSet<>( + adminMemberTeamService.getTeamIdsBySemesterId(semesterId) + ); + + if(!validTeamIds.containsAll(teamIds)) { + throw new MemberException(NOT_FOUND_TEAM_OF_SEMESTER); + } + } + + // 구성원 유형에서 허용되는 활동 상태인지 검증 + private void validateStatuses(List statuses, MemberType type) { + if (!ActivityStatus.isAllAvailableFor(statuses, type)) { + throw new MemberException(INVALID_ACTIVITY_STATUS); + } + } + } diff --git a/src/main/java/org/ject/support/common/response/CursorPageResponse.java b/src/main/java/org/ject/support/common/response/CursorPageResponse.java new file mode 100644 index 00000000..54f110ad --- /dev/null +++ b/src/main/java/org/ject/support/common/response/CursorPageResponse.java @@ -0,0 +1,21 @@ +package org.ject.support.common.response; + +import java.util.List; + +public record CursorPageResponse( + List content, + int size, + boolean hasNext, + Long nextCursor, + long totalCount +) { + public static CursorPageResponse of( + List content, + int size, + boolean hasNext, + Long nextCursor, + long totalCount + ) { + return new CursorPageResponse<>(content, size, hasNext, nextCursor, totalCount); + } +} diff --git a/src/main/java/org/ject/support/domain/member/ActivityStatus.java b/src/main/java/org/ject/support/domain/member/ActivityStatus.java index 65efe829..68f1cc92 100644 --- a/src/main/java/org/ject/support/domain/member/ActivityStatus.java +++ b/src/main/java/org/ject/support/domain/member/ActivityStatus.java @@ -1,15 +1,34 @@ package org.ject.support.domain.member; +import java.util.List; + import lombok.AllArgsConstructor; import lombok.Getter; @Getter @AllArgsConstructor public enum ActivityStatus { - ACTIVE("활동중"), + ACTIVE("활동 중"), COMPLETED("완주"), - ENDED("활동종료"), - DROPOUT("중도하차"); + WITHDRAWN("탈퇴"), + ENDED("활동 종료"), + DROPOUT("중도 이탈"); private final String description; + + public boolean isAvailableFor(MemberType type) { + return switch (type) { + case SEMESTER -> this == ACTIVE || this == COMPLETED || this == WITHDRAWN; + case MAKERS, SUPPORTERS -> this == ACTIVE || this == ENDED || this == DROPOUT; + }; + } + + public static boolean isAllAvailableFor(List statuses, MemberType type) { + if (statuses == null || statuses.isEmpty()) { + return true; + } + + return statuses.stream() + .allMatch(status -> status.isAvailableFor(type)); + } } diff --git a/src/main/java/org/ject/support/domain/member/entity/MemberActivity.java b/src/main/java/org/ject/support/domain/member/entity/MemberActivity.java index 7a7cb1ad..14231e65 100644 --- a/src/main/java/org/ject/support/domain/member/entity/MemberActivity.java +++ b/src/main/java/org/ject/support/domain/member/entity/MemberActivity.java @@ -111,4 +111,13 @@ public static MemberActivity createSemesterActivity( //Todo: 메이커스 구성원 활동과 관리 항목 생성 //Todo: 운영 서포터즈 구성원 활동과 관리 항목 생성 + + // 구성원 유형에 맞는 활동 상태로 변경 + public void updateActivityStatus(ActivityStatus activityStatus) { + if (!activityStatus.isAvailableFor(memberType)) { + throw new MemberException(MemberErrorCode.INVALID_ACTIVITY_STATUS); + } + + this.activityStatus = activityStatus; + } } diff --git a/src/main/java/org/ject/support/domain/member/exception/MemberErrorCode.java b/src/main/java/org/ject/support/domain/member/exception/MemberErrorCode.java index 089171c0..e78cde93 100644 --- a/src/main/java/org/ject/support/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/org/ject/support/domain/member/exception/MemberErrorCode.java @@ -1,14 +1,12 @@ package org.ject.support.domain.member.exception; +import static org.springframework.http.HttpStatus.*; + import lombok.AllArgsConstructor; import lombok.Getter; import org.ject.support.common.exception.ErrorCode; import org.springframework.http.HttpStatus; -import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.NOT_FOUND; -import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; - @Getter @AllArgsConstructor public enum MemberErrorCode implements ErrorCode { @@ -18,7 +16,9 @@ public enum MemberErrorCode implements ErrorCode { DUPLICATE_EMAIL(CONFLICT, "MEMBER-4", "이미 사용 중인 이메일입니다."), EXCEEDED_INTERESTED_DOMAINS_MAX_SIZE(PAYLOAD_TOO_LARGE, "MEMBER-5", "관심 도메인 목록의 최대 크기를 초과했습니다."), ALREADY_EXIST_MEMBER_SEMESTER_ACTIVITY(CONFLICT, "MEMBER-6", "해당 기수에 이미 등록된 일반 구성원 활동입니다."), - NOT_FOUND_TEAM_OF_SEMESTER(NOT_FOUND, "MEMBER-7", "해당 기수의 팀을 찾을 수 없습니다.") + NOT_FOUND_TEAM_OF_SEMESTER(NOT_FOUND, "MEMBER-7", "해당 기수의 팀을 찾을 수 없습니다."), + REQUIRED_SEMESTER_FOR_TEAM_FILTER(BAD_REQUEST, "MEMBER-8", "팀 필터를 사용할 때는 기수를 함께 선택해야 합니다."), + INVALID_ACTIVITY_STATUS(BAD_REQUEST, "MEMBER-9", "구성원 유형에 맞지 않는 활동 상태입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/org/ject/support/domain/member/repository/MemberActivityQueryRepository.java b/src/main/java/org/ject/support/domain/member/repository/MemberActivityQueryRepository.java new file mode 100644 index 00000000..583844af --- /dev/null +++ b/src/main/java/org/ject/support/domain/member/repository/MemberActivityQueryRepository.java @@ -0,0 +1,15 @@ +package org.ject.support.domain.member.repository; + +import java.util.List; + +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; + +public interface MemberActivityQueryRepository { + List searchMemberSemesters( + MemberSemesterSearchCondition condition, + int limit + ); + + long countMemberSemesters(MemberSemesterSearchCondition conditon); +} diff --git a/src/main/java/org/ject/support/domain/member/repository/MemberActivityQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/member/repository/MemberActivityQueryRepositoryImpl.java new file mode 100644 index 00000000..3982023c --- /dev/null +++ b/src/main/java/org/ject/support/domain/member/repository/MemberActivityQueryRepositoryImpl.java @@ -0,0 +1,121 @@ +package org.ject.support.domain.member.repository; + +import static org.ject.support.domain.member.entity.QMember.*; +import static org.ject.support.domain.member.entity.QMemberActivity.*; +import static org.ject.support.domain.member.entity.QMemberSemester.*; + +import java.util.List; + +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.domain.member.ActivityStatus; +import org.ject.support.domain.member.CareerDetails; +import org.ject.support.domain.member.JobFamily; +import org.ject.support.domain.member.MemberType; +import org.ject.support.domain.recruit.domain.RecruitTypeDetail; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class MemberActivityQueryRepositoryImpl implements MemberActivityQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + //다중 값은 in, 단일 값은 eq로 필터링 + //id기준 내림차순 - 최신순 + //size+1개 조회 + @Override + public List searchMemberSemesters(MemberSemesterSearchCondition condition, int limit) { + return jpaQueryFactory.select(Projections.constructor( + SearchMemberSemesterProjection.class, + memberActivity.id, + member.name, + memberActivity.jobFamily, + member.phoneNumber, + memberActivity.careerDetails, + memberActivity.experiencePeriod, + memberActivity.activityStatus + )) + .from(memberActivity) + .join(member).on(member.id.eq(memberActivity.memberId)) + .join(memberSemester).on(memberSemester.id.eq(memberActivity.id)) + .where( + cursorLt(condition.cursor()), + semesterIdIn(condition.semesterId()), + jobFamilyIn(condition.jobFamilies()), + careerDetailsIn(condition.careerDetails()), + teamIdIn(condition.teamIds()), + recruitTypeDetailIn(condition.recruitTypeDetails()), + statusIn(condition.statuses()), + member.isDeleted.isFalse(), + memberActivity.isDeleted.isFalse(), + memberActivity.memberType.eq(MemberType.SEMESTER) + ).orderBy(memberActivity.id.desc()) + .limit(limit) + .fetch(); + } + + @Override + public long countMemberSemesters(MemberSemesterSearchCondition condition) { + Long count = jpaQueryFactory.select(memberActivity.id.count()) + .from(memberActivity) + .join(member).on(member.id.eq(memberActivity.memberId)) + .join(memberSemester).on(memberSemester.id.eq(memberActivity.id)) + .where( + semesterIdIn(condition.semesterId()), + jobFamilyIn(condition.jobFamilies()), + careerDetailsIn(condition.careerDetails()), + teamIdIn(condition.teamIds()), + recruitTypeDetailIn(condition.recruitTypeDetails()), + statusIn(condition.statuses()), + member.isDeleted.isFalse(), + memberActivity.isDeleted.isFalse(), + memberActivity.memberType.eq(MemberType.SEMESTER) + ).fetchOne(); + + return count == null ? 0L : count; + } + + private BooleanExpression cursorLt(Long cursor){ + return cursor == null ? null : memberActivity.id.lt(cursor); + } + + private BooleanExpression semesterIdIn(Long semesterId) { + return semesterId == null ? null + : memberSemester.semesterId.eq(semesterId); + } + + private BooleanExpression jobFamilyIn(List jobFamilies) { + return jobFamilies == null || jobFamilies.isEmpty() + ? null + : memberActivity.jobFamily.in(jobFamilies); + } + + private BooleanExpression careerDetailsIn(List careerDetails) { + return careerDetails == null || careerDetails.isEmpty() + ? null + : memberActivity.careerDetails.in(careerDetails); + } + + private BooleanExpression teamIdIn(List teamIds) { + return teamIds == null || teamIds.isEmpty() + ? null + : memberSemester.teamId.in(teamIds); + } + + private BooleanExpression recruitTypeDetailIn(List recruitTypeDetails) { + return recruitTypeDetails == null || recruitTypeDetails.isEmpty() + ? null + : memberActivity.recruitTypeDetail.in(recruitTypeDetails); + } + + private BooleanExpression statusIn(List statuses) { + return statuses == null || statuses.isEmpty() + ? null + : memberActivity.activityStatus.in(statuses); + } +} diff --git a/src/main/java/org/ject/support/domain/member/repository/MemberActivityRepository.java b/src/main/java/org/ject/support/domain/member/repository/MemberActivityRepository.java index c51510cd..868b320f 100644 --- a/src/main/java/org/ject/support/domain/member/repository/MemberActivityRepository.java +++ b/src/main/java/org/ject/support/domain/member/repository/MemberActivityRepository.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Repository; @Repository -public interface MemberActivityRepository extends JpaRepository { +public interface MemberActivityRepository extends JpaRepository, MemberActivityQueryRepository { @Query(""" select count(ma) > 0 diff --git a/src/test/java/org/ject/support/admin/member/controller/AdminMemberSemesterControllerTest.java b/src/test/java/org/ject/support/admin/member/controller/AdminMemberSemesterControllerTest.java index 369d6503..06817afc 100644 --- a/src/test/java/org/ject/support/admin/member/controller/AdminMemberSemesterControllerTest.java +++ b/src/test/java/org/ject/support/admin/member/controller/AdminMemberSemesterControllerTest.java @@ -1,147 +1,338 @@ package org.ject.support.admin.member.controller; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.never; -import static org.mockito.BDDMockito.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.ject.support.domain.member.fixture.MemberFixture.member; +import static org.ject.support.domain.member.fixture.SemesterActivityFixture.semesterActivity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Comparator; import java.util.List; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; -import org.ject.support.admin.member.service.AdminMemberUseCase; -import org.ject.support.common.exception.GlobalExceptionHandler; +import org.ject.support.domain.member.ActivityStatus; import org.ject.support.domain.member.CareerDetails; import org.ject.support.domain.member.ExperiencePeriod; import org.ject.support.domain.member.JobFamily; +import org.ject.support.domain.member.MemberType; import org.ject.support.domain.member.Region; +import org.ject.support.domain.member.entity.Member; +import org.ject.support.domain.member.entity.MemberActivity; +import org.ject.support.domain.member.entity.Team; +import org.ject.support.domain.member.exception.MemberErrorCode; +import org.ject.support.domain.member.repository.MemberActivityRepository; +import org.ject.support.domain.member.repository.MemberRepository; +import org.ject.support.domain.member.repository.TeamRepository; import org.ject.support.domain.recruit.domain.RecruitTypeDetail; -import org.junit.jupiter.api.BeforeEach; +import org.ject.support.domain.recruit.domain.Semester; +import org.ject.support.domain.recruit.repository.SemesterRepository; +import org.ject.support.testconfig.AuthenticatedUser; +import org.ject.support.testconfig.IntegrationTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.cache.support.NullValue; import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; -@ExtendWith(MockitoExtension.class) +@IntegrationTest +@AutoConfigureMockMvc +@Transactional +@AuthenticatedUser(isAdmin = true) +@TestPropertySource(properties = {"spring.data.redis.repositories.enabled=false"}) class AdminMemberSemesterControllerTest { - @Mock - private AdminMemberUseCase adminMemberUseCase; - - @InjectMocks - private AdminMemberSemesterController adminMemberSemesterController; - - private MockMvc mockMvc; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper().findAndRegisterModules(); - mockMvc = MockMvcBuilders.standaloneSetup(adminMemberSemesterController) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); - } - - @Test - @DisplayName("유효한 요청이면 일반 구성원을 추가한다") - void 유효한_요청이면_일반_구성원을_추가한다() throws Exception { - // given - CreateMemberSemesterRequest request = createMemberSemesterRequest("jectkim@ject.kr"); - - // when - mockMvc.perform(post("/admin/members/semester") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - - // then - verify(adminMemberUseCase).createMemberSemester(any(CreateMemberSemesterRequest.class)); - } - - @Test - @DisplayName("팀을 선택하지 않은 요청도 일반 구성원을 추가한다") - void 팀을_선택하지_않은_요청도_일반_구성원을_추가한다() throws Exception { - // given - CreateMemberSemesterRequest request = createMemberSemesterRequest("jectkim@ject.kr", null); - - // when - mockMvc.perform(post("/admin/members/semester") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - - // then - verify(adminMemberUseCase).createMemberSemester(any(CreateMemberSemesterRequest.class)); - } - - @Test - @DisplayName("필수값이 없으면 일반 구성원을 추가하지 않는다") - void 필수값이_없으면_일반_구성원을_추가하지_않는다() throws Exception { - // given - CreateMemberSemesterRequest request = new CreateMemberSemesterRequest( - null, - "jectkim@ject.kr", - "01012345678", - JobFamily.BE, - RecruitTypeDetail.REGULAR, - CareerDetails.EMPLOYEE, - 1L, - null, - ExperiencePeriod.ONE_TO_TWO, - "memo", - List.of("HEALTHCARE"), - Region.SEOUL - ); - - // when - mockMvc.perform(post("/admin/members/semester") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - - // then - verify(adminMemberUseCase, never()).createMemberSemester(any(CreateMemberSemesterRequest.class)); - } - - @Test - @DisplayName("이메일 형식이 올바르지 않으면 일반 구성원을 추가하지 않는다") - void 이메일_형식이_올바르지_않으면_일반_구성원을_추가하지_않는다() throws Exception { - // given - CreateMemberSemesterRequest request = createMemberSemesterRequest("invalid-email"); - - // when - mockMvc.perform(post("/admin/members/semester") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - - // then - verify(adminMemberUseCase, never()).createMemberSemester(any(CreateMemberSemesterRequest.class)); - } - - private CreateMemberSemesterRequest createMemberSemesterRequest(String email) { - return createMemberSemesterRequest(email, 2L); + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberActivityRepository memberActivityRepository; + + @Autowired + private SemesterRepository semesterRepository; + + @Autowired + private TeamRepository teamRepository; + + @Test + @DisplayName("일반 구성원을 추가한다") + void 일반_구성원을_추가한다() throws Exception { + // given + Semester semester = saveSemester(); + Team team = saveTeam(semester.getId(), "1팀"); + String email = uniqueEmail("add"); + CreateMemberSemesterRequest request = createMemberSemesterRequest(email, semester.getId(), team.getId()); + + // when + mockMvc.perform(post("/admin/members/semester") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + // then + Member member = memberRepository.findByEmail(email).orElseThrow(); + assertThat(member.getName()).isEqualTo(request.name()); + assertThat(member.getPhoneNumber()).isEqualTo(request.phoneNumber()); + assertThat(memberActivityRepository.existsSemesterActivity( + member.getId(), + MemberType.SEMESTER, + semester.getId() + )).isTrue(); + } + + @Test + @DisplayName("팀을 선택하지 않아도 일반 구성원을 추가한다") + void 팀을_선택하지_않아도_일반_구성원을_추가한다() throws Exception { + // given + Semester semester = saveSemester(); + String email = uniqueEmail("noteam"); + CreateMemberSemesterRequest request = createMemberSemesterRequest(email, semester.getId(), null); + + // when + mockMvc.perform(post("/admin/members/semester") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + + // then + Member member = memberRepository.findByEmail(email).orElseThrow(); + MemberActivity memberActivity = memberActivityRepository.findAll().stream() + .filter(activity -> activity.getMemberId().equals(member.getId())) + .findFirst() + .orElseThrow(); + assertThat(memberActivity.getMemberSemester().getSemesterId()).isEqualTo(semester.getId()); + assertThat(memberActivity.getMemberSemester().getTeamId()).isNull(); + } + + @Test + @DisplayName("필수값이 없으면 일반 구성원을 추가하지 않는다") + void 필수값이_없으면_일반_구성원을_추가하지_않는다() throws Exception { + // given + Semester semester = saveSemester(); + CreateMemberSemesterRequest request = new CreateMemberSemesterRequest( + null, + uniqueEmail("invalid"), + "01012345678", + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + semester.getId(), + null, + ExperiencePeriod.ONE_TO_TWO, + "memo", + List.of("HEALTHCARE"), + Region.SEOUL + ); + + // when & then + mockMvc.perform(post("/admin/members/semester") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("일반 구성원 목록을 커서 기반으로 조회한다") + void 일반_구성원_목록을_커서_기반으로_조회한다() throws Exception { + // given + Semester semester = saveSemester(); + MemberActivity first = saveMemberSemesterActivity( + uniqueEmail("list1"), + semester.getId(), + null, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberActivity second = saveMemberSemesterActivity( + uniqueEmail("list2"), + semester.getId(), + null, + JobFamily.FE, + RecruitTypeDetail.REGULAR, + CareerDetails.STUDENT, + ExperiencePeriod.NONE + ); + MemberActivity third = saveMemberSemesterActivity( + uniqueEmail("list3"), + semester.getId(), + null, + JobFamily.PM, + RecruitTypeDetail.NEW, + CareerDetails.JOB_SEEKER, + ExperiencePeriod.THREE_TO_FOUR + ); + + // when & then + mockMvc.perform(get("/admin/members/semester") + .param("size", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.content", hasSize(2))) + .andExpect(jsonPath("$.data.content[0].memberActivityId").value(third.getId())) + .andExpect(jsonPath("$.data.content[1].memberActivityId").value(second.getId())) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.nextCursor").value(second.getId())) + .andExpect(jsonPath("$.data.totalCount").value(3)); + + mockMvc.perform(get("/admin/members/semester") + .param("cursor", String.valueOf(second.getId())) + .param("size", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].memberActivityId").value(first.getId())) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.nextCursor").value(nullValue())) + .andExpect(jsonPath("$.data.totalCount").value(3)); + } + + @Test + @DisplayName("필터 조건으로 일반 구성원 목록을 조회한다") + void 필터_조건으로_일반_구성원_목록을_조회한다() throws Exception { + // given + Semester semester = saveSemester(); + Team team = saveTeam(semester.getId(), "필터팀"); + MemberActivity matched = saveMemberSemesterActivity( + uniqueEmail("match"), + semester.getId(), + team.getId(), + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveMemberSemesterActivity( + uniqueEmail("other"), + semester.getId(), + team.getId(), + JobFamily.FE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + + // when & then + mockMvc.perform(get("/admin/members/semester") + .param("size", "30") + .param("semesterId", String.valueOf(semester.getId())) + .param("jobFamilies", "BE") + .param("recruitTypeDetails", "REGULAR") + .param("careerDetails", "EMPLOYEE") + .param("teamIds", String.valueOf(team.getId())) + .param("statuses", ActivityStatus.ACTIVE.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].memberActivityId").value(matched.getId())) + .andExpect(jsonPath("$.data.content[0].status").value(ActivityStatus.ACTIVE.name())) + .andExpect(jsonPath("$.data.totalCount").value(1)); + } + + @Test + @DisplayName("팀 필터만 있으면 일반 구성원 목록을 조회하지 않는다") + void 팀_필터만_있으면_일반_구성원_목록을_조회하지_않는다() throws Exception { + // when & then + mockMvc.perform(get("/admin/members/semester") + .param("teamIds", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(MemberErrorCode.REQUIRED_SEMESTER_FOR_TEAM_FILTER.getCode())); + } + + @Test + @DisplayName("잘못된 enum 값이면 일반 구성원 목록을 조회하지 않는다") + void 잘못된_enum_값이면_일반_구성원_목록을_조회하지_않는다() throws Exception { + // when & then + mockMvc.perform(get("/admin/members/semester") + .param("jobFamilies", "WRONG")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("일반 구성원에서 사용할 수 없는 활동 상태면 목록을 조회하지 않는다") + void 일반_구성원에서_사용할_수_없는_활동_상태면_목록을_조회하지_않는다() throws Exception { + // when & then + mockMvc.perform(get("/admin/members/semester") + .param("statuses", ActivityStatus.DROPOUT.name())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(MemberErrorCode.INVALID_ACTIVITY_STATUS.getCode())); + } + + private CreateMemberSemesterRequest createMemberSemesterRequest(String email, Long semesterId, Long teamId) { + return new CreateMemberSemesterRequest( + "김젝트", + email, + "01012345678", + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + semesterId, + teamId, + ExperiencePeriod.ONE_TO_TWO, + "memo", + List.of("HEALTHCARE", "FINTECH", "AI"), + Region.SEOUL + ); } - private CreateMemberSemesterRequest createMemberSemesterRequest(String email, Long teamId) { - return new CreateMemberSemesterRequest( - "김젝트", - email, - "01012345678", - JobFamily.BE, - RecruitTypeDetail.REGULAR, - CareerDetails.EMPLOYEE, - 1L, - teamId, - ExperiencePeriod.ONE_TO_TWO, - "memo", - List.of("HEALTHCARE", "FINTECH", "AI"), - Region.SEOUL - ); - } + private Semester saveSemester() { + return semesterRepository.save(Semester.builder() + .name("테스트기수" + uniqueSuffix()) + .isRecruiting(true) + .build()); + } + + private Team saveTeam(Long semesterId, String name) { + return teamRepository.save(Team.builder() + .name(name + uniqueSuffix()) + .semesterId(semesterId) + .build()); + } + + private MemberActivity saveMemberSemesterActivity( + String email, + Long semesterId, + Long teamId, + JobFamily jobFamily, + RecruitTypeDetail recruitTypeDetail, + CareerDetails careerDetails, + ExperiencePeriod experiencePeriod + ) { + Member member = memberRepository.save(member() + .email(email) + .build()); + + return memberActivityRepository.saveAndFlush(semesterActivity() + .memberId(member.getId()) + .semesterId(semesterId) + .teamId(teamId) + .jobFamily(jobFamily) + .recruitTypeDetail(recruitTypeDetail) + .careerDetails(careerDetails) + .experiencePeriod(experiencePeriod) + .build()); + } + + private String uniqueEmail(String prefix) { + return prefix + uniqueSuffix() + "@t.kr"; + } + + private String uniqueSuffix() { + String suffix = String.valueOf(System.nanoTime()); + return suffix.substring(suffix.length() - 6); + } } diff --git a/src/test/java/org/ject/support/admin/member/service/AdminMemberActivityServiceTest.java b/src/test/java/org/ject/support/admin/member/service/AdminMemberActivityServiceTest.java index f51a74e4..5549df1c 100644 --- a/src/test/java/org/ject/support/admin/member/service/AdminMemberActivityServiceTest.java +++ b/src/test/java/org/ject/support/admin/member/service/AdminMemberActivityServiceTest.java @@ -8,7 +8,13 @@ import static org.mockito.BDDMockito.verify; import java.util.List; + +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.admin.member.dto.result.SearchMemberSemesterPageResult; +import org.ject.support.common.response.CursorPageResponse; +import org.ject.support.domain.member.ActivityStatus; import org.ject.support.domain.member.CareerDetails; import org.ject.support.domain.member.ExperiencePeriod; import org.ject.support.domain.member.JobFamily; @@ -36,6 +42,9 @@ class AdminMemberActivityServiceTest { @InjectMocks private AdminMemberActivityService adminMemberActivityService; + /** + * 일반 구성원 추가 테스트 + */ @Test @DisplayName("동일 기수 활동이 없으면 일반 구성원 활동을 저장한다") void 동일_기수_활동이_없으면_일반_구성원_활동을_저장한다() { @@ -91,6 +100,48 @@ class AdminMemberActivityServiceTest { verify(memberActivityRepository, never()).save(any(MemberActivity.class)); } + /** + * 일반 구성원 목록 조회 테스트 + */ + + @Test + @DisplayName("다중 필터를 적용해서 일반 구성원 목록을 조회한다") + void 다중_필터를_적용해서_일반_구성원_목록을_조회한다() { + // given + MemberSemesterSearchCondition condition = new MemberSemesterSearchCondition( + null, + 20, + 1L, + List.of(JobFamily.BE), + List.of(RecruitTypeDetail.REGULAR), + List.of(CareerDetails.EMPLOYEE), + List.of(1L,2L,3L), + List.of(ActivityStatus.ACTIVE) + ); + + List projections = List.of( + new SearchMemberSemesterProjection( + 1L, + "김젝트", + JobFamily.BE, + "01012345678", + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO, + ActivityStatus.ACTIVE + ) + ); + given(memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1)).willReturn(projections); + given(memberActivityRepository.countMemberSemesters(condition)).willReturn(1L); + + // when + SearchMemberSemesterPageResult pageResult = adminMemberActivityService.searchMemberSemesterList(condition); + // then + assertThat(pageResult.content()).isEqualTo(projections); + assertThat(pageResult.totalCount()).isEqualTo(1L); + verify(memberActivityRepository).searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + verify(memberActivityRepository).countMemberSemesters(condition); + } + private CreateMemberSemesterRequest createMemberSemesterRequest() { return new CreateMemberSemesterRequest( "김젝트", @@ -107,4 +158,6 @@ private CreateMemberSemesterRequest createMemberSemesterRequest() { Region.SEOUL ); } + + } diff --git a/src/test/java/org/ject/support/admin/member/service/AdminMemberUseCaseTest.java b/src/test/java/org/ject/support/admin/member/service/AdminMemberUseCaseTest.java index 19f1a469..664943b8 100644 --- a/src/test/java/org/ject/support/admin/member/service/AdminMemberUseCaseTest.java +++ b/src/test/java/org/ject/support/admin/member/service/AdminMemberUseCaseTest.java @@ -5,7 +5,13 @@ import java.util.List; +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse; +import org.ject.support.admin.member.dto.result.SearchMemberSemesterPageResult; +import org.ject.support.common.response.CursorPageResponse; +import org.ject.support.domain.member.ActivityStatus; import org.ject.support.domain.member.CareerDetails; import org.ject.support.domain.member.ExperiencePeriod; import org.ject.support.domain.member.JobFamily; @@ -61,6 +67,44 @@ private CreateMemberSemesterRequest createMemberSemesterRequest(Long teamId) { Region.SEOUL ); } + + private MemberSemesterSearchCondition searchCondition(Integer size, Long semesterId, List teamIds) { + return searchCondition(size, semesterId, teamIds, null); + } + + private MemberSemesterSearchCondition searchCondition( + Integer size, + Long semesterId, + List teamIds, + List statuses + ) { + return new MemberSemesterSearchCondition( + null, + size, + semesterId, + null, + null, + null, + teamIds, + statuses + ); + } + + private SearchMemberSemesterProjection searchProjection(Long memberActivityId) { + return new SearchMemberSemesterProjection( + memberActivityId, + "김젝트", + JobFamily.BE, + "01012345678", + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO, + ActivityStatus.ACTIVE + ); + } + + /** + * 일반 구성원 추가 테스트 + */ @Test @DisplayName("일반 구성원을 생성한다") void 일반_구성원을_생성한다() { @@ -139,4 +183,142 @@ private CreateMemberSemesterRequest createMemberSemesterRequest(Long teamId) { } + /** + * 일반 구성원 목록 조회 테스트 + */ + @Test + @DisplayName("조회 결과가 size보다 많으면 다음 커서를 반환한다") + void 조회_결과가_size보다_많으면_다음_커서를_반환한다() { + // given + MemberSemesterSearchCondition condition = searchCondition(2, null, null); + SearchMemberSemesterPageResult pageResult = new SearchMemberSemesterPageResult( + List.of(searchProjection(5L), searchProjection(4L), searchProjection(3L)), + 3L + ); + given(adminMemberActivityService.searchMemberSemesterList(condition)).willReturn(pageResult); + + // when + CursorPageResponse response = + adminMemberUseCase.searchMemberSemester(condition); + + // then + assertThat(response.content()).hasSize(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor()).isEqualTo(4L); + assertThat(response.totalCount()).isEqualTo(3L); + verifyNoInteractions(semesterInquiryUsecase); + verifyNoInteractions(adminMemberTeamService); + } + + @Test + @DisplayName("조회 결과가 size보다 적으면 다음 커서를 반환하지 않는다") + void 조회_결과가_size보다_적으면_다음_커서를_반환하지_않는다() { + // given + MemberSemesterSearchCondition condition = searchCondition(3, null, null); + SearchMemberSemesterPageResult pageResult = new SearchMemberSemesterPageResult( + List.of(searchProjection(5L), searchProjection(4L)), + 2L + ); + given(adminMemberActivityService.searchMemberSemesterList(condition)).willReturn(pageResult); + + // when + CursorPageResponse response = + adminMemberUseCase.searchMemberSemester(condition); + + // then + assertThat(response.content()).hasSize(2); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + assertThat(response.totalCount()).isEqualTo(2L); + } + + @Test + @DisplayName("조회 결과가 없으면 빈 응답을 반환한다") + void 조회_결과가_없으면_빈_응답을_반환한다() { + // given + MemberSemesterSearchCondition condition = searchCondition(3, null, null); + SearchMemberSemesterPageResult pageResult = new SearchMemberSemesterPageResult(List.of(), 0L); + given(adminMemberActivityService.searchMemberSemesterList(condition)).willReturn(pageResult); + + // when + CursorPageResponse response = + adminMemberUseCase.searchMemberSemester(condition); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + assertThat(response.totalCount()).isZero(); + } + + @Test + @DisplayName("팀 필터만 있으면 예외가 발생한다") + void 팀_필터만_있으면_예외가_발생한다() { + // given + MemberSemesterSearchCondition condition = searchCondition(3, null, List.of(1L)); + + // when + Throwable throwable = catchThrowable(() -> adminMemberUseCase.searchMemberSemester(condition)); + + // then + assertThat(throwable) + .isInstanceOf(MemberException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.REQUIRED_SEMESTER_FOR_TEAM_FILTER); + verifyNoInteractions(adminMemberActivityService); + } + + @Test + @DisplayName("해당 기수에 없는 팀으로 조회하면 예외가 발생한다") + void 해당_기수에_없는_팀으로_조회하면_예외가_발생한다() { + // given + MemberSemesterSearchCondition condition = searchCondition(3, 1L, List.of(9L)); + given(adminMemberTeamService.getTeamIdsBySemesterId(condition.semesterId())) + .willReturn(List.of(1L, 2L, 3L)); + + // when + Throwable throwable = catchThrowable(() -> adminMemberUseCase.searchMemberSemester(condition)); + + // then + assertThat(throwable) + .isInstanceOf(MemberException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.NOT_FOUND_TEAM_OF_SEMESTER); + verify(semesterInquiryUsecase).getSemester(condition.semesterId()); + verifyNoInteractions(adminMemberActivityService); + } + + @Test + @DisplayName("일반 구성원에서 사용할 수 없는 활동 상태로 조회하면 예외가 발생한다") + void 일반_구성원에서_사용할_수_없는_활동_상태로_조회하면_예외가_발생한다() { + // given + MemberSemesterSearchCondition condition = searchCondition(3, null, null, List.of(ActivityStatus.DROPOUT)); + + // when + Throwable throwable = catchThrowable(() -> adminMemberUseCase.searchMemberSemester(condition)); + + // then + assertThat(throwable) + .isInstanceOf(MemberException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.INVALID_ACTIVITY_STATUS); + verifyNoInteractions(adminMemberActivityService); + } + + @Test + @DisplayName("일반 구성원에서 탈퇴 상태로 목록을 조회한다") + void 일반_구성원에서_탈퇴_상태로_목록을_조회한다() { + // given + MemberSemesterSearchCondition condition = searchCondition(3, null, null, List.of(ActivityStatus.WITHDRAWN)); + SearchMemberSemesterPageResult pageResult = new SearchMemberSemesterPageResult(List.of(), 0L); + given(adminMemberActivityService.searchMemberSemesterList(condition)).willReturn(pageResult); + + // when + CursorPageResponse response = + adminMemberUseCase.searchMemberSemester(condition); + + // then + assertThat(response.content()).isEmpty(); + verify(adminMemberActivityService).searchMemberSemesterList(condition); + } } diff --git a/src/test/java/org/ject/support/common/data/redis/resilience/CacheFallbackIntegrationTest.java b/src/test/java/org/ject/support/common/data/redis/resilience/CacheFallbackIntegrationTest.java index a317ed5f..dc928058 100644 --- a/src/test/java/org/ject/support/common/data/redis/resilience/CacheFallbackIntegrationTest.java +++ b/src/test/java/org/ject/support/common/data/redis/resilience/CacheFallbackIntegrationTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.ject.support.domain.member.fixture.MemberFixture.member; import static org.ject.support.domain.recruit.domain.Question.InputType.TEXT; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import java.time.LocalDateTime; @@ -20,12 +22,13 @@ import org.ject.support.domain.recruit.repository.SemesterRepository; import org.ject.support.domain.recruit.service.QuestionService; import org.ject.support.testconfig.IntegrationTest; -import org.ject.support.testconfig.RedisTestContainersConfig; 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.cache.Cache; +import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.transaction.annotation.Transactional; @@ -51,6 +54,9 @@ class CacheFallbackIntegrationTest { @Autowired private RedisCacheCircuitBreakerProvider circuitBreakerProvider; + @Autowired + private ResilientCacheErrorHandler resilientCacheErrorHandler; + private Recruit recruit; @BeforeEach @@ -87,19 +93,24 @@ void setUp() { } @AfterEach - void restoreRedis() { - RedisTestContainersConfig.start(); + void resetCircuitBreaker() { circuitBreakerProvider.resetAll(); // 테스트 간 격리를 위해 서킷 브레이커 메트릭 초기화 } @Test - @DisplayName("레디스가 다운되어도 DB에서 데이터를 정상적으로 조회하고 서킷 브레이커가 동작한다") - void shouldFallbackToDbWhenRedisIsDown() { + @DisplayName("레디스 장애가 기록되면 DB에서 데이터를 정상적으로 조회하고 서킷 브레이커가 동작한다") + void shouldFallbackToDbWhenRedisFailureRecorded() { // given - RedisTestContainersConfig.stop(); // Redis 중단 시뮬레이션 + Cache cache = mock(Cache.class); + given(cache.getName()).willReturn("question"); + RedisConnectionFailureException exception = new RedisConnectionFailureException("Redis connection failed"); + + // 레디스 실패 5회 기록으로 최소 호출 횟수를 채워 서킷 OPEN 처리 + for (int i = 0; i < 5; i++) { + resilientCacheErrorHandler.handleCacheGetError(exception, cache, "RECRUIT:" + recruit.getId()); + } - // when: 캐시가 적용된 서비스 메서드 호출 - // Redis가 죽어있으므로 CacheErrorHandler가 예외를 잡고, DB에서 데이터를 가져와야 함 + // when QuestionResponses response = questionService.findQuestions(recruit.getId()); // then @@ -107,8 +118,8 @@ void shouldFallbackToDbWhenRedisIsDown() { assertThat(response.questionResponses()).isNotEmpty(); assertThat(response.questionResponses().get(0).title()).isEqualTo("title1"); - // 서킷 브레이커 상태 확인 최소 호출 횟수(5회)를 채워야 상태가 변하지만, 에러가 기록되었는지는 확인할 수 있음 CircuitBreaker breaker = circuitBreakerProvider.get("question"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); assertThat(breaker.getMetrics().getNumberOfFailedCalls()).isPositive(); } @@ -120,8 +131,7 @@ void shouldNotSwallowNonInfrastructureExceptions() { // expected // ErrorHandler가 이 예외를 다시 던지는지 확인 - ResilientCacheErrorHandler errorHandler = new ResilientCacheErrorHandler(circuitBreakerProvider); - assertThatThrownBy(() -> errorHandler.handleCacheGetError(serializationException, null, "key")) + assertThatThrownBy(() -> resilientCacheErrorHandler.handleCacheGetError(serializationException, null, "key")) .isSameAs(serializationException); // 서킷 브레이커에 실패가 기록되지 않았어야 함 diff --git a/src/test/java/org/ject/support/domain/member/ActivityStatusTest.java b/src/test/java/org/ject/support/domain/member/ActivityStatusTest.java new file mode 100644 index 00000000..3a3f9c3e --- /dev/null +++ b/src/test/java/org/ject/support/domain/member/ActivityStatusTest.java @@ -0,0 +1,81 @@ +package org.ject.support.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ActivityStatusTest { + + @Test + @DisplayName("일반 구성원은 활동 중 완주 탈퇴 상태를 사용할 수 있다") + void 일반_구성원은_활동_중_완주_탈퇴_상태를_사용할_수_있다() { + // given + List statuses = List.of( + ActivityStatus.ACTIVE, + ActivityStatus.COMPLETED, + ActivityStatus.WITHDRAWN + ); + + // when + boolean result = ActivityStatus.isAllAvailableFor(statuses, MemberType.SEMESTER); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("일반 구성원은 활동 종료 중도 이탈 상태를 사용할 수 없다") + void 일반_구성원은_활동_종료_중도_이탈_상태를_사용할_수_없다() { + // given + List statuses = List.of( + ActivityStatus.ENDED, + ActivityStatus.DROPOUT + ); + + // when + boolean result = ActivityStatus.isAllAvailableFor(statuses, MemberType.SEMESTER); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("메이커스와 운영은 활동 중 활동 종료 중도 이탈 상태를 사용할 수 있다") + void 메이커스와_운영은_활동_중_활동_종료_중도_이탈_상태를_사용할_수_있다() { + // given + List statuses = List.of( + ActivityStatus.ACTIVE, + ActivityStatus.ENDED, + ActivityStatus.DROPOUT + ); + + // when + boolean makersResult = ActivityStatus.isAllAvailableFor(statuses, MemberType.MAKERS); + boolean supportersResult = ActivityStatus.isAllAvailableFor(statuses, MemberType.SUPPORTERS); + + // then + assertThat(makersResult).isTrue(); + assertThat(supportersResult).isTrue(); + } + + @Test + @DisplayName("메이커스와 운영은 완주 탈퇴 상태를 사용할 수 없다") + void 메이커스와_운영은_완주_탈퇴_상태를_사용할_수_없다() { + // given + List statuses = List.of( + ActivityStatus.COMPLETED, + ActivityStatus.WITHDRAWN + ); + + // when + boolean makersResult = ActivityStatus.isAllAvailableFor(statuses, MemberType.MAKERS); + boolean supportersResult = ActivityStatus.isAllAvailableFor(statuses, MemberType.SUPPORTERS); + + // then + assertThat(makersResult).isFalse(); + assertThat(supportersResult).isFalse(); + } +} diff --git a/src/test/java/org/ject/support/domain/member/entity/MemberActivityTest.java b/src/test/java/org/ject/support/domain/member/entity/MemberActivityTest.java index fc6beecc..ccde5b96 100644 --- a/src/test/java/org/ject/support/domain/member/entity/MemberActivityTest.java +++ b/src/test/java/org/ject/support/domain/member/entity/MemberActivityTest.java @@ -1,6 +1,7 @@ package org.ject.support.domain.member.entity; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; import static org.ject.support.domain.member.fixture.SemesterActivityFixture.semesterActivity; import org.ject.support.domain.member.ActivityStatus; @@ -8,6 +9,8 @@ import org.ject.support.domain.member.ExperiencePeriod; import org.ject.support.domain.member.JobFamily; import org.ject.support.domain.member.MemberType; +import org.ject.support.domain.member.exception.MemberErrorCode; +import org.ject.support.domain.member.exception.MemberException; import org.ject.support.domain.recruit.domain.RecruitTypeDetail; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -73,4 +76,22 @@ class MemberActivityTest { // then assertThat(memberActivity.getMemberSemester().getTeamId()).isNull(); } + + @Test + @DisplayName("일반 구성원에서 사용할 수 없는 활동 상태로 변경하면 예외가 발생한다") + void 일반_구성원에서_사용할_수_없는_활동_상태로_변경하면_예외가_발생한다() { + // given + MemberActivity memberActivity = semesterActivity().build(); + + // when + Throwable throwable = catchThrowable(() -> + memberActivity.updateActivityStatus(ActivityStatus.DROPOUT) + ); + + // then + assertThat(throwable) + .isInstanceOf(MemberException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.INVALID_ACTIVITY_STATUS); + } } diff --git a/src/test/java/org/ject/support/domain/member/fixture/SemesterActivityFixture.java b/src/test/java/org/ject/support/domain/member/fixture/SemesterActivityFixture.java index 4ee1614b..d43be354 100644 --- a/src/test/java/org/ject/support/domain/member/fixture/SemesterActivityFixture.java +++ b/src/test/java/org/ject/support/domain/member/fixture/SemesterActivityFixture.java @@ -3,6 +3,7 @@ import org.ject.support.domain.member.CareerDetails; import org.ject.support.domain.member.ExperiencePeriod; import org.ject.support.domain.member.JobFamily; +import org.ject.support.domain.member.ActivityStatus; import org.ject.support.domain.member.entity.MemberActivity; import org.ject.support.domain.recruit.domain.RecruitTypeDetail; @@ -13,6 +14,7 @@ public final class SemesterActivityFixture { private RecruitTypeDetail recruitTypeDetail = RecruitTypeDetail.REGULAR; private CareerDetails careerDetails = CareerDetails.EMPLOYEE; private ExperiencePeriod experiencePeriod = ExperiencePeriod.ONE_TO_TWO; + private ActivityStatus activityStatus = ActivityStatus.ACTIVE; private String memo = "테스트 메모"; private Long semesterId = 2L; private Long teamId = 3L; @@ -49,6 +51,11 @@ public SemesterActivityFixture experiencePeriod(ExperiencePeriod experiencePerio return this; } + public SemesterActivityFixture activityStatus(ActivityStatus activityStatus) { + this.activityStatus = activityStatus; + return this; + } + public SemesterActivityFixture memo(String memo) { this.memo = memo; return this; @@ -65,7 +72,7 @@ public SemesterActivityFixture teamId(Long teamId) { } public MemberActivity build() { - return MemberActivity.createSemesterActivity( + MemberActivity memberActivity = MemberActivity.createSemesterActivity( memberId, jobFamily, recruitTypeDetail, @@ -75,5 +82,7 @@ public MemberActivity build() { semesterId, teamId ); + memberActivity.updateActivityStatus(activityStatus); + return memberActivity; } } diff --git a/src/test/java/org/ject/support/domain/member/repository/MemberActivityRepositoryTest.java b/src/test/java/org/ject/support/domain/member/repository/MemberActivityRepositoryTest.java index 4163a84c..f64f9d50 100644 --- a/src/test/java/org/ject/support/domain/member/repository/MemberActivityRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/member/repository/MemberActivityRepositoryTest.java @@ -5,11 +5,20 @@ import static org.ject.support.domain.member.fixture.SemesterActivityFixture.semesterActivity; import jakarta.persistence.EntityManager; +import java.util.List; +import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection; +import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition; +import org.ject.support.domain.member.ActivityStatus; +import org.ject.support.domain.member.CareerDetails; +import org.ject.support.domain.member.ExperiencePeriod; +import org.ject.support.domain.member.JobFamily; import org.ject.support.domain.member.MemberType; import org.ject.support.domain.member.entity.Member; import org.ject.support.domain.member.entity.MemberActivity; import org.ject.support.domain.member.entity.MemberSemester; +import org.ject.support.domain.recruit.domain.RecruitTypeDetail; import org.ject.support.testconfig.QueryDslTestConfig; +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.orm.jpa.DataJpaTest; @@ -30,7 +39,76 @@ class MemberActivityRepositoryTest { @Autowired EntityManager entityManager; + private MemberSemesterSearchCondition searchCondition( + Long cursor, + Integer size, + Long semesterId, + List jobFamilies, + List recruitTypeDetails, + List careerDetails, + List teamIds, + List statuses + ) { + return new MemberSemesterSearchCondition( + cursor, + size, + semesterId, + jobFamilies, + recruitTypeDetails, + careerDetails, + teamIds, + statuses + ); + } + + private MemberActivity saveSemesterActivity( + String email, + Long semesterId, + Long teamId, + JobFamily jobFamily, + RecruitTypeDetail recruitTypeDetail, + CareerDetails careerDetails, + ExperiencePeriod experiencePeriod, + ActivityStatus activityStatus + ) { + Member member = memberRepository.save(member().email(email).build()); + return memberActivityRepository.saveAndFlush(semesterActivity() + .memberId(member.getId()) + .semesterId(semesterId) + .teamId(teamId) + .jobFamily(jobFamily) + .recruitTypeDetail(recruitTypeDetail) + .careerDetails(careerDetails) + .experiencePeriod(experiencePeriod) + .activityStatus(activityStatus) + .build()); + } + + private MemberActivity saveSemesterActivity( + String email, + Long semesterId, + Long teamId, + JobFamily jobFamily, + RecruitTypeDetail recruitTypeDetail, + CareerDetails careerDetails, + ExperiencePeriod experiencePeriod + ) { + return saveSemesterActivity( + email, + semesterId, + teamId, + jobFamily, + recruitTypeDetail, + careerDetails, + experiencePeriod, + ActivityStatus.ACTIVE + ); + } + /** + * 일반 구성원 추가 테스트 + */ @Test + @DisplayName("일반 구성원 활동을 저장하면 하위 테이블에 기수 정보를 함께 저장한다") void 일반_구성원_활동을_저장하면_하위_테이블에_기수_정보를_함께_저장한다() { // given Member member = memberRepository.save(member().email("member@test.com").build()); @@ -56,6 +134,7 @@ class MemberActivityRepositoryTest { } @Test + @DisplayName("같은 구성원의 동일 기수 활동이 존재하면 true를 반환한다") void 같은_구성원의_동일_기수_활동이_존재하면_true를_반환한다() { // given Member member = memberRepository.save(member().email("member@test.com").build()); @@ -76,6 +155,7 @@ class MemberActivityRepositoryTest { } @Test + @DisplayName("다른 기수의 활동만 존재하면 false를 반환한다") void 다른_기수의_활동만_존재하면_false를_반환한다() { // given Member member = memberRepository.save(member().email("member@test.com").build()); @@ -96,6 +176,7 @@ class MemberActivityRepositoryTest { } @Test + @DisplayName("다른 구성원의 활동만 존재하면 false를 반환한다") void 다른_구성원의_활동만_존재하면_false를_반환한다() { // given Member member = memberRepository.save(member().email("member@test.com").build()); @@ -117,6 +198,7 @@ class MemberActivityRepositoryTest { } @Test + @DisplayName("다른 구성원 유형으로 조회하면 false를 반환한다") void 다른_구성원_유형으로_조회하면_false를_반환한다() { // given Member member = memberRepository.save(member().email("member@test.com").build()); @@ -135,4 +217,454 @@ class MemberActivityRepositoryTest { // then assertThat(result).isFalse(); } + + /** + * 일반 구성원 목록 조회 테스트 + */ + // 목록 조회 (필터 X) + @Test + void 일반_구성원_목록을_최신순으로_조회한다() { + // given + MemberActivity first = saveSemesterActivity( + "member1@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberActivity second = saveSemesterActivity( + "member2@test.com", + SEMESTER_ID, + 1L, + JobFamily.FE, + RecruitTypeDetail.REGULAR, + CareerDetails.STUDENT, + ExperiencePeriod.NONE + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + null, + null, + null, + null, + null, + null + ); + + // when + List results = + memberActivityRepository.searchMemberSemesters(condition, 30); + + // then + assertThat(results) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(second.getId(), first.getId()); + } + + // 목록 조회(커서) + @Test + @DisplayName("커서보다 작은 일반 구성원 목록을 조회한다") + void 커서보다_작은_일반_구성원_목록을_조회한다() { + // given + MemberActivity first = saveSemesterActivity( + "member1@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberActivity second = saveSemesterActivity( + "member2@test.com", + SEMESTER_ID, + 1L, + JobFamily.FE, + RecruitTypeDetail.REGULAR, + CareerDetails.STUDENT, + ExperiencePeriod.NONE + ); + MemberSemesterSearchCondition condition = searchCondition( + second.getId(), + 30, + null, + null, + null, + null, + null, + null + ); + + // when + List results = + memberActivityRepository.searchMemberSemesters(condition, 30); + + // then + assertThat(results) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(first.getId()); + } + + // 목록 조회(전체 필터 조합) + @Test + @DisplayName("전체 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 전체_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-job@test.com", + SEMESTER_ID, + 1L, + JobFamily.FE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-semester@test.com", + 6L, + 2L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + SEMESTER_ID, + List.of(JobFamily.BE), + List.of(RecruitTypeDetail.REGULAR), + List.of(CareerDetails.EMPLOYEE), + List.of(1L), + null + ); + + // when + List results = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + + // then + assertThat(results) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + } + + // 목록 조회(기수 필터) + @Test + @DisplayName("기수 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 기수_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-semester@test.com", + 10L, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + SEMESTER_ID, + null, + null, + null, + null, + null + ); + // when + List result = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + // then + assertThat(result) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + } + + // 목록 조회(직군 필터) + @Test + @DisplayName("직군 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 직군_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-job-family@test.com", + SEMESTER_ID, + 1L, + JobFamily.FE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + null, + List.of(JobFamily.BE), + null, + null, + null, + null + ); + // when + List result = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + // then + assertThat(result) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + } + // 목록 조회(모집 단위 필터) + @Test + @DisplayName("모집 단위 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 모집_단위_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-recruit-type@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REFILL, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + null, + null, + List.of(RecruitTypeDetail.REGULAR), + null, + null, + null + ); + // when + List result = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + // then + assertThat(result) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + } + // 목록 조회(신분 필터) + @Test + @DisplayName("신분 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 신분_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-career@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.STUDENT, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + null, + null, + null, + List.of(CareerDetails.EMPLOYEE), + null, + null + ); + // when + List result = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + // then + assertThat(result) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + } + // 목록 조회(팀 필터) + @Test + @DisplayName("팀 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 팀_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "other-team@test.com", + SEMESTER_ID, + 4L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + null, + null, + null, + null, + List.of(1L,2L,3L), + null + ); + // when + List result = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + // then + assertThat(result) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + } + + // 목록 조회(활동 상태 필터) + @Test + @DisplayName("활동 상태 필터 조건에 맞는 일반 구성원 목록을 조회한다") + void 활동_상태_필터_조건에_맞는_일반_구성원_목록을_조회한다() { + // given + MemberActivity matched = saveSemesterActivity( + "matched@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO, + ActivityStatus.COMPLETED + ); + saveSemesterActivity( + "other-status@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO, + ActivityStatus.ACTIVE + ); + MemberSemesterSearchCondition condition = searchCondition( + null, + 30, + null, + null, + null, + null, + null, + List.of(ActivityStatus.COMPLETED) + ); + + // when + List result = + memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1); + + // then + assertThat(result) + .extracting(SearchMemberSemesterProjection::memberActivityId) + .containsExactly(matched.getId()); + assertThat(result) + .extracting(SearchMemberSemesterProjection::status) + .containsExactly(ActivityStatus.COMPLETED); + } + + @Test + void 카운트_조회는_커서_조건을_적용하지_않는다() { + // given + saveSemesterActivity( + "member1@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberActivity cursor = saveSemesterActivity( + "member2@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + saveSemesterActivity( + "member3@test.com", + SEMESTER_ID, + 1L, + JobFamily.BE, + RecruitTypeDetail.REGULAR, + CareerDetails.EMPLOYEE, + ExperiencePeriod.ONE_TO_TWO + ); + MemberSemesterSearchCondition condition = searchCondition( + cursor.getId(), + 30, + SEMESTER_ID, + List.of(JobFamily.BE), + List.of(RecruitTypeDetail.REGULAR), + List.of(CareerDetails.EMPLOYEE), + List.of(1L), + null + ); + + // when + List results = + memberActivityRepository.searchMemberSemesters(condition, 30); + long totalCount = memberActivityRepository.countMemberSemesters(condition); + + // then + assertThat(results).hasSize(1); + assertThat(totalCount).isEqualTo(3L); + } } diff --git a/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java b/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java index b77c5267..7ba51d1b 100644 --- a/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java +++ b/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java @@ -6,7 +6,6 @@ import org.ject.support.domain.recruit.domain.Question; import org.ject.support.domain.recruit.domain.Recruit; import org.ject.support.domain.recruit.domain.Semester; -import org.ject.support.domain.recruit.repository.QuestionRepository; import org.ject.support.domain.recruit.repository.RecruitRepository; import org.ject.support.domain.recruit.repository.SemesterRepository; import org.ject.support.testconfig.AuthenticatedUser; @@ -21,6 +20,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -43,9 +43,6 @@ class QuestionControllerTest { @Autowired RecruitRepository recruitRepository; - @Autowired - QuestionRepository questionRepository; - @Autowired MemberRepository memberRepository; @@ -60,7 +57,7 @@ class QuestionControllerTest { @BeforeEach void setUp() { - String uniqueSuffix = String.valueOf(System.nanoTime()); + String uniqueSuffix = uniqueSuffix(); List questions = List.of( Question.builder().sequence(1).inputType(TEXT).isRequired(true).title("title1").label("label").selectOptions(List.of("a", "b", "c")).build(), Question.builder().sequence(2).inputType(TEXT).isRequired(true).title("title2").label("label").build(), @@ -88,13 +85,20 @@ void setUp() { recruitRepository.save(recruit); member = member() - .email("test_" + uniqueSuffix + "@gmail.com") + .email("test_" + uniqueSuffix + "@t.kr") .interestedDomains(List.of()) .region(null) .build(); memberRepository.save(member); } + private String uniqueSuffix() { + return UUID.randomUUID() + .toString() + .replace("-", "") + .substring(0, 8); + } + @Test @AuthenticatedUser void 지원서_문항_조회_시_redis에_캐싱한다() throws Exception {