diff --git a/techeerzip/build.gradle b/techeerzip/build.gradle index 2eaf6ee9..3adc7eb8 100644 --- a/techeerzip/build.gradle +++ b/techeerzip/build.gradle @@ -48,11 +48,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-aop' - // QueryDSL - implementation 'com.querydsl:querydsl-core:5.0.0' - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + // QueryDSL (CVE-2024-49203 수정 버전) + implementation 'com.querydsl:querydsl-core:5.1.0' + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' @@ -81,9 +81,18 @@ dependencies { testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:postgresql' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Testcontainers + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + testImplementation 'org.testcontainers:postgresql:1.19.3' // SpringDoc OpenAPI (테스트 용도) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // 보안 취약점 해결을 위한 전이 의존성 버전 지정 + implementation 'org.apache.commons:commons-compress:1.28.0' + implementation 'org.apache.commons:commons-lang3:3.18.0' // S3 implementation "software.amazon.awssdk:s3:2.31.32" diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapper.java b/techeerzip/src/main/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapper.java index dc0d2804..7dcfca11 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapper.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapper.java @@ -36,14 +36,19 @@ public static List toEntities( } return incomingMembersInfo.stream() .map( - info -> - StudyMember.builder() - .user(users.get(info.getUserId())) - .studyTeam(team) - .isLeader(info.getIsLeader()) - .status(StatusCategory.APPROVED) - .summary(DEFAULT_MEMBER_SUMMARY) - .build()) + info -> { + User user = users.get(info.getUserId()); + if (user == null) { + throw new StudyMemberBadRequestException(); + } + return StudyMember.builder() + .user(user) + .studyTeam(team) + .isLeader(info.getIsLeader()) + .status(StatusCategory.APPROVED) + .summary(DEFAULT_MEMBER_SUMMARY) + .build(); + }) .toList(); } } diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java index 144e79ba..a3b747b4 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java @@ -1,457 +1,428 @@ package backend.techeerzip.domain.studyMember.entity; -import static org.junit.jupiter.api.Assertions.*; - -import java.time.LocalDateTime; - +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.studyTeam.entity.StudyTeam; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.global.entity.StatusCategory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import backend.techeerzip.domain.studyTeam.entity.StudyTeam; -import backend.techeerzip.domain.user.entity.User; -import backend.techeerzip.global.entity.StatusCategory; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; -@DisplayName("StudyMember 엔티티 테스트") +@DisplayName("StudyMember 엔티티 단위 테스트") class StudyMemberTest { + private StudyTeam studyTeam; + private User user; private StudyMember studyMember; - private StudyTeam mockStudyTeam; - private User mockUser; @BeforeEach void setUp() { - // Mock 엔티티 생성 (실제 의존성 없이 테스트) - mockStudyTeam = - StudyTeam.builder() - .name("테스트 스터디") - .studyExplain("테스트 설명") - .isRecruited(true) - .build(); - - mockUser = - User.builder() - .name("홍길동") - .email("test@test.com") - .password("password") - .year(21) - .build(); - - studyMember = - StudyMember.builder() - .isLeader(false) - .summary("스터디 멤버입니다.") - .status(StatusCategory.APPROVED) - .studyTeam(mockStudyTeam) - .user(mockUser) - .build(); + // StudyTeam mock 객체 생성 + studyTeam = StudyTeam.builder() + .name("테스트 스터디팀") + .githubLink("https://github.com/test") + .notionLink("https://notion.so/test") + .studyExplain("스터디 설명") + .goal("목표") + .rule("규칙") + .recruitNum(5) + .recruitExplain("모집 설명") + .isRecruited(true) + .isFinished(false) + .build(); + + // User mock 객체 생성 + Role role = new Role("테스트 역할"); + user = User.builder() + .name("테스트 사용자") + .email("test@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/user") + .mainPosition("백엔드") + .school("테스트 학교") + .profileImage("profile.jpg") + .isAuth(true) + .role(role) + .grade("1학년") + .build(); + + // StudyMember 생성 + studyMember = StudyMember.builder() + .isLeader(false) + .summary("스터디 지원 요약") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user) + .build(); } - @Nested - @DisplayName("엔티티 생성 테스트") - class CreateTest { - - @Test - @DisplayName("StudyMember 빌더로 정상 생성") - void createStudyMember() { - assertNotNull(studyMember); - assertFalse(studyMember.isLeader()); - assertEquals("스터디 멤버입니다.", studyMember.getSummary()); - assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); - assertFalse(studyMember.isDeleted()); - } - - @Test - @DisplayName("기본값 검증 - isDeleted는 false") - void defaultIsDeletedIsFalse() { - assertFalse(studyMember.isDeleted()); - } - - @Test - @DisplayName("연관관계 설정 검증") - void relationshipSetup() { - assertEquals(mockStudyTeam, studyMember.getStudyTeam()); - assertEquals(mockUser, studyMember.getUser()); + @Test + @DisplayName("update 메서드 - summary와 status를 업데이트하고 updatedAt이 변경되는지 확인") + void update() { + // given + String newSummary = "업데이트된 요약"; + StatusCategory newStatus = StatusCategory.APPROVED; + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.update(newSummary, newStatus); + + // then + assertEquals(newSummary, studyMember.getSummary()); + assertEquals(newStatus, studyMember.getStatus()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } } - @Nested - @DisplayName("update 메서드 테스트") - class UpdateTest { - - @Test - @DisplayName("요약과 상태를 업데이트하면 필드 값이 변경됨") - void updateSummaryAndStatus() { - String newSummary = "새로운 자기소개입니다."; - StatusCategory newStatus = StatusCategory.PENDING; + @Test + @DisplayName("isDeleted 메서드 - isDeleted 필드 반환 확인") + void isDeleted() { + // given + assertFalse(studyMember.isDeleted()); - studyMember.update(newSummary, newStatus); - - assertEquals(newSummary, studyMember.getSummary()); - assertEquals(newStatus, studyMember.getStatus()); - } + // when + studyMember.softDelete(); - @Test - @DisplayName("update 호출 시 updatedAt이 갱신됨") - void updateUpdatesTimestamp() { - LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); - - // 시간 차이를 만들기 위해 잠시 대기 - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - studyMember.update("새 요약", StatusCategory.REJECT); - - assertNotNull(studyMember.getUpdatedAt()); - // updatedAt이 변경되었는지 확인 (null이 아닌 경우에만) - if (beforeUpdate != null) { - assertTrue( - studyMember.getUpdatedAt().isAfter(beforeUpdate) - || studyMember.getUpdatedAt().equals(beforeUpdate)); - } - } + // then + assertTrue(studyMember.isDeleted()); } - @Nested - @DisplayName("changeLeaderStatus 메서드 테스트") - class ChangeLeaderStatusTest { - - @Test - @DisplayName("리더 상태를 true로 변경") - void changeToLeader() { - assertFalse(studyMember.isLeader()); - - studyMember.changeLeaderStatus(true); - - assertTrue(studyMember.isLeader()); + @Test + @DisplayName("changeLeaderStatus 메서드 - isLeader 변경 및 updatedAt 업데이트 확인") + void changeLeaderStatus() { + // given + assertFalse(studyMember.isLeader()); + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.changeLeaderStatus(true); + + // then + assertTrue(studyMember.isLeader()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } - @Test - @DisplayName("리더 상태를 false로 변경") - void changeToMember() { - studyMember.changeLeaderStatus(true); // 먼저 리더로 변경 - assertTrue(studyMember.isLeader()); + // when - 다시 false로 변경 + studyMember.changeLeaderStatus(false); - studyMember.changeLeaderStatus(false); - - assertFalse(studyMember.isLeader()); - } - - @Test - @DisplayName("리더 상태 변경 시 updatedAt 갱신") - void changeLeaderStatusUpdatesTimestamp() { - studyMember.changeLeaderStatus(true); - assertNotNull(studyMember.getUpdatedAt()); - } + // then + assertFalse(studyMember.isLeader()); } - @Nested - @DisplayName("softDelete 메서드 테스트") - class SoftDeleteTest { - - @Test - @DisplayName("소프트 삭제 시 isDeleted가 true로 변경") - void softDeleteChangesIsDeletedToTrue() { - assertFalse(studyMember.isDeleted()); - - studyMember.softDelete(); - - assertTrue(studyMember.isDeleted()); + @Test + @DisplayName("softDelete 메서드 - isDeleted가 false일 때만 true로 변경") + void softDelete() { + // given + assertFalse(studyMember.isDeleted()); + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.softDelete(); + + // then + assertTrue(studyMember.isDeleted()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } - @Test - @DisplayName("이미 삭제된 경우 중복 삭제해도 문제 없음") - void softDeleteAlreadyDeletedMember() { - studyMember.softDelete(); - assertTrue(studyMember.isDeleted()); + // when - 이미 삭제된 상태에서 다시 호출 + LocalDateTime secondUpdate = studyMember.getUpdatedAt(); + studyMember.softDelete(); - // 중복 삭제 시도 - studyMember.softDelete(); - - assertTrue(studyMember.isDeleted()); // 여전히 삭제 상태 - } + // then - 상태가 변경되지 않아야 함 + assertTrue(studyMember.isDeleted()); + assertEquals(secondUpdate, studyMember.getUpdatedAt()); + } - @Test - @DisplayName("소프트 삭제 시 updatedAt 갱신") - void softDeleteUpdatesTimestamp() { - studyMember.softDelete(); - assertNotNull(studyMember.getUpdatedAt()); + @Test + @DisplayName("toActive(Boolean) 메서드 - isLeader 설정, status를 APPROVED로, isDeleted를 false로 변경") + void testToActive() { + // given + studyMember.toReject(); // 먼저 다른 상태로 변경 + studyMember.softDelete(); // 삭제 상태로 변경 + assertTrue(studyMember.isDeleted()); + assertEquals(StatusCategory.REJECT, studyMember.getStatus()); + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.toActive(true); + + // then + assertTrue(studyMember.isLeader()); + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + assertFalse(studyMember.isDeleted()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } } - @Nested - @DisplayName("toActive 메서드 테스트") - class ToActiveTest { - - @Test - @DisplayName("toActive(Boolean) 호출 시 리더 상태와 함께 활성화") - void toActiveWithLeaderStatus() { - studyMember = - StudyMember.builder() - .isLeader(false) - .summary("테스트") - .status(StatusCategory.PENDING) - .studyTeam(mockStudyTeam) - .user(mockUser) - .build(); - studyMember.softDelete(); // 삭제 상태로 만듦 - - studyMember.toActive(true); // 리더로 활성화 - - assertTrue(studyMember.isLeader()); - assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); - assertFalse(studyMember.isDeleted()); + @Test + @DisplayName("toActive() 메서드 - status를 APPROVED로, isDeleted를 false로 변경") + void toActive() { + // given + studyMember.toReject(); // 먼저 다른 상태로 변경 + studyMember.softDelete(); // 삭제 상태로 변경 + assertTrue(studyMember.isDeleted()); + assertEquals(StatusCategory.REJECT, studyMember.getStatus()); + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.toActive(); + + // then + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + assertFalse(studyMember.isDeleted()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } + } - @Test - @DisplayName("toActive() 호출 시 리더 상태 유지하고 활성화") - void toActiveWithoutLeaderChange() { - studyMember = - StudyMember.builder() - .isLeader(true) - .summary("테스트") - .status(StatusCategory.PENDING) - .studyTeam(mockStudyTeam) - .user(mockUser) - .build(); - studyMember.softDelete(); // 삭제 상태로 만듦 - - studyMember.toActive(); // 리더 상태 유지하면서 활성화 - - assertTrue(studyMember.isLeader()); // 리더 상태 유지 - assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); - assertFalse(studyMember.isDeleted()); + @Test + @DisplayName("toApplicant 메서드 - status를 PENDING으로 변경") + void toApplicant() { + // given + studyMember.toActive(); // 먼저 APPROVED 상태로 변경 + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.toApplicant(); + + // then + assertEquals(StatusCategory.PENDING, studyMember.getStatus()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } + } - @Test - @DisplayName("toActive 호출 시 updatedAt 갱신") - void toActiveUpdatesTimestamp() { - studyMember.toActive(false); - assertNotNull(studyMember.getUpdatedAt()); + @Test + @DisplayName("toReject 메서드 - status를 REJECT로 변경") + void toReject() { + // given + studyMember.toActive(); // 먼저 APPROVED 상태로 변경 + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // when + studyMember.toReject(); + + // then + assertEquals(StatusCategory.REJECT, studyMember.getStatus()); + assertNotNull(studyMember.getUpdatedAt()); + if (beforeUpdate != null) { + assertTrue(studyMember.getUpdatedAt().isAfter(beforeUpdate) || + studyMember.getUpdatedAt().equals(beforeUpdate)); } } - @Nested - @DisplayName("toApplicant 메서드 테스트") - class ToApplicantTest { + @Test + @DisplayName("isRejected 메서드 - status가 REJECT인지 확인") + void isRejected() { + // given + assertFalse(studyMember.isRejected()); - @Test - @DisplayName("toApplicant 호출 시 상태가 PENDING으로 변경") - void toApplicantChangeStatusToPending() { - assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + // when + studyMember.toReject(); - studyMember.toApplicant(); + // then + assertTrue(studyMember.isRejected()); - assertEquals(StatusCategory.PENDING, studyMember.getStatus()); - } + // when - 다른 상태로 변경 + studyMember.toActive(); - @Test - @DisplayName("toApplicant 호출 시 updatedAt 갱신") - void toApplicantUpdatesTimestamp() { - studyMember.toApplicant(); - assertNotNull(studyMember.getUpdatedAt()); - } + // then + assertFalse(studyMember.isRejected()); } - @Nested - @DisplayName("toReject 메서드 테스트") - class ToRejectTest { + @Test + @DisplayName("isPending 메서드 - status가 PENDING인지 확인") + void isPending() { + // given + assertTrue(studyMember.isPending()); - @Test - @DisplayName("toReject 호출 시 상태가 REJECT로 변경") - void toRejectChangeStatusToReject() { - assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + // when + studyMember.toActive(); - studyMember.toReject(); + // then + assertFalse(studyMember.isPending()); - assertEquals(StatusCategory.REJECT, studyMember.getStatus()); - } + // when - 다시 PENDING으로 변경 + studyMember.toApplicant(); - @Test - @DisplayName("toReject 호출 시 updatedAt 갱신") - void toRejectUpdatesTimestamp() { - studyMember.toReject(); - assertNotNull(studyMember.getUpdatedAt()); - } + // then + assertTrue(studyMember.isPending()); } - @Nested - @DisplayName("상태 확인 메서드 테스트") - class StatusCheckTest { - - @Test - @DisplayName("isActive - APPROVED이고 삭제되지 않은 경우 true") - void isActiveWhenApprovedAndNotDeleted() { - studyMember = - StudyMember.builder() - .isLeader(false) - .summary("테스트") - .status(StatusCategory.APPROVED) - .studyTeam(mockStudyTeam) - .user(mockUser) - .build(); - - assertTrue(studyMember.isActive()); - } + @Test + @DisplayName("isActive 메서드 - isDeleted가 false이고 status가 APPROVED인지 확인") + void isActive() { + // given + assertFalse(studyMember.isActive()); // PENDING 상태이므로 false - @Test - @DisplayName("isActive - 삭제된 경우 false") - void isActiveWhenDeleted() { - studyMember.softDelete(); + // when + studyMember.toActive(); - assertFalse(studyMember.isActive()); - } + // then + assertTrue(studyMember.isActive()); - @Test - @DisplayName("isActive - PENDING 상태인 경우 false") - void isActiveWhenPending() { - studyMember.toApplicant(); + // when - 삭제 상태로 변경 + studyMember.softDelete(); - assertFalse(studyMember.isActive()); - } + // then + assertFalse(studyMember.isActive()); // 삭제되었으므로 false - @Test - @DisplayName("isPending - PENDING 상태인 경우 true") - void isPendingWhenStatusIsPending() { - studyMember.toApplicant(); + // when - 다시 활성화 + studyMember.toActive(); - assertTrue(studyMember.isPending()); - } + // then + assertTrue(studyMember.isActive()); + } - @Test - @DisplayName("isPending - APPROVED 상태인 경우 false") - void isPendingWhenStatusIsApproved() { - assertFalse(studyMember.isPending()); - } + @Test + @DisplayName("getId 메서드 - id 반환 확인") + void getId() { + // given & when + // id는 JPA가 생성하므로 null일 수 있음 + // 실제로는 DB에 저장된 후에 id가 할당됨 + Long id = studyMember.getId(); + + // then + // JPA 컨텍스트 없이 생성된 엔티티는 id가 null일 수 있음 + // getId() 메서드가 정상적으로 호출되고 null을 반환할 수 있음을 확인 + assertNotNull(studyMember); + // id는 null일 수 있지만, getId() 메서드는 예외 없이 호출되어야 함 + // JPA 컨텍스트 없이 생성된 엔티티는 id가 null일 수 있으므로 null 체크는 하지 않음 + assertTrue(id == null || id >= 0); + } - @Test - @DisplayName("isRejected - REJECT 상태인 경우 true") - void isRejectedWhenStatusIsReject() { - studyMember.toReject(); + @Test + @DisplayName("isLeader 메서드 - isLeader 반환 확인") + void isLeader() { + // given + assertFalse(studyMember.isLeader()); - assertTrue(studyMember.isRejected()); - } + // when + studyMember.changeLeaderStatus(true); - @Test - @DisplayName("isRejected - APPROVED 상태인 경우 false") - void isRejectedWhenStatusIsApproved() { - assertFalse(studyMember.isRejected()); - } + // then + assertTrue(studyMember.isLeader()); } - @Nested - @DisplayName("상태 전이 시나리오 테스트") - class StateTransitionTest { - - @Test - @DisplayName("지원 → 승인 → 재지원 → 거절 플로우") - void fullLifecycleTest() { - // 1. 최초 지원 (PENDING) - StudyMember applicant = - StudyMember.builder() - .isLeader(false) - .summary("지원합니다") - .status(StatusCategory.PENDING) - .studyTeam(mockStudyTeam) - .user(mockUser) - .build(); - assertTrue(applicant.isPending()); - assertFalse(applicant.isActive()); - - // 2. 승인 (APPROVED) - applicant.toActive(); - assertFalse(applicant.isPending()); - assertTrue(applicant.isActive()); - assertEquals(StatusCategory.APPROVED, applicant.getStatus()); - - // 3. 다른 팀에 재지원 (PENDING으로 변경) - applicant.toApplicant(); - assertTrue(applicant.isPending()); - assertFalse(applicant.isActive()); - - // 4. 거절 (REJECT) - applicant.toReject(); - assertTrue(applicant.isRejected()); - assertFalse(applicant.isActive()); - assertFalse(applicant.isPending()); - } - - @Test - @DisplayName("리더 승급 시나리오") - void leaderPromotionScenario() { - // 일반 멤버로 시작 - assertFalse(studyMember.isLeader()); - assertTrue(studyMember.isActive()); - - // 리더로 승급 - studyMember.changeLeaderStatus(true); - assertTrue(studyMember.isLeader()); - assertTrue(studyMember.isActive()); // 여전히 활성 상태 - - // 다시 일반 멤버로 - studyMember.changeLeaderStatus(false); - assertFalse(studyMember.isLeader()); - assertTrue(studyMember.isActive()); // 여전히 활성 상태 - } + @Test + @DisplayName("getSummary 메서드 - summary 반환 확인") + void getSummary() { + // given + String expectedSummary = "스터디 지원 요약"; - @Test - @DisplayName("탈퇴 후 재가입 시나리오") - void withdrawAndRejoinScenario() { - // 활성 멤버 - assertTrue(studyMember.isActive()); - - // 탈퇴 (소프트 삭제) - studyMember.softDelete(); - assertFalse(studyMember.isActive()); - assertTrue(studyMember.isDeleted()); - - // 재가입 (다시 활성화) - studyMember.toActive(false); - assertTrue(studyMember.isActive()); - assertFalse(studyMember.isDeleted()); - assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); - } - } + // when & then + assertEquals(expectedSummary, studyMember.getSummary()); - @Nested - @DisplayName("엣지 케이스 테스트") - class EdgeCaseTest { + // when - 업데이트 + String newSummary = "새로운 요약"; + studyMember.update(newSummary, StatusCategory.APPROVED); - @Test - @DisplayName("summary가 최대 길이(3000자)인 경우") - void maximumSummaryLength() { - String longSummary = "a".repeat(3000); - studyMember.update(longSummary, StatusCategory.APPROVED); + // then + assertEquals(newSummary, studyMember.getSummary()); + } - assertEquals(3000, studyMember.getSummary().length()); - } + @Test + @DisplayName("getStatus 메서드 - status 반환 확인") + void getStatus() { + // given + assertEquals(StatusCategory.PENDING, studyMember.getStatus()); - @Test - @DisplayName("동일한 상태로 여러 번 변경해도 문제 없음") - void sameStatusMultipleTimes() { - studyMember.toApplicant(); - assertTrue(studyMember.isPending()); + // when + studyMember.toActive(); - studyMember.toApplicant(); // 중복 호출 - assertTrue(studyMember.isPending()); + // then + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + } - studyMember.toApplicant(); // 또 중복 호출 - assertTrue(studyMember.isPending()); - } + @Test + @DisplayName("getStudyTeam 메서드 - studyTeam 반환 확인") + void getStudyTeam() { + // when & then + assertNotNull(studyMember.getStudyTeam()); + assertEquals(studyTeam, studyMember.getStudyTeam()); + assertEquals("테스트 스터디팀", studyMember.getStudyTeam().getName()); + } - @Test - @DisplayName("삭제된 상태에서도 상태 변경 가능") - void statusChangeWhileDeleted() { - studyMember.softDelete(); - assertTrue(studyMember.isDeleted()); + @Test + @DisplayName("getUser 메서드 - user 반환 확인") + void getUser() { + // when & then + assertNotNull(studyMember.getUser()); + assertEquals(user, studyMember.getUser()); + assertEquals("테스트 사용자", studyMember.getUser().getName()); + } - studyMember.toApplicant(); - assertEquals(StatusCategory.PENDING, studyMember.getStatus()); - assertTrue(studyMember.isDeleted()); // 여전히 삭제 상태 - } + @Test + @DisplayName("builder 메서드 - 빌더 패턴으로 객체 생성 확인") + void builder() { + // given + StudyTeam newStudyTeam = StudyTeam.builder() + .name("새 스터디팀") + .githubLink("https://github.com/new") + .notionLink("https://notion.so/new") + .studyExplain("새 스터디 설명") + .goal("새 목표") + .rule("새 규칙") + .recruitNum(3) + .recruitExplain("새 모집 설명") + .isRecruited(false) + .isFinished(false) + .build(); + + Role newRole = new Role("새 역할"); + User newUser = User.builder() + .name("새 사용자") + .email("new@example.com") + .password("newpassword") + .isLft(true) + .githubUrl("https://github.com/newuser") + .mainPosition("프론트엔드") + .school("새 학교") + .profileImage("newprofile.jpg") + .isAuth(false) + .role(newRole) + .grade("2학년") + .build(); + + // when + StudyMember newStudyMember = StudyMember.builder() + .isLeader(true) + .summary("새 요약") + .status(StatusCategory.APPROVED) + .studyTeam(newStudyTeam) + .user(newUser) + .build(); + + // then + assertNotNull(newStudyMember); + assertTrue(newStudyMember.isLeader()); + assertEquals("새 요약", newStudyMember.getSummary()); + assertEquals(StatusCategory.APPROVED, newStudyMember.getStatus()); + assertEquals(newStudyTeam, newStudyMember.getStudyTeam()); + assertEquals(newUser, newStudyMember.getUser()); + assertFalse(newStudyMember.isDeleted()); } -} +} \ No newline at end of file diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapperTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapperTest.java new file mode 100644 index 00000000..31297664 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/mapper/StudyMemberMapperTest.java @@ -0,0 +1,245 @@ +package backend.techeerzip.domain.studyMember.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.studyMember.entity.StudyMember; +import backend.techeerzip.domain.studyMember.exception.StudyMemberBadRequestException; +import backend.techeerzip.domain.studyTeam.dto.request.StudyMemberInfoRequest; +import backend.techeerzip.domain.studyTeam.entity.StudyTeam; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.global.entity.StatusCategory; + +@DisplayName("StudyMemberMapper 단위 테스트") +class StudyMemberMapperTest { + + private StudyTeam studyTeam; + private User user1; + private User user2; + + @BeforeEach + void setUp() { + Role role = new Role("테스트 역할"); + ReflectionTestUtils.setField(role, "id", 1L); + + studyTeam = StudyTeam.builder() + .name("테스트 스터디팀") + .githubLink("https://github.com/test") + .notionLink("https://notion.so/test") + .studyExplain("스터디 설명") + .goal("목표") + .rule("규칙") + .recruitNum(5) + .recruitExplain("모집 설명") + .isRecruited(true) + .isFinished(false) + .build(); + ReflectionTestUtils.setField(studyTeam, "id", 1L); + + user1 = User.builder() + .name("사용자1") + .email("user1@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/user1") + .mainPosition("백엔드") + .school("테스트 학교") + .profileImage("profile1.jpg") + .isAuth(true) + .role(role) + .grade("1학년") + .year(21) + .build(); + ReflectionTestUtils.setField(user1, "id", 1L); + + user2 = User.builder() + .name("사용자2") + .email("user2@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/user2") + .mainPosition("프론트엔드") + .school("테스트 학교") + .profileImage("profile2.jpg") + .isAuth(true) + .role(role) + .grade("2학년") + .year(20) + .build(); + ReflectionTestUtils.setField(user2, "id", 2L); + } + + @Test + @DisplayName("toEntity - StudyMemberInfoRequest를 StudyMember 엔티티로 변환") + void toEntity() { + // given + StudyMemberInfoRequest info = StudyMemberInfoRequest.builder() + .userId(1L) + .isLeader(true) + .build(); + + // when + StudyMember result = StudyMemberMapper.toEntity(info, studyTeam, user1); + + // then + assertNotNull(result); + assertTrue(result.isLeader()); + assertEquals("스터디 멤버입니다.", result.getSummary()); + assertEquals(StatusCategory.APPROVED, result.getStatus()); + assertEquals(studyTeam, result.getStudyTeam()); + assertEquals(user1, result.getUser()); + } + + @Test + @DisplayName("toEntity - isLeader가 false인 경우") + void toEntity_WhenIsLeaderFalse() { + // given + StudyMemberInfoRequest info = StudyMemberInfoRequest.builder() + .userId(1L) + .isLeader(false) + .build(); + + // when + StudyMember result = StudyMemberMapper.toEntity(info, studyTeam, user1); + + // then + assertNotNull(result); + assertFalse(result.isLeader()); + assertEquals("스터디 멤버입니다.", result.getSummary()); + assertEquals(StatusCategory.APPROVED, result.getStatus()); + assertEquals(studyTeam, result.getStudyTeam()); + assertEquals(user1, result.getUser()); + } + + @Test + @DisplayName("toEntities - 여러 StudyMemberInfoRequest를 StudyMember 엔티티 리스트로 변환") + void toEntities() { + // given + StudyMemberInfoRequest info1 = StudyMemberInfoRequest.builder() + .userId(1L) + .isLeader(true) + .build(); + StudyMemberInfoRequest info2 = StudyMemberInfoRequest.builder() + .userId(2L) + .isLeader(false) + .build(); + + List memberInfoList = List.of(info1, info2); + + Map users = new HashMap<>(); + users.put(1L, user1); + users.put(2L, user2); + + // when + List result = StudyMemberMapper.toEntities(memberInfoList, studyTeam, users); + + // then + assertNotNull(result); + assertEquals(2, result.size()); + + // 첫 번째 멤버 확인 + StudyMember member1 = result.getFirst(); + assertTrue(member1.isLeader()); + assertEquals("스터디 멤버입니다.", member1.getSummary()); + assertEquals(StatusCategory.APPROVED, member1.getStatus()); + assertEquals(studyTeam, member1.getStudyTeam()); + assertEquals(user1, member1.getUser()); + + // 두 번째 멤버 확인 + StudyMember member2 = result.get(1); + assertFalse(member2.isLeader()); + assertEquals("스터디 멤버입니다.", member2.getSummary()); + assertEquals(StatusCategory.APPROVED, member2.getStatus()); + assertEquals(studyTeam, member2.getStudyTeam()); + assertEquals(user2, member2.getUser()); + } + + @Test + @DisplayName("toEntities - 빈 리스트를 변환하면 빈 리스트 반환") + void toEntities_WhenEmptyList_ReturnsEmptyList() { + // given + List emptyList = List.of(); + Map users = new HashMap<>(); + + // when + List result = StudyMemberMapper.toEntities(emptyList, studyTeam, users); + + // then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("toEntities - incomingMembersInfo가 null이면 예외 발생") + void toEntities_WhenIncomingMembersInfoIsNull_ThrowsException() { + // given + Map users = new HashMap<>(); + users.put(1L, user1); + + // when & then + assertThrows( + StudyMemberBadRequestException.class, + () -> StudyMemberMapper.toEntities(null, studyTeam, users)); + } + + @Test + @DisplayName("toEntities - team이 null이면 예외 발생") + void toEntities_WhenTeamIsNull_ThrowsException() { + // given + StudyMemberInfoRequest info = StudyMemberInfoRequest.builder() + .userId(1L) + .isLeader(true) + .build(); + List memberInfoList = List.of(info); + Map users = new HashMap<>(); + users.put(1L, user1); + + // when & then + assertThrows( + StudyMemberBadRequestException.class, + () -> StudyMemberMapper.toEntities(memberInfoList, null, users)); + } + + @Test + @DisplayName("toEntities - users가 null이면 예외 발생") + void toEntities_WhenUsersIsNull_ThrowsException() { + // given + StudyMemberInfoRequest info = StudyMemberInfoRequest.builder() + .userId(1L) + .isLeader(true) + .build(); + List memberInfoList = List.of(info); + + // when & then + assertThrows( + StudyMemberBadRequestException.class, + () -> StudyMemberMapper.toEntities(memberInfoList, studyTeam, null)); + } + + @Test + @DisplayName("toEntities - users Map에 해당 userId가 없으면 예외 발생") + void toEntities_WhenUserNotFoundInMap_ThrowsException() { + // given + StudyMemberInfoRequest info = StudyMemberInfoRequest.builder() + .userId(999L) // 존재하지 않는 userId + .isLeader(true) + .build(); + List memberInfoList = List.of(info); + Map users = new HashMap<>(); + users.put(1L, user1); // 999L은 없음 + + // when & then + assertThrows( + StudyMemberBadRequestException.class, + () -> StudyMemberMapper.toEntities(memberInfoList, studyTeam, users)); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/repository/StudyMemberDslRepositoryImplTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/repository/StudyMemberDslRepositoryImplTest.java new file mode 100644 index 00000000..cba7bef7 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/repository/StudyMemberDslRepositoryImplTest.java @@ -0,0 +1,379 @@ +package backend.techeerzip.domain.studyMember.repository; + +import backend.techeerzip.config.RepositoryTestSupport; +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.role.repository.RoleRepository; +import backend.techeerzip.domain.studyMember.entity.StudyMember; +import backend.techeerzip.domain.studyTeam.dto.response.StudyApplicantResponse; +import backend.techeerzip.domain.studyTeam.entity.StudyTeam; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.global.config.QueryDslConfig; +import backend.techeerzip.global.entity.StatusCategory; +import jakarta.persistence.EntityManager; +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.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@Import(QueryDslConfig.class) +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("StudyMemberDslRepositoryImpl 통합 테스트") +class StudyMemberDslRepositoryImplTest extends RepositoryTestSupport { + + @SuppressWarnings("unused") + @Autowired + private EntityManager entityManager; + + @SuppressWarnings("unused") + @Autowired + private JPAQueryFactory queryFactory; + + @Autowired + private RoleRepository roleRepository; + + private StudyMemberDslRepositoryImpl repository; + private StudyTeam studyTeam; + private User user1; + private User user2; + private User user3; + + @BeforeEach + void setUp() { + repository = new StudyMemberDslRepositoryImpl(entityManager, queryFactory); + + // Flyway 마이그레이션에서 생성된 Role 조회 (id=3인 'techeer' Role) + Role role = roleRepository.findById(3L) + .orElseThrow(() -> new IllegalStateException("Role with id 3 not found")); + + // StudyTeam 생성 (Native SQL로 직접 INSERT - DB 스키마에 없는 필드 제외) + LocalDateTime now = LocalDateTime.now(); + Long studyTeamId = ((Number) entityManager.createNativeQuery( + "INSERT INTO \"StudyTeam\" (\"createdAt\", \"updatedAt\", \"isDeleted\", \"isRecruited\", \"isFinished\", name, \"notionLink\", \"studyExplain\", \"recruitNum\", \"recruitExplain\", \"likeCount\", \"viewCount\") " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id" + ) + .setParameter(1, now) + .setParameter(2, now) + .setParameter(3, false) + .setParameter(4, true) + .setParameter(5, false) + .setParameter(6, "테스트 스터디팀") + .setParameter(7, "https://notion.so/test") + .setParameter(8, "스터디 설명") + .setParameter(9, 5) + .setParameter(10, "모집 설명") + .setParameter(11, 0) + .setParameter(12, 0) + .getSingleResult()).longValue(); + + entityManager.flush(); + entityManager.clear(); + + // 생성된 StudyTeam 객체 생성 (Builder 사용 후 리플렉션으로 ID 설정 - Hibernate 조회 회피) + studyTeam = StudyTeam.builder() + .name("테스트 스터디팀") + .notionLink("https://notion.so/test") + .studyExplain("스터디 설명") + .githubLink("") // DB에 없는 필드지만 Builder가 요구 + .goal("") // DB에 없는 필드지만 Builder가 요구 + .rule("") // DB에 없는 필드지만 Builder가 요구 + .recruitNum(5) + .recruitExplain("모집 설명") + .isRecruited(true) + .isFinished(false) + .build(); + ReflectionTestUtils.setField(studyTeam, "id", studyTeamId); + + // User 생성 + user1 = User.builder() + .name("지원자1") + .email("applicant1@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/user1") + .mainPosition("백엔드") + .school("테스트 학교") + .profileImage("profile1.jpg") + .isAuth(true) + .role(role) + .grade("1학년") + .year(21) + .build(); + entityManager.persist(user1); + + user2 = User.builder() + .name("지원자2") + .email("applicant2@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/user2") + .mainPosition("프론트엔드") + .school("테스트 학교") + .profileImage("profile2.jpg") + .isAuth(true) + .role(role) + .grade("2학년") + .year(20) + .build(); + entityManager.persist(user2); + + user3 = User.builder() + .name("거절된 지원자") + .email("rejected@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/user3") + .mainPosition("백엔드") + .school("테스트 학교") + .profileImage("profile3.jpg") + .isAuth(true) + .role(role) + .grade("3학년") + .year(19) + .build(); + entityManager.persist(user3); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("findManyApplicants - PENDING 상태의 지원자만 조회") + void findManyApplicants() { + // given + // PENDING 상태 지원자 생성 + var pendingMember1 = StudyMember.builder() + .isLeader(false) + .summary("지원자1의 지원 요약") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user1) + .build(); + entityManager.persist(pendingMember1); + + var pendingMember2 = StudyMember.builder() + .isLeader(false) + .summary("지원자2의 지원 요약") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user2) + .build(); + entityManager.persist(pendingMember2); + + // APPROVED 상태 멤버 (조회되지 않아야 함) + var approvedMember = StudyMember.builder() + .isLeader(false) + .summary("승인된 멤버") + .status(StatusCategory.APPROVED) + .studyTeam(studyTeam) + .user(user3) + .build(); + entityManager.persist(approvedMember); + + entityManager.flush(); + entityManager.clear(); + + // when + List result = repository.findManyApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertEquals(2, result.size()); + + // 첫 번째 지원자 확인 + StudyApplicantResponse applicant1 = result.stream() + .filter(a -> a.getUserId().equals(user1.getId())) + .findFirst() + .orElse(null); + assertNotNull(applicant1); + assertEquals("지원자1의 지원 요약", applicant1.getSummary()); + assertEquals(StatusCategory.PENDING, applicant1.getStatus()); + assertEquals(user1.getId(), applicant1.getUserId()); + assertEquals("지원자1", applicant1.getName()); + assertEquals("profile1.jpg", applicant1.getProfileImage()); + assertEquals(21, applicant1.getYear()); + + // 두 번째 지원자 확인 + StudyApplicantResponse applicant2 = result.stream() + .filter(a -> a.getUserId().equals(user2.getId())) + .findFirst() + .orElse(null); + assertNotNull(applicant2); + assertEquals("지원자2의 지원 요약", applicant2.getSummary()); + assertEquals(StatusCategory.PENDING, applicant2.getStatus()); + assertEquals(user2.getId(), applicant2.getUserId()); + assertEquals("지원자2", applicant2.getName()); + assertEquals("profile2.jpg", applicant2.getProfileImage()); + assertEquals(20, applicant2.getYear()); + } + + @Test + @DisplayName("findManyApplicants - 삭제된 지원자는 조회되지 않음") + void findManyApplicants_ExcludesDeleted() { + // given + var pendingMember = StudyMember.builder() + .isLeader(false) + .summary("지원 요약") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user1) + .build(); + entityManager.persist(pendingMember); + + var deletedMember = StudyMember.builder() + .isLeader(false) + .summary("삭제된 지원자") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user2) + .build(); + deletedMember.softDelete(); + entityManager.persist(deletedMember); + + entityManager.flush(); + entityManager.clear(); + + // when + List result = repository.findManyApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(user1.getId(), result.getFirst().getUserId()); + } + + @Test + @DisplayName("findManyApplicants - 다른 팀의 지원자는 조회되지 않음") + void findManyApplicants_OnlySameTeam() { + // given + // 다른 StudyTeam 생성 (Native SQL로 직접 INSERT - DB 스키마에 없는 필드 제외) + LocalDateTime now2 = LocalDateTime.now(); + Long otherTeamId = ((Number) entityManager.createNativeQuery( + "INSERT INTO \"StudyTeam\" (\"createdAt\", \"updatedAt\", \"isDeleted\", \"isRecruited\", \"isFinished\", name, \"notionLink\", \"studyExplain\", \"recruitNum\", \"recruitExplain\", \"likeCount\", \"viewCount\") " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id" + ) + .setParameter(1, now2) + .setParameter(2, now2) + .setParameter(3, false) + .setParameter(4, false) + .setParameter(5, false) + .setParameter(6, "다른 스터디팀") + .setParameter(7, "https://notion.so/other") + .setParameter(8, "다른 스터디 설명") + .setParameter(9, 3) + .setParameter(10, "다른 모집 설명") + .setParameter(11, 0) + .setParameter(12, 0) + .getSingleResult()).longValue(); + + entityManager.flush(); + entityManager.clear(); + + // 생성된 StudyTeam 객체 생성 (Builder 사용 후 리플렉션으로 ID 설정 - Hibernate 조회 회피) + StudyTeam otherTeam = StudyTeam.builder() + .name("다른 스터디팀") + .notionLink("https://notion.so/other") + .studyExplain("다른 스터디 설명") + .githubLink("") // DB에 없는 필드지만 Builder가 요구 + .goal("") // DB에 없는 필드지만 Builder가 요구 + .rule("") // DB에 없는 필드지만 Builder가 요구 + .recruitNum(3) + .recruitExplain("다른 모집 설명") + .isRecruited(false) + .isFinished(false) + .build(); + ReflectionTestUtils.setField(otherTeam, "id", otherTeamId); + + // 현재 팀의 PENDING 지원자 + var currentTeamMember = StudyMember.builder() + .isLeader(false) + .summary("현재 팀 지원자") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user1) + .build(); + entityManager.persist(currentTeamMember); + + // 다른 팀의 PENDING 지원자 + var otherTeamMember = StudyMember.builder() + .isLeader(false) + .summary("다른 팀 지원자") + .status(StatusCategory.PENDING) + .studyTeam(otherTeam) + .user(user2) + .build(); + entityManager.persist(otherTeamMember); + + entityManager.flush(); + entityManager.clear(); + + // when + List result = repository.findManyApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(user1.getId(), result.getFirst().getUserId()); + assertEquals("현재 팀 지원자", result.getFirst().getSummary()); + } + + @Test + @DisplayName("findManyApplicants - 지원자가 없으면 빈 리스트 반환") + void findManyApplicants_EmptyWhenNoApplicants() { + // given - 지원자 없음 + + // when + List result = repository.findManyApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("findManyApplicants - REJECT 상태 지원자는 조회되지 않음") + void findManyApplicants_ExcludesRejected() { + // given + var pendingMember = StudyMember.builder() + .isLeader(false) + .summary("PENDING 지원자") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user1) + .build(); + entityManager.persist(pendingMember); + + var rejectedMember = StudyMember.builder() + .isLeader(false) + .summary("REJECT 지원자") + .status(StatusCategory.REJECT) + .studyTeam(studyTeam) + .user(user2) + .build(); + entityManager.persist(rejectedMember); + + entityManager.flush(); + entityManager.clear(); + + // when + List result = repository.findManyApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(user1.getId(), result.getFirst().getUserId()); + assertEquals(StatusCategory.PENDING, result.getFirst().getStatus()); + } +} \ No newline at end of file diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/service/StudyMemberServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/service/StudyMemberServiceTest.java new file mode 100644 index 00000000..b1d4360c --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/service/StudyMemberServiceTest.java @@ -0,0 +1,398 @@ +package backend.techeerzip.domain.studyMember.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +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.test.util.ReflectionTestUtils; + +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.studyMember.entity.StudyMember; +import backend.techeerzip.domain.studyMember.exception.StudyMemberNotFoundException; +import backend.techeerzip.domain.studyMember.repository.StudyMemberDslRepository; +import backend.techeerzip.domain.studyMember.repository.StudyMemberRepository; +import backend.techeerzip.domain.studyTeam.dto.response.StudyApplicantResponse; +import backend.techeerzip.domain.studyTeam.entity.StudyTeam; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.repository.UserRepository; +import backend.techeerzip.global.entity.StatusCategory; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StudyMemberService 단위 테스트") +class StudyMemberServiceTest { + + @Mock private StudyMemberRepository studyMemberRepository; + + @Mock private StudyMemberDslRepository studyMemberDslRepository; + + @Mock private UserRepository userRepository; + + @InjectMocks private StudyMemberService studyMemberService; + + private StudyTeam studyTeam; + private User user; + private StudyMember studyMember; + + @BeforeEach + void setUp() { + Role role = new Role("테스트 역할"); + ReflectionTestUtils.setField(role, "id", 1L); + + user = User.builder() + .name("테스트 사용자") + .email("test@example.com") + .password("password") + .isLft(false) + .githubUrl("https://github.com/test") + .mainPosition("백엔드") + .school("테스트 학교") + .profileImage("profile.jpg") + .isAuth(true) + .role(role) + .grade("1학년") + .year(21) + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + + studyTeam = StudyTeam.builder() + .name("테스트 스터디팀") + .githubLink("https://github.com/test") + .notionLink("https://notion.so/test") + .studyExplain("스터디 설명") + .goal("목표") + .rule("규칙") + .recruitNum(5) + .recruitExplain("모집 설명") + .isRecruited(true) + .isFinished(false) + .build(); + ReflectionTestUtils.setField(studyTeam, "id", 1L); + + studyMember = StudyMember.builder() + .isLeader(false) + .summary("지원 요약") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user) + .build(); + ReflectionTestUtils.setField(studyMember, "id", 1L); + } + + @Test + @DisplayName("checkActiveMemberByTeamAndUser - 활성 멤버가 존재하면 true 반환") + void checkActiveMemberByTeamAndUser_WhenActiveMemberExists_ReturnsTrue() { + // given + when(studyMemberRepository.existsByUserIdAndStudyTeamIdAndIsDeletedFalseAndStatus( + user.getId(), studyTeam.getId(), StatusCategory.APPROVED)) + .thenReturn(true); + + // when + boolean result = studyMemberService.checkActiveMemberByTeamAndUser( + studyTeam.getId(), user.getId()); + + // then + assertTrue(result); + verify(studyMemberRepository, times(1)) + .existsByUserIdAndStudyTeamIdAndIsDeletedFalseAndStatus( + user.getId(), studyTeam.getId(), StatusCategory.APPROVED); + } + + @Test + @DisplayName("checkActiveMemberByTeamAndUser - 활성 멤버가 존재하지 않으면 false 반환") + void checkActiveMemberByTeamAndUser_WhenActiveMemberNotExists_ReturnsFalse() { + // given + when(studyMemberRepository.existsByUserIdAndStudyTeamIdAndIsDeletedFalseAndStatus( + user.getId(), studyTeam.getId(), StatusCategory.APPROVED)) + .thenReturn(false); + + // when + boolean result = studyMemberService.checkActiveMemberByTeamAndUser( + studyTeam.getId(), user.getId()); + + // then + assertFalse(result); + verify(studyMemberRepository, times(1)) + .existsByUserIdAndStudyTeamIdAndIsDeletedFalseAndStatus( + user.getId(), studyTeam.getId(), StatusCategory.APPROVED); + } + + @Test + @DisplayName("applyApplicant - 기존 지원자가 없으면 새로 생성") + void applyApplicant_WhenNoExistingMember_CreatesNewApplicant() { + // given + String summary = "새로운 지원 요약"; + when(studyMemberRepository.findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId())) + .thenReturn(Optional.empty()); + when(userRepository.getReferenceById(user.getId())).thenReturn(user); + when(studyMemberRepository.save(any(StudyMember.class))).thenReturn(studyMember); + + // when + StudyMember result = studyMemberService.applyApplicant(studyTeam, user.getId(), summary); + + // then + assertNotNull(result); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId()); + verify(userRepository, times(1)).getReferenceById(user.getId()); + verify(studyMemberRepository, times(1)).save(any(StudyMember.class)); + } + + @Test + @DisplayName("applyApplicant - 기존 지원자가 PENDING이 아니면 상태를 PENDING으로 변경") + void applyApplicant_WhenExistingMemberNotPending_UpdatesToPending() { + // given + String summary = "업데이트된 지원 요약"; + StudyMember existingMember = StudyMember.builder() + .isLeader(false) + .summary("기존 요약") + .status(StatusCategory.REJECT) + .studyTeam(studyTeam) + .user(user) + .build(); + ReflectionTestUtils.setField(existingMember, "id", 2L); + + when(studyMemberRepository.findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId())) + .thenReturn(Optional.of(existingMember)); + + // when + StudyMember result = studyMemberService.applyApplicant(studyTeam, user.getId(), summary); + + // then + assertNotNull(result); + assertEquals(StatusCategory.PENDING, result.getStatus()); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId()); + verify(studyMemberRepository, never()).save(any(StudyMember.class)); + } + + @Test + @DisplayName("applyApplicant - 기존 지원자가 이미 PENDING이면 상태 변경 없음") + void applyApplicant_WhenExistingMemberIsPending_NoStatusChange() { + // given + String summary = "지원 요약"; + StudyMember existingMember = StudyMember.builder() + .isLeader(false) + .summary("기존 요약") + .status(StatusCategory.PENDING) + .studyTeam(studyTeam) + .user(user) + .build(); + ReflectionTestUtils.setField(existingMember, "id", 2L); + + when(studyMemberRepository.findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId())) + .thenReturn(Optional.of(existingMember)); + + // when + StudyMember result = studyMemberService.applyApplicant(studyTeam, user.getId(), summary); + + // then + assertNotNull(result); + assertEquals(StatusCategory.PENDING, result.getStatus()); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId()); + verify(studyMemberRepository, never()).save(any(StudyMember.class)); + } + + @Test + @DisplayName("acceptApplicant - PENDING 지원자를 승인하면 APPROVED로 변경하고 이메일 반환") + void acceptApplicant_WhenPendingMember_UpdatesToApprovedAndReturnsEmail() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING)) + .thenReturn(Optional.of(studyMember)); + + // when + String result = studyMemberService.acceptApplicant(studyTeam.getId(), user.getId()); + + // then + assertEquals(user.getEmail(), result); + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING); + } + + @Test + @DisplayName("acceptApplicant - PENDING 지원자가 없으면 예외 발생") + void acceptApplicant_WhenPendingMemberNotFound_ThrowsException() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows( + StudyMemberNotFoundException.class, + () -> studyMemberService.acceptApplicant(studyTeam.getId(), user.getId())); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING); + } + + @Test + @DisplayName("acceptApplicant - 이미 APPROVED 상태인 멤버는 PENDING 조회 시 찾을 수 없음") + void acceptApplicant_WhenAlreadyApproved_ThrowsException() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows( + StudyMemberNotFoundException.class, + () -> studyMemberService.acceptApplicant(studyTeam.getId(), user.getId())); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING); + } + + @Test + @DisplayName("rejectApplicant - PENDING 지원자를 거절하면 REJECT로 변경하고 이메일 반환") + void rejectApplicant_WhenPendingMember_UpdatesToRejectAndReturnsEmail() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING)) + .thenReturn(Optional.of(studyMember)); + + // when + String result = studyMemberService.rejectApplicant(studyTeam.getId(), user.getId()); + + // then + assertEquals(user.getEmail(), result); + assertEquals(StatusCategory.REJECT, studyMember.getStatus()); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING); + } + + @Test + @DisplayName("rejectApplicant - PENDING 지원자가 없으면 예외 발생") + void rejectApplicant_WhenPendingMemberNotFound_ThrowsException() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows( + StudyMemberNotFoundException.class, + () -> studyMemberService.rejectApplicant(studyTeam.getId(), user.getId())); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING); + } + + @Test + @DisplayName("rejectApplicant - 이미 REJECT 상태인 멤버는 PENDING 조회 시 찾을 수 없음") + void rejectApplicant_WhenAlreadyRejected_ThrowsException() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows( + StudyMemberNotFoundException.class, + () -> studyMemberService.rejectApplicant(studyTeam.getId(), user.getId())); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserIdAndStatus( + studyTeam.getId(), user.getId(), StatusCategory.PENDING); + } + + @Test + @DisplayName("getApplicants - 지원자 목록 조회") + void getApplicants_ReturnsApplicantList() { + // given + StudyApplicantResponse applicant1 = StudyApplicantResponse.builder() + .id(1L) + .summary("지원자1 요약") + .status(StatusCategory.PENDING) + .userId(user.getId()) + .name(user.getName()) + .profileImage(user.getProfileImage()) + .year(user.getYear()) + .build(); + + StudyApplicantResponse applicant2 = StudyApplicantResponse.builder() + .id(2L) + .summary("지원자2 요약") + .status(StatusCategory.PENDING) + .userId(2L) + .name("지원자2") + .profileImage("profile2.jpg") + .year(20) + .build(); + + List expectedList = List.of(applicant1, applicant2); + when(studyMemberDslRepository.findManyApplicants(studyTeam.getId())) + .thenReturn(expectedList); + + // when + List result = studyMemberService.getApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(expectedList, result); + verify(studyMemberDslRepository, times(1)).findManyApplicants(studyTeam.getId()); + } + + @Test + @DisplayName("getApplicants - 지원자가 없으면 빈 리스트 반환") + void getApplicants_WhenNoApplicants_ReturnsEmptyList() { + // given + when(studyMemberDslRepository.findManyApplicants(studyTeam.getId())) + .thenReturn(List.of()); + + // when + List result = studyMemberService.getApplicants(studyTeam.getId()); + + // then + assertNotNull(result); + assertTrue(result.isEmpty()); + verify(studyMemberDslRepository, times(1)).findManyApplicants(studyTeam.getId()); + } + + @Test + @DisplayName("getMember - 멤버가 존재하면 Optional에 담아 반환") + void getMember_WhenMemberExists_ReturnsOptionalWithMember() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId())) + .thenReturn(Optional.of(studyMember)); + + // when + Optional result = studyMemberService.getMember(studyTeam.getId(), user.getId()); + + // then + assertTrue(result.isPresent()); + assertEquals(studyMember, result.get()); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId()); + } + + @Test + @DisplayName("getMember - 멤버가 없으면 빈 Optional 반환") + void getMember_WhenMemberNotExists_ReturnsEmptyOptional() { + // given + when(studyMemberRepository.findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId())) + .thenReturn(Optional.empty()); + + // when + Optional result = studyMemberService.getMember(studyTeam.getId(), user.getId()); + + // then + assertTrue(result.isEmpty()); + verify(studyMemberRepository, times(1)) + .findByStudyTeamIdAndUserId(studyTeam.getId(), user.getId()); + } +}