From cabaa2b2f4b2d84de70a64df58e48afa9e2d3c10 Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Thu, 4 Dec 2025 14:31:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20User=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/UserService.java | 26 +- .../privideo/domain/user/UserServiceTest.java | 506 ++++++++++++++++++ 2 files changed, 516 insertions(+), 16 deletions(-) create mode 100644 src/test/java/app/allstackproject/privideo/domain/user/UserServiceTest.java diff --git a/src/main/java/app/allstackproject/privideo/domain/user/service/UserService.java b/src/main/java/app/allstackproject/privideo/domain/user/service/UserService.java index 5653ba0..e0830f3 100644 --- a/src/main/java/app/allstackproject/privideo/domain/user/service/UserService.java +++ b/src/main/java/app/allstackproject/privideo/domain/user/service/UserService.java @@ -1,10 +1,7 @@ package app.allstackproject.privideo.domain.user.service; -import static app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE; -import static app.allstackproject.privideo.shared.enums.BaseStatusType.INACTIVE; import static app.allstackproject.privideo.domain.organization.dto.enums.JoinStatusType.PENDING; import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ALREADY_LEAVED_USER; -import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.DB_CONSTRAINT_VIOLATE; import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.DUPLICATE_EMAIL; import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_ORG_CODE; import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_PASSWORD; @@ -12,25 +9,26 @@ import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.PASSWORD_MISMATCH; import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.PASSWORD_SAME_AS_CURRENT; import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.USER_NOT_FOUND; +import static app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE; +import static app.allstackproject.privideo.shared.enums.BaseStatusType.INACTIVE; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrgRedisRepository; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; import app.allstackproject.privideo.domain.user.dto.enums.GenderType; -import app.allstackproject.privideo.global.exception.ApiException; -import app.allstackproject.privideo.global.security.JwtProvider; import app.allstackproject.privideo.domain.user.dto.request.PostLoginRequest; import app.allstackproject.privideo.domain.user.dto.request.PostSignupRequest; import app.allstackproject.privideo.domain.user.dto.request.UpdateUserInfoRequest; import app.allstackproject.privideo.domain.user.dto.response.UserInfoResponse; -import app.allstackproject.privideo.domain.member.entity.Member; -import app.allstackproject.privideo.domain.organization.entity.Organization; import app.allstackproject.privideo.domain.user.entity.User; -import app.allstackproject.privideo.domain.member.repository.MemberRepository; -import app.allstackproject.privideo.domain.organization.repository.OrgRedisRepository; -import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; import app.allstackproject.privideo.domain.user.repository.UserRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.security.JwtProvider; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -65,11 +63,7 @@ public boolean signup(@Valid PostSignupRequest postSignupRequest) { postSignupRequest.getAge() ).hashPassword(passwordEncoder); - try { - userRepository.save(user); - } catch (DataIntegrityViolationException e) { - throw new ApiException(DB_CONSTRAINT_VIOLATE); - } + userRepository.save(user); String orgCode = postSignupRequest.getOrganizationCode(); if (orgCode != null && !orgCode.isBlank()) { diff --git a/src/test/java/app/allstackproject/privideo/domain/user/UserServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/user/UserServiceTest.java new file mode 100644 index 0000000..43c0a18 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/user/UserServiceTest.java @@ -0,0 +1,506 @@ +package app.allstackproject.privideo.domain.user; + +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ALREADY_LEAVED_USER; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.DUPLICATE_EMAIL; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_ORG_CODE; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_PASSWORD; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ORG_CODE_NOT_AVAILABLE; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.PASSWORD_MISMATCH; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.PASSWORD_SAME_AS_CURRENT; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.USER_NOT_FOUND; +import static app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE; +import static app.allstackproject.privideo.shared.enums.BaseStatusType.INACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrgRedisRepository; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.domain.user.dto.enums.GenderType; +import app.allstackproject.privideo.domain.user.dto.request.PostLoginRequest; +import app.allstackproject.privideo.domain.user.dto.request.PostSignupRequest; +import app.allstackproject.privideo.domain.user.dto.request.UpdateUserInfoRequest; +import app.allstackproject.privideo.domain.user.dto.response.UserInfoResponse; +import app.allstackproject.privideo.domain.user.entity.User; +import app.allstackproject.privideo.domain.user.repository.UserRepository; +import app.allstackproject.privideo.domain.user.service.UserService; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.security.JwtProvider; +import java.util.Collections; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtProvider jwtProvider; + @Mock + private OrgRedisRepository orgRedisRepository; + + private static final String EMAIL_ACTIVE = "active@example.com"; + private static final String EMAIL_INACTIVE = "inactive@example.com"; + private static final String PASSWORD = "password1!"; + private static final String ENCODED_PASSWORD = "encodedPw!"; + private static final String ORG_CODE = "ORG123"; + private static final Long ORG_ID = 1L; + + private User activeUser; + private User inactiveUser; + private Organization testOrg; + + @BeforeEach + void setUp() { + activeUser = User.create( + "활성 유저", + EMAIL_ACTIVE, + ENCODED_PASSWORD, + GenderType.MALE, + "01011112222", + 20 + ); + + inactiveUser = User.create( + "비활성 유저", + EMAIL_INACTIVE, + ENCODED_PASSWORD, + GenderType.FEMALE, + "01033334444", + 30 + ); + inactiveUser.updateToInactive(); + + testOrg = Organization.create( + activeUser, + "테스트 조직", + "조직 설명입니다." + ); + } + + // =========================== + // signup + // =========================== + + @Test + @DisplayName("[signup] 정상 회원가입 - 조직 코드 없음") + void signup_success_withoutOrg_stateCheck() { + PostSignupRequest req = buildSignupRequest(EMAIL_ACTIVE, null); + + // 서비스 로직이 INACTIVE -> ACTIVE 순으로 체크한다고 가정하고 둘 다 스텁 + given(userRepository.existsByEmailAndStatus(req.getEmail(), INACTIVE)).willReturn(false); + given(userRepository.existsByEmailAndStatus(req.getEmail(), ACTIVE)).willReturn(false); + + given(passwordEncoder.encode(PASSWORD)).willReturn(ENCODED_PASSWORD); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + boolean result = userService.signup(req); + + assertThat(result).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + User saved = captor.getValue(); + + assertThat(saved.getName()).isEqualTo(req.getName()); + assertThat(saved.getEmail()).isEqualTo(req.getEmail()); + assertThat(saved.getGender()).isEqualTo(GenderType.valueOf(req.getGender())); + assertThat(saved.getPhoneNumber()).isEqualTo(req.getPhoneNumber()); + assertThat(saved.getAge()).isEqualTo(req.getAge()); + assertThat(saved.getPassword()).isEqualTo(ENCODED_PASSWORD); + verifyNoInteractions(orgRedisRepository, organizationRepository, memberRepository); + } + + @Test + @DisplayName("[signup] 정상 회원가입 - 조직 코드로 Member 생성") + void signup_success_withOrg() { + PostSignupRequest req = buildSignupRequest(EMAIL_ACTIVE, ORG_CODE); + + given(userRepository.existsByEmailAndStatus(req.getEmail(), INACTIVE)).willReturn(false); + given(userRepository.existsByEmailAndStatus(req.getEmail(), ACTIVE)).willReturn(false); + + given(passwordEncoder.encode(PASSWORD)).willReturn(ENCODED_PASSWORD); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + given(orgRedisRepository.getOrgIdByCode(ORG_CODE)).willReturn(ORG_ID); + given(organizationRepository.findById(ORG_ID)).willReturn(Optional.of(testOrg)); + + boolean result = userService.signup(req); + + assertThat(result).isTrue(); + verify(userRepository).save(any(User.class)); + verify(memberRepository).save(any(Member.class)); + } + + @Test + @DisplayName("[signup] INACTIVE 이메일이 존재하면 ALREADY_LEAVED_USER") + void signup_alreadyLeaved() { + PostSignupRequest req = buildSignupRequest(EMAIL_INACTIVE, null); + + // 이 케이스에서는 INACTIVE 만 체크하고 바로 예외 던지므로 ACTIVE 스텁 불필요 + given(userRepository.existsByEmailAndStatus(req.getEmail(), INACTIVE)) + .willReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.signup(req)); + + assertThat(ex.getResponseStatus()).isEqualTo(ALREADY_LEAVED_USER); + } + + @Test + @DisplayName("[signup] ACTIVE 이메일이 존재하면 DUPLICATE_EMAIL") + void signup_duplicateEmail() { + PostSignupRequest req = buildSignupRequest(EMAIL_ACTIVE, null); + + given(userRepository.existsByEmailAndStatus(req.getEmail(), INACTIVE)).willReturn(false); + given(userRepository.existsByEmailAndStatus(req.getEmail(), ACTIVE)).willReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.signup(req)); + + assertThat(ex.getResponseStatus()).isEqualTo(DUPLICATE_EMAIL); + } + + @Test + @DisplayName("[signup] 조직 코드가 Redis에 없으면 ORG_CODE_NOT_AVAILABLE") + void signup_orgCodeNotAvailable() { + PostSignupRequest req = buildSignupRequest(EMAIL_ACTIVE, ORG_CODE); + + given(userRepository.existsByEmailAndStatus(req.getEmail(), INACTIVE)).willReturn(false); + given(userRepository.existsByEmailAndStatus(req.getEmail(), ACTIVE)).willReturn(false); + + given(passwordEncoder.encode(PASSWORD)).willReturn(ENCODED_PASSWORD); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + given(orgRedisRepository.getOrgIdByCode(ORG_CODE)).willReturn(null); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.signup(req)); + + assertThat(ex.getResponseStatus()).isEqualTo(ORG_CODE_NOT_AVAILABLE); + } + + @Test + @DisplayName("[signup] Redis에는 있으나 DB에 조직이 없으면 INVALID_ORG_CODE") + void signup_invalidOrgCode() { + PostSignupRequest req = buildSignupRequest(EMAIL_ACTIVE, ORG_CODE); + + given(userRepository.existsByEmailAndStatus(req.getEmail(), INACTIVE)).willReturn(false); + given(userRepository.existsByEmailAndStatus(req.getEmail(), ACTIVE)).willReturn(false); + + given(passwordEncoder.encode(PASSWORD)).willReturn(ENCODED_PASSWORD); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + given(orgRedisRepository.getOrgIdByCode(ORG_CODE)).willReturn(ORG_ID); + given(organizationRepository.findById(ORG_ID)).willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.signup(req)); + + assertThat(ex.getResponseStatus()).isEqualTo(INVALID_ORG_CODE); + } + + // =========================== + // login + // =========================== + + @Test + @DisplayName("[login] 성공 시 bootstrap 토큰 반환") + void login_success() { + PostLoginRequest req = new PostLoginRequest(EMAIL_ACTIVE, PASSWORD); + User user = mock(User.class); + + given(userRepository.findByEmailAndStatus(req.getEmail(), ACTIVE)) + .willReturn(Optional.of(user)); + given(user.matchPassword(req.getPassword(), passwordEncoder)) + .willReturn(true); + given(user.getId()).willReturn(1L); + given(jwtProvider.createBootstrapToken(1L)).willReturn("TOKEN"); + + String token = userService.login(req); + + assertThat(token).isEqualTo("TOKEN"); + } + + @Test + @DisplayName("[login] 회원이 없으면 USER_NOT_FOUND") + void login_userNotFound() { + PostLoginRequest req = new PostLoginRequest(EMAIL_ACTIVE, PASSWORD); + + given(userRepository.findByEmailAndStatus(req.getEmail(), ACTIVE)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.login(req)); + + assertThat(ex.getResponseStatus()).isEqualTo(USER_NOT_FOUND); + } + + @Test + @DisplayName("[login] 비밀번호 불일치 시 INVALID_PASSWORD") + void login_invalidPassword() { + PostLoginRequest req = new PostLoginRequest(EMAIL_ACTIVE, PASSWORD); + User user = mock(User.class); + + given(userRepository.findByEmailAndStatus(req.getEmail(), ACTIVE)) + .willReturn(Optional.of(user)); + given(user.matchPassword(req.getPassword(), passwordEncoder)) + .willReturn(false); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.login(req)); + + assertThat(ex.getResponseStatus()).isEqualTo(INVALID_PASSWORD); + } + + // =========================== + // getUserInfo + // =========================== + + @Test + @DisplayName("[getUserInfo] 정상 조회") + void getUserInfo_success() { + Long userId = 1L; + + User user = mock(User.class); + Member member = mock(Member.class); + Organization org = mock(Organization.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(memberRepository.findByUserIdAndStatus(userId, ACTIVE)) + .willReturn(Collections.singletonList(member)); + + // UserInfoResponse.of(...) 안에서 실제로 사용되는 필드들만 스텁 + given(member.getOrganization()).willReturn(org); + // 어떤 필드는 안 쓸 수도 있으니 lenient 로 처리 + lenient().when(org.getId()).thenReturn(ORG_ID); + lenient().when(org.getName()).thenReturn("테스트 조직"); + lenient().when(user.getName()).thenReturn("홍길동"); + lenient().when(user.getEmail()).thenReturn(EMAIL_ACTIVE); + + UserInfoResponse response = userService.getUserInfo(userId); + + assertThat(response).isNotNull(); + } + + @Test + @DisplayName("[getUserInfo] 유저가 없으면 USER_NOT_FOUND") + void getUserInfo_userNotFound() { + Long userId = 1L; + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.getUserInfo(userId)); + + assertThat(ex.getResponseStatus()).isEqualTo(USER_NOT_FOUND); + } + + // =========================== + // updateUserInfo + // =========================== + + @Test + @DisplayName("[updateUserInfo] 유저가 없으면 USER_NOT_FOUND") + void updateUserInfo_userNotFound() { + Long userId = 1L; + UpdateUserInfoRequest req = buildUpdateUserInfoRequest( + "", + "", + GenderType.FEMALE.name() + ); + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.updateUserInfo(userId, req)); + + assertThat(ex.getResponseStatus()).isEqualTo(USER_NOT_FOUND); + } + + @Test + @DisplayName("[updateUserInfo] 비밀번호 변경 없이 기본 정보만 수정 (실제 엔티티 상태 검증)") + void updateUserInfo_onlyProfileChange_stateCheck() { + Long userId = 1L; + + UpdateUserInfoRequest req = buildUpdateUserInfoRequest( + "", + "", + GenderType.FEMALE.name() + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(activeUser)); + + boolean result = userService.updateUserInfo(userId, req); + + assertThat(result).isTrue(); + assertThat(activeUser.getPhoneNumber()).isEqualTo("01043214321"); + assertThat(activeUser.getGender()).isEqualTo(GenderType.FEMALE); + assertThat(activeUser.getAge()).isEqualTo(30); + assertThat(activeUser.getPassword()).isEqualTo(ENCODED_PASSWORD); + } + + @Test + @DisplayName("[updateUserInfo] newPassword / confirmPassword 불일치 시 PASSWORD_MISMATCH") + void updateUserInfo_passwordMismatch() { + Long userId = 1L; + UpdateUserInfoRequest req = buildUpdateUserInfoRequest( + "changedPw!", + "confirmPw!!", + GenderType.FEMALE.name() + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(activeUser)); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.updateUserInfo(userId, req)); + + assertThat(ex.getResponseStatus()).isEqualTo(PASSWORD_MISMATCH); + } + + @Test + @DisplayName("[updateUserInfo] 현재 비밀번호와 동일하면 PASSWORD_SAME_AS_CURRENT") + void updateUserInfo_passwordSameAsCurrent() { + Long userId = 1L; + String newPassword = "samePw!1"; + UpdateUserInfoRequest req = buildUpdateUserInfoRequest( + newPassword, + newPassword, + GenderType.FEMALE.name() + ); + User user = mock(User.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(user.getPassword()).willReturn(ENCODED_PASSWORD); + given(passwordEncoder.matches(newPassword, ENCODED_PASSWORD)).willReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.updateUserInfo(userId, req)); + + assertThat(ex.getResponseStatus()).isEqualTo(PASSWORD_SAME_AS_CURRENT); + } + + @Test + @DisplayName("[updateUserInfo] 비밀번호 정상 변경 (실제 엔티티 상태 검증)") + void updateUserInfo_changePassword_stateCheck() { + Long userId = 1L; + String newPassword = "newPw!1"; + String encodedNewPw = "encodedNewPw!"; + + User user = User.create( + "홍길동", + EMAIL_ACTIVE, + ENCODED_PASSWORD, + GenderType.MALE, + "01011112222", + 20 + ); + + UpdateUserInfoRequest req = buildUpdateUserInfoRequest( + newPassword, + newPassword, + GenderType.FEMALE.name() + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(newPassword, ENCODED_PASSWORD)).willReturn(false); + given(passwordEncoder.encode(newPassword)).willReturn(encodedNewPw); + + boolean result = userService.updateUserInfo(userId, req); + + assertThat(result).isTrue(); + assertThat(user.getPhoneNumber()).isEqualTo("01043214321"); + assertThat(user.getGender()).isEqualTo(GenderType.FEMALE); + assertThat(user.getAge()).isEqualTo(30); + assertThat(user.getPassword()).isEqualTo(encodedNewPw); + } + + // =========================== + // deleteUser + // =========================== + + @Test + @DisplayName("[deleteUser] 유저가 없으면 USER_NOT_FOUND") + void deleteUser_notFound() { + Long userId = 1L; + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> userService.deleteUser(userId)); + + assertThat(ex.getResponseStatus()).isEqualTo(USER_NOT_FOUND); + } + + @Test + @DisplayName("[deleteUser] 정상 시 memberRepository.inactivateAllByUserId 호출 및 유저 INACTIVE 처리") + void deleteUser_success() { + Long userId = 1L; + User user = mock(User.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + boolean result = userService.deleteUser(userId); + + assertThat(result).isTrue(); + verify(memberRepository).inactivateAllByUserId(userId); + verify(user).updateToInactive(); + } + + // =========================== + // helper methods + // =========================== + + private PostSignupRequest buildSignupRequest(String email, String orgCode) { + return new PostSignupRequest( + "홍길동", + email, + PASSWORD, + GenderType.MALE.name(), + 20, + "01012341234", + orgCode + ); + } + + private UpdateUserInfoRequest buildUpdateUserInfoRequest( + String newPassword, + String confirmPassword, + String changedGender + ) { + return new UpdateUserInfoRequest( + newPassword, + 30, + confirmPassword, + changedGender, + "01043214321" + ); + } +} \ No newline at end of file From 30e3f171c6be3ea2357927597d1917eda14f3d9a Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Thu, 4 Dec 2025 15:24:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20Video=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/admin/dto/ReadAllVideoItem.java | 2 + .../domain/history/dto/HistoryItem.java | 2 + .../user/dto/request/PostSignupRequest.java | 2 +- .../video}/GeminiAiServiceTest.java | 2 +- .../domain/video/VideoServiceTest.java | 943 ++++++++++++++++++ 5 files changed, 949 insertions(+), 2 deletions(-) rename src/test/java/app/allstackproject/privideo/{ => domain/video}/GeminiAiServiceTest.java (97%) create mode 100644 src/test/java/app/allstackproject/privideo/domain/video/VideoServiceTest.java diff --git a/src/main/java/app/allstackproject/privideo/domain/admin/dto/ReadAllVideoItem.java b/src/main/java/app/allstackproject/privideo/domain/admin/dto/ReadAllVideoItem.java index 22a86d6..88d07be 100644 --- a/src/main/java/app/allstackproject/privideo/domain/admin/dto/ReadAllVideoItem.java +++ b/src/main/java/app/allstackproject/privideo/domain/admin/dto/ReadAllVideoItem.java @@ -4,9 +4,11 @@ import java.time.LocalDate; import java.time.LocalDateTime; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter +@NoArgsConstructor public class ReadAllVideoItem { private Long id; diff --git a/src/main/java/app/allstackproject/privideo/domain/history/dto/HistoryItem.java b/src/main/java/app/allstackproject/privideo/domain/history/dto/HistoryItem.java index 7c1cd5c..b2ad167 100644 --- a/src/main/java/app/allstackproject/privideo/domain/history/dto/HistoryItem.java +++ b/src/main/java/app/allstackproject/privideo/domain/history/dto/HistoryItem.java @@ -3,9 +3,11 @@ import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter +@NoArgsConstructor @AllArgsConstructor public class HistoryItem { private Long id; diff --git a/src/main/java/app/allstackproject/privideo/domain/user/dto/request/PostSignupRequest.java b/src/main/java/app/allstackproject/privideo/domain/user/dto/request/PostSignupRequest.java index 6ae7c2f..9833695 100644 --- a/src/main/java/app/allstackproject/privideo/domain/user/dto/request/PostSignupRequest.java +++ b/src/main/java/app/allstackproject/privideo/domain/user/dto/request/PostSignupRequest.java @@ -35,7 +35,7 @@ public class PostSignupRequest { @AgeTypeConstraint private Integer age; - @NotBlank(message = "비밀번호를 입력해주세요.") + @NotBlank(message = "전화번호를 입력해주세요.") private String phoneNumber; private String organizationCode; diff --git a/src/test/java/app/allstackproject/privideo/GeminiAiServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/video/GeminiAiServiceTest.java similarity index 97% rename from src/test/java/app/allstackproject/privideo/GeminiAiServiceTest.java rename to src/test/java/app/allstackproject/privideo/domain/video/GeminiAiServiceTest.java index 5c31084..cd8b522 100644 --- a/src/test/java/app/allstackproject/privideo/GeminiAiServiceTest.java +++ b/src/test/java/app/allstackproject/privideo/domain/video/GeminiAiServiceTest.java @@ -1,4 +1,4 @@ -package app.allstackproject.privideo; +package app.allstackproject.privideo.domain.video; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/app/allstackproject/privideo/domain/video/VideoServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/video/VideoServiceTest.java new file mode 100644 index 0000000..6fb4f8a --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/video/VideoServiceTest.java @@ -0,0 +1,943 @@ +package app.allstackproject.privideo.domain.video; + +import static app.allstackproject.privideo.domain.video.enums.AiFunctionType.NONE; +import static app.allstackproject.privideo.domain.video.enums.AiFunctionType.SUMMARY; +import static app.allstackproject.privideo.domain.video.enums.UploadStatusType.COMPLETE; +import static app.allstackproject.privideo.domain.video.enums.UploadStatusType.FAIL; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_AIRFLOW_STATUS; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.MEMBER_NOT_IN_ORGANIZATION; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.PLAY_SESSION_NOT_FOUND; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.VIDEO_ALREADY_WATCHING; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.VIDEO_NOT_ACCESSIBLE; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.VIDEO_NOT_IN_ORGANIZATION; +import static app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import app.allstackproject.privideo.domain.admin.dto.ReadAllVideoItem; +import app.allstackproject.privideo.domain.comment.repository.CommentRepository; +import app.allstackproject.privideo.domain.history.entity.History; +import app.allstackproject.privideo.domain.history.repository.HistoryRepository; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.entity.MemberGroup; +import app.allstackproject.privideo.domain.member.entity.MemberGroupMapping; +import app.allstackproject.privideo.domain.member.repository.MemberGroupMappingRepository; +import app.allstackproject.privideo.domain.member.repository.MemberGroupRepository; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.organization.dto.enums.OpenScopeType; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.domain.quiz.repository.QuizRepository; +import app.allstackproject.privideo.domain.scrap.repository.ScrapRepository; +import app.allstackproject.privideo.domain.video.dto.request.CreateVideoRequest; +import app.allstackproject.privideo.domain.video.dto.request.LeaveVideoSessionInfo; +import app.allstackproject.privideo.domain.video.dto.request.ModifyVideoRequest; +import app.allstackproject.privideo.domain.video.dto.response.CreateVideoResponse; +import app.allstackproject.privideo.domain.video.dto.response.JoinVideoSessionResult; +import app.allstackproject.privideo.domain.video.dto.response.ReadVideoInfoResponse; +import app.allstackproject.privideo.domain.video.entity.Category; +import app.allstackproject.privideo.domain.video.entity.Video; +import app.allstackproject.privideo.domain.video.enums.UploadStatusType; +import app.allstackproject.privideo.domain.video.repository.CategoryRepository; +import app.allstackproject.privideo.domain.video.repository.VideoCategoryMappingRepository; +import app.allstackproject.privideo.domain.video.repository.VideoMemberGroupMappingRepository; +import app.allstackproject.privideo.domain.video.repository.VideoRedisRepository; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.domain.video.service.AiFunctionService; +import app.allstackproject.privideo.domain.video.service.LogService; +import app.allstackproject.privideo.domain.video.service.VideoService; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.response.SuccessResponse; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import app.allstackproject.privideo.global.util.S3Util; +import java.math.BigInteger; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class VideoServiceTest { + + @InjectMocks + private VideoService videoService; + + @Mock + private MemberRepository memberRepository; + @Mock + private MemberGroupRepository memberGroupRepository; + @Mock + private VideoRepository videoRepository; + @Mock + private HistoryRepository historyRepository; + @Mock + private LogService logService; + @Mock + private CategoryRepository categoryRepository; + @Mock + private ScrapRepository scrapRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private S3Util s3Util; + @Mock + private CdnUrlProvider cdnUrlProvider; + @Mock + private VideoMemberGroupMappingRepository videoMemberGroupMappingRepository; + @Mock + private VideoCategoryMappingRepository videoCategoryMappingRepository; + @Mock + private QuizRepository quizRepository; + @Mock + private CommentRepository commentRepository; + @Mock + private AiFunctionService aiFunctionService; + @Mock + private MemberGroupMappingRepository memberGroupMappingRepository; + @Mock + private VideoRedisRepository videoRedisRepository; + + // ===== prepareJoinVideoSession ===== + @Nested + @DisplayName("prepareJoinVideoSession") + class PrepareJoinVideoSession { + + @Test + @DisplayName("정상 진입 - 미완료 history -> create 타입 결과") + void success_createResult() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Video video = mock(Video.class); + + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + given(video.getWholeTime()).willReturn(120L); + given(video.getHlsPrefix()).willReturn("hls/org-10/uuid"); + given(video.getIsComment()).willReturn(true); + given(video.getAiFunctionType()).willReturn(NONE); + + given(videoRedisRepository.existsWatchSession(anyString())) + .willReturn(false); + + given(memberGroupRepository.isAccessibleToVideo(memberId, videoId)) + .willReturn(true); + + given(s3Util.generatePlaybackUrl("hls/org-10/uuid")) + .willReturn("https://cdn/hls/org-10/uuid/master.m3u8"); + + given(logService.getSegViewCounts(eq(videoId), anyInt())) + .willReturn(List.of(1L, 2L, 3L)); + + given(scrapRepository.existsByMemberIdAndVideoId(memberId, videoId)) + .willReturn(false); + + given(categoryRepository.findAllByVideoId(videoId)) + .willReturn(List.of("cat1", "cat2")); + + History history = mock(History.class); + given(historyRepository.findByMemberIdAndVideoId(memberId, videoId)) + .willReturn(Optional.of(history)); + given(history.getRecentPositionSec()).willReturn(30L); + given(history.isComplete()).willReturn(false); + + // when + JoinVideoSessionResult result = videoService.prepareJoinVideoSession(memberId, orgId, videoId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPlaybackUrl()).isEqualTo("https://cdn/hls/org-10/uuid/master.m3u8"); + assertThat(result.getSegViewCnts()).containsExactly(1L, 2L, 3L); + assertThat(result.getIsScrapped()).isFalse(); + } + + @Test + @DisplayName("완료된 history가 있으면 completed 타입 결과 반환") + void success_completedResult() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Video video = mock(Video.class); + + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + given(video.getWholeTime()).willReturn(60L); + given(video.getHlsPrefix()).willReturn("hls/org-10/uuid"); + given(video.getIsComment()).willReturn(true); + given(video.getAiFunctionType()).willReturn(SUMMARY); + given(video.getAiSummary()).willReturn("요약 내용"); + + given(videoRedisRepository.existsWatchSession(anyString())) + .willReturn(false); + + given(memberGroupRepository.isAccessibleToVideo(memberId, videoId)) + .willReturn(true); + + given(s3Util.generatePlaybackUrl("hls/org-10/uuid")) + .willReturn("https://cdn/hls/org-10/uuid/master.m3u8"); + + given(logService.getSegViewCounts(eq(videoId), anyInt())) + .willReturn(List.of(1L, 2L)); + + given(scrapRepository.existsByMemberIdAndVideoId(memberId, videoId)) + .willReturn(true); + + given(categoryRepository.findAllByVideoId(videoId)) + .willReturn(List.of("cat1")); + + History history = mock(History.class); + given(historyRepository.findByMemberIdAndVideoId(memberId, videoId)) + .willReturn(Optional.of(history)); + given(history.getRecentPositionSec()).willReturn(60L); + given(history.isComplete()).willReturn(true); + + // when + JoinVideoSessionResult result = videoService.prepareJoinVideoSession(memberId, orgId, videoId); + + // then + assertThat(result.getWatchCompleted()).isTrue(); + assertThat(result.getVideo().getRecentPositionSec()).isEqualTo(60L); + assertThat(result.getAiSummary()).isEqualTo("요약 내용"); + } + + @Test + @DisplayName("이미 시청 세션이 존재하면 VIDEO_ALREADY_WATCHING 예외") + void alreadyWatching() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Video video = mock(Video.class); + + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + + given(videoRedisRepository.existsWatchSession(anyString())) + .willReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.prepareJoinVideoSession(memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_ALREADY_WATCHING); + } + + @Test + @DisplayName("조직에 속해있지 않으면 MEMBER_NOT_IN_ORGANIZATION 예외") + void memberNotInOrg() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.prepareJoinVideoSession(memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(MEMBER_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("영상이 다른 조직에 속해 있으면 VIDEO_NOT_IN_ORGANIZATION 예외") + void videoNotInOrg() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Organization otherOrg = mock(Organization.class); + Video video = mock(Video.class); + + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(otherOrg); + given(otherOrg.getId()).willReturn(999L); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.prepareJoinVideoSession(memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("그룹 권한이 없으면 VIDEO_NOT_ACCESSIBLE 예외") + void notAccessibleToVideo() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Video video = mock(Video.class); + + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + + given(videoRedisRepository.existsWatchSession(anyString())) + .willReturn(false); + + given(memberGroupRepository.isAccessibleToVideo(memberId, videoId)) + .willReturn(false); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.prepareJoinVideoSession(memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_NOT_ACCESSIBLE); + } + } + + // ===== openWatchSession ===== + @Nested + @DisplayName("openWatchSession") + class OpenWatchSession { + + @Test + @DisplayName("정상 오픈 시 history 생성/저장, watch 카운트/세션 생성 및 OrgView 증가") + void success() { + String sessionId = "session"; + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(videoRedisRepository.existsWatchSession(sessionId)) + .willReturn(false); + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + + given(historyRepository.findByMemberIdAndVideoId(memberId, videoId)) + .willReturn(Optional.empty()); + + Member memberRef = mock(Member.class); + given(memberRepository.getReferenceById(memberId)).willReturn(memberRef); + + // when + videoService.openWatchSession(sessionId, memberId, orgId, videoId); + + // then + verify(video).watch(); + verify(videoRedisRepository).createWatchSession(sessionId, memberId); + verify(logService).incOrgViewBucket(eq(orgId), any(Instant.class)); + } + + @Test + @DisplayName("이미 세션 존재하면 VIDEO_ALREADY_WATCHING") + void alreadyWatching() { + String sessionId = "session"; + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(videoRedisRepository.existsWatchSession(sessionId)) + .willReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.openWatchSession(sessionId, memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_ALREADY_WATCHING); + } + } + + // ===== leaveVideoSession ===== + @Nested + @DisplayName("leaveVideoSession") + class LeaveVideoSession { + + @Test + @DisplayName("정상 종료 시 history/seg 로그 업데이트 및 세션 삭제") + void success() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + String sessionId = UUID.nameUUIDFromBytes( + (memberId.toString() + videoId.toString()).getBytes(StandardCharsets.UTF_8) + ).toString(); + + LeaveVideoSessionInfo info = mock(LeaveVideoSessionInfo.class); + given(info.getSessionId()).willReturn(sessionId); + given(info.getOrgId()).willReturn(orgId); + given(info.getVideoId()).willReturn(videoId); + given(info.getWatchSegments()).willReturn("1111"); + given(info.getWatchRate()).willReturn(80L); + given(info.getRecentPosition()).willReturn(40L); + given(info.getIsQuit()).willReturn(false); + + given(videoRedisRepository.existsWatchSession(sessionId)).willReturn(true); + given(videoRedisRepository.getMemberIdByWatchSession(sessionId)).willReturn(memberId); + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Video video = mock(Video.class); + History history = mock(History.class); + + given(memberRepository.findByIdAndStatus(memberId, ACTIVE)) + .willReturn(Optional.of(member)); + given(member.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(video.getWholeTime()).willReturn(40L); + + given(historyRepository.findByMemberIdAndVideoId(memberId, videoId)) + .willReturn(Optional.of(history)); + given(history.isComplete()).willReturn(false); + + // when + boolean result = videoService.leaveVideoSession(info); + + // then + assertThat(result).isTrue(); + verify(videoRedisRepository).deleteWatchSession(sessionId); + verify(history).update(eq(80L), eq(40L), anyBoolean()); + verify(history).updateLastWatchedAt(); + verify(logService).incSegViewBucket(eq(videoId), any(BigInteger.class), anyInt()); + } + + @Test + @DisplayName("quit=true 라면 quit 카운트 및 quit 버킷도 증가") + void success_quit() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + String sessionId = UUID.nameUUIDFromBytes( + (memberId.toString() + videoId.toString()).getBytes(StandardCharsets.UTF_8) + ).toString(); + + LeaveVideoSessionInfo info = mock(LeaveVideoSessionInfo.class); + given(info.getSessionId()).willReturn(sessionId); + given(info.getOrgId()).willReturn(orgId); + given(info.getVideoId()).willReturn(videoId); + given(info.getWatchSegments()).willReturn("1111"); + given(info.getWatchRate()).willReturn(50L); + given(info.getRecentPosition()).willReturn(20L); + given(info.getIsQuit()).willReturn(true); + + given(videoRedisRepository.existsWatchSession(sessionId)).willReturn(true); + given(videoRedisRepository.getMemberIdByWatchSession(sessionId)).willReturn(memberId); + + Member member = mock(Member.class); + Organization org = mock(Organization.class); + Video video = mock(Video.class); + History history = mock(History.class); + + given(memberRepository.findByIdAndStatus(memberId, ACTIVE)) + .willReturn(Optional.of(member)); + given(member.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(video.getWholeTime()).willReturn(40L); + + given(historyRepository.findByMemberIdAndVideoId(memberId, videoId)) + .willReturn(Optional.of(history)); + given(history.isComplete()).willReturn(false); + + // when + boolean result = videoService.leaveVideoSession(info); + + // then + assertThat(result).isTrue(); + verify(videoRedisRepository).deleteWatchSession(sessionId); + verify(history).update(eq(50L), eq(20L), anyBoolean()); + verify(history).updateLastWatchedAt(); + verify(logService).incSegViewBucket(eq(videoId), any(BigInteger.class), anyInt()); + + verify(logService).incSegQuitBucket(eq(videoId), eq(20L), anyInt()); + verify(video).quit(); + } + + @Test + @DisplayName("Redis에 세션이 없으면 PLAY_SESSION_NOT_FOUND") + void noSession() { + LeaveVideoSessionInfo info = mock(LeaveVideoSessionInfo.class); + given(info.getSessionId()).willReturn("any-session"); + + given(videoRedisRepository.existsWatchSession("any-session")) + .willReturn(false); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.leaveVideoSession(info)); + + assertThat(ex.getResponseStatus()).isEqualTo(PLAY_SESSION_NOT_FOUND); + } + } + + // ===== getMemberVideos ===== + @Nested + @DisplayName("getMemberVideos") + class GetMemberVideos { + + @Test + @DisplayName("멤버가 조직에 속해 있지 않으면 MEMBER_NOT_IN_ORGANIZATION") + void memberNotInOrg() { + Long memberId = 1L; + Long orgId = 10L; + + given(memberRepository.existsByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(false); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.getMemberVideos(memberId, orgId)); + + assertThat(ex.getResponseStatus()).isEqualTo(MEMBER_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("썸네일 키를 CDN URL로 변환해서 반환") + void success() { + Long memberId = 1L; + Long orgId = 10L; + + given(memberRepository.existsByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(true); + + ReadAllVideoItem item1 = new ReadAllVideoItem(); + item1.setThumbnailUrl("thumb1"); + ReadAllVideoItem item2 = new ReadAllVideoItem(); + item2.setThumbnailUrl("thumb2"); + + given(videoRepository.findByOrgIdAndCreatorId(orgId, memberId)) + .willReturn(List.of(item1, item2)); + given(cdnUrlProvider.generateImgUrl("thumb1")).willReturn("cdn/thumb1"); + given(cdnUrlProvider.generateImgUrl("thumb2")).willReturn("cdn/thumb2"); + + List result = videoService.getMemberVideos(memberId, orgId); + + assertThat(result) + .extracting(ReadAllVideoItem::getThumbnailUrl) + .containsExactly("cdn/thumb1", "cdn/thumb2"); + } + } + + // ===== createVideo ===== + @Nested + @DisplayName("createVideo") + class CreateVideo { + + @Test + @DisplayName("정상 생성 시 Video 저장 및 presigned URL 반환") + void success() throws Exception { + Long memberId = 1L; + Long orgId = 10L; + + Organization org = mock(Organization.class); + Member member = mock(Member.class); + + given(organizationRepository.findById(orgId)).willReturn(Optional.of(org)); + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + CreateVideoRequest req = mock(CreateVideoRequest.class); + given(req.getAiFunction()).willReturn("SUMMARY"); + given(req.getTitle()).willReturn("title"); + given(req.getDescription()).willReturn("desc"); + given(req.getWholeTime()).willReturn(120L); + given(req.getIsComment()).willReturn(true); + given(req.getExpiredAt()).willReturn(LocalDate.now()); + given(req.getMemberGroups()).willReturn(List.of(1L, 2L)); + given(req.getCategories()).willReturn(List.of(10L, 20L)); + + MultipartFile thumbnail = mock(MultipartFile.class); + given(req.getThumbnailImg()).willReturn(thumbnail); + given(thumbnail.getOriginalFilename()).willReturn("thumb.png"); + + given(s3Util.isImageFile(thumbnail)).willReturn(true); + given(s3Util.generateVideoKey(eq(orgId), anyString())) + .willReturn("org-10/uuid/video.mp4"); + given(s3Util.generateImgKey(eq(orgId), anyString(), anyString(), any())) + .willReturn("images/org-10/thumb.png"); + given(s3Util.generateHlsPrefix("org-10/uuid/video.mp4")) + .willReturn("hls/org-10/uuid"); + + given(videoRepository.save(any(Video.class))).willAnswer(invocation -> { + return invocation.getArgument(0); + }); + + Set myGroupIds = Set.of(1L, 2L, 3L); + MemberGroupMapping m1 = mock(MemberGroupMapping.class); + MemberGroupMapping m2 = mock(MemberGroupMapping.class); + MemberGroup g1 = mock(MemberGroup.class); + MemberGroup g2 = mock(MemberGroup.class); + Organization groupOrg = mock(Organization.class); + given(groupOrg.getId()).willReturn(orgId); + given(g1.getId()).willReturn(1L); + given(g2.getId()).willReturn(2L); + given(g1.getOrganization()).willReturn(groupOrg); + given(g2.getOrganization()).willReturn(groupOrg); + given(m1.getMemberGroup()).willReturn(g1); + given(m2.getMemberGroup()).willReturn(g2); + + given(memberGroupMappingRepository.findAllByMemberId(memberId)) + .willReturn(List.of(m1, m2)); + given(memberGroupRepository.findAllById(List.of(1L, 2L))) + .willReturn(List.of(g1, g2)); + + Category c1 = mock(Category.class); + Category c2 = mock(Category.class); + given(c1.getMemberGroupId()).willReturn(1L); + given(c2.getMemberGroupId()).willReturn(2L); + given(categoryRepository.findAllById(List.of(10L, 20L))) + .willReturn(List.of(c1, c2)); + + given(s3Util.generatePresignedUploadUrl("org-10/uuid/video.mp4")) + .willReturn(new URL("https://s3/presigned")); + + CreateVideoResponse res = videoService.createVideo(memberId, orgId, req); + + assertThat(res.getPresignedUrl()).isEqualTo("https://s3/presigned"); + verify(videoMemberGroupMappingRepository).saveAll(anyList()); + verify(videoCategoryMappingRepository).saveAll(anyList()); + } + } + + // ===== updateVideoEncodingResult ===== + @Nested + @DisplayName("updateVideoEncodingResult") + class UpdateVideoEncodingResult { + + @Test + @DisplayName("SUCCESS - AI 기능 수행 후 업로드 상태 COMPLETE로 변경") + void successWithAi() { + Long orgId = 10L; + String uuid = "uuid"; + String videoKey = "org-10/uuid/video.mp4"; + + given(s3Util.generateVideoKey(orgId, uuid)) + .willReturn(videoKey); + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + given(org.getId()).willReturn(orgId); + + given(videoRepository.findByVideoKey(videoKey)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(video.getId()).willReturn(100L); + given(video.getAiFunctionType()).willReturn(SUMMARY); + + SuccessResponse res = videoService.updateVideoEncodingResult(orgId, uuid, "SUCCESS"); + + assertThat(res.getIsSuccess()).isTrue(); + verify(aiFunctionService).processAiFunction(100L, videoKey, SUMMARY); + verify(video).setUploadStatus(COMPLETE); + } + + @Test + @DisplayName("FAILED - 매핑 삭제, 파일 삭제, 상태 FAIL") + void failed() { + Long orgId = 10L; + String uuid = "uuid"; + String videoKey = "org-10/uuid/video.mp4"; + + given(s3Util.generateVideoKey(orgId, uuid)) + .willReturn(videoKey); + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + given(org.getId()).willReturn(orgId); + + given(videoRepository.findByVideoKey(videoKey)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(video.getId()).willReturn(100L); + given(video.getVideoKey()).willReturn("videoKey"); + given(video.getThumbnailKey()).willReturn("thumbKey"); + + SuccessResponse res = videoService.updateVideoEncodingResult(orgId, uuid, "FAILED"); + + assertThat(res.getIsSuccess()).isTrue(); + verify(videoMemberGroupMappingRepository).deleteAllByVideoId(100L); + verify(videoCategoryMappingRepository).deleteAllByVideoId(100L); + verify(video).setUploadStatus(FAIL); + verify(s3Util).deleteFileByKey("videoKey", false); + verify(s3Util).deleteFileByKey("thumbKey", true); + } + + @Test + @DisplayName("알 수 없는 status 면 INVALID_AIRFLOW_STATUS") + void invalidStatus() { + Long orgId = 10L; + String uuid = "uuid"; + String videoKey = "org-10/uuid/video.mp4"; + + given(s3Util.generateVideoKey(orgId, uuid)) + .willReturn(videoKey); + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + given(org.getId()).willReturn(orgId); + + given(videoRepository.findByVideoKey(videoKey)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + + ApiException ex = assertThrows(ApiException.class, + () -> videoService.updateVideoEncodingResult(orgId, uuid, "UNKNOWN")); + + assertThat(ex.getResponseStatus()).isEqualTo(INVALID_AIRFLOW_STATUS); + } + } + + // ===== readVideoEncodingResult ===== + @Nested + @DisplayName("readVideoEncodingResult") + class ReadVideoEncodingResult { + + @Test + @DisplayName("정상 조회 시 UploadStatusType 반환") + void success() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + Member creator = mock(Member.class); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + given(video.getCreator()).willReturn(creator); + given(creator.getId()).willReturn(memberId); + given(video.getUploadStatus()).willReturn(COMPLETE); + + UploadStatusType res = videoService.readVideoEncodingResult(memberId, orgId, videoId); + + assertThat(res).isEqualTo(COMPLETE); + } + + @Test + @DisplayName("업로드 실패 상태라면 비디오 삭제 후 FAIL 반환") + void deleteOnFail() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + Member creator = mock(Member.class); + + given(videoRepository.findById(videoId)) + .willReturn(Optional.of(video)); + given(video.getOrganization()).willReturn(org); + given(org.getId()).willReturn(orgId); + given(video.getCreator()).willReturn(creator); + given(creator.getId()).willReturn(memberId); + given(video.getUploadStatus()).willReturn(FAIL); + + UploadStatusType res = videoService.readVideoEncodingResult(memberId, orgId, videoId); + + assertThat(res).isEqualTo(FAIL); + verify(videoRepository).delete(video); + } + } + + // ===== readVideoInfo ===== + @Nested + @DisplayName("readVideoInfo") + class ReadVideoInfo { + + @Test + @DisplayName("내 그룹이 없으면 빈 memberGroupItems 와 openScope 반환") + void noMyGroups() { + Long orgId = 10L; + Long memberId = 1L; + Long videoId = 100L; + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + Member creator = mock(Member.class); + + given(videoRepository.findByIdAndOrganizationId(videoId, orgId)) + .willReturn(Optional.of(video)); + given(video.getCreator()).willReturn(creator); + given(creator.getId()).willReturn(memberId); + given(video.getThumbnailKey()).willReturn("thumb"); + given(cdnUrlProvider.generateImgUrl("thumb")).willReturn("cdn/thumb"); + given(videoMemberGroupMappingRepository.findAllByVideoId(videoId)) + .willReturn(List.of()); + given(videoCategoryMappingRepository.findAllByVideoId(videoId)) + .willReturn(List.of()); + given(video.getTitle()).willReturn("title"); + given(video.getDescription()).willReturn("desc"); + given(video.getWatchCnt()).willReturn(10L); + given(video.getExpiredAt()).willReturn(LocalDate.now()); + given(video.getIsComment()).willReturn(true); + + given(memberGroupMappingRepository.findAllByMemberId(memberId)) + .willReturn(List.of()); + + ReadVideoInfoResponse res = videoService.readVideoInfo(orgId, memberId, videoId); + + assertThat(res.getMemberGroups()).isEmpty(); + assertThat(res.getOpenScope()).isEqualTo(OpenScopeType.PUBLIC); + } + } + + // ===== modifyVideo ===== + @Nested + @DisplayName("modifyVideo") + class ModifyVideo { + + @Test + @DisplayName("정상 수정 시 true 반환 및 매핑 재저장") + void success() { + Long orgId = 10L; + Long memberId = 1L; + Long videoId = 100L; + + ModifyVideoRequest req = mock(ModifyVideoRequest.class); + given(req.getMemberGroups()).willReturn(List.of(1L, 2L)); + given(req.getCategories()).willReturn(List.of(10L, 20L)); + given(req.getDescription()).willReturn("new desc"); + given(req.getIsComment()).willReturn(false); + given(req.getExpiredAt()).willReturn(LocalDate.now()); + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + Member creator = mock(Member.class); + + given(videoRepository.findByIdAndOrganizationId(videoId, orgId)) + .willReturn(Optional.of(video)); + given(video.getCreator()).willReturn(creator); + given(creator.getId()).willReturn(memberId); + + MemberGroupMapping m1 = mock(MemberGroupMapping.class); + MemberGroupMapping m2 = mock(MemberGroupMapping.class); + MemberGroup g1 = mock(MemberGroup.class); + MemberGroup g2 = mock(MemberGroup.class); + Organization groupOrg = mock(Organization.class); + given(groupOrg.getId()).willReturn(orgId); + given(g1.getId()).willReturn(1L); + given(g2.getId()).willReturn(2L); + given(g1.getOrganization()).willReturn(groupOrg); + given(g2.getOrganization()).willReturn(groupOrg); + given(m1.getMemberGroup()).willReturn(g1); + given(m2.getMemberGroup()).willReturn(g2); + + given(memberGroupMappingRepository.findAllByMemberId(memberId)) + .willReturn(List.of(m1, m2)); + given(memberGroupRepository.findAllById(List.of(1L, 2L))) + .willReturn(List.of(g1, g2)); + + Category c1 = mock(Category.class); + Category c2 = mock(Category.class); + given(c1.getMemberGroupId()).willReturn(1L); + given(c2.getMemberGroupId()).willReturn(2L); + given(categoryRepository.findAllById(List.of(10L, 20L))) + .willReturn(List.of(c1, c2)); + + boolean res = videoService.modifyVideo(orgId, memberId, videoId, req); + + assertThat(res).isTrue(); + verify(video).modify(eq("new desc"), eq(false), any()); + verify(videoMemberGroupMappingRepository).deleteAllByVideoId(videoId); + verify(videoCategoryMappingRepository).deleteAllByVideoId(videoId); + verify(videoMemberGroupMappingRepository).saveAll(anyList()); + verify(videoCategoryMappingRepository).saveAll(anyList()); + } + } + + // ===== deleteVideo ===== + @Nested + @DisplayName("deleteVideo") + class DeleteVideo { + + @Test + @DisplayName("정상 삭제 시 관련 엔티티 및 S3 파일 모두 정리") + void success() { + Long orgId = 10L; + Long memberId = 1L; + Long videoId = 100L; + + Video video = mock(Video.class); + Organization org = mock(Organization.class); + Member creator = mock(Member.class); + + given(videoRepository.findByIdAndOrganizationId(videoId, orgId)) + .willReturn(Optional.of(video)); + given(video.getCreator()).willReturn(creator); + given(creator.getId()).willReturn(memberId); + given(video.getThumbnailKey()).willReturn("thumbKey"); + given(video.getVideoKey()).willReturn("videoKey"); + + boolean result = videoService.deleteVideo(orgId, memberId, videoId); + + assertThat(result).isTrue(); + verify(s3Util).deleteFileByKey("thumbKey", true); + verify(s3Util).deleteFileByKey("videoKey", false); + verify(commentRepository).deleteAllByVideoId(videoId); + verify(historyRepository).deleteAllByVideoId(videoId); + verify(quizRepository).deleteAllByVideoId(videoId); + verify(scrapRepository).deleteAllByVideoId(videoId); + verify(videoMemberGroupMappingRepository).deleteAllByVideoId(videoId); + verify(videoCategoryMappingRepository).deleteAllByVideoId(videoId); + verify(videoRepository).delete(video); + } + } +} \ No newline at end of file From 7a3e789d5d60a67be56daeeafa58849531c093e2 Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Fri, 5 Dec 2025 09:24:50 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20History=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/history/HistoryServiceTest.java | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/test/java/app/allstackproject/privideo/domain/history/HistoryServiceTest.java diff --git a/src/test/java/app/allstackproject/privideo/domain/history/HistoryServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/history/HistoryServiceTest.java new file mode 100644 index 0000000..be7fba5 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/history/HistoryServiceTest.java @@ -0,0 +1,128 @@ +package app.allstackproject.privideo.domain.history; + +import static app.allstackproject.privideo.domain.video.service.LogService.SEGMENT_SECONDS; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.MEMBER_NOT_IN_ORGANIZATION; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.VIDEO_CREATE_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +import app.allstackproject.privideo.domain.admin.dto.VideoIntervalLogItem; +import app.allstackproject.privideo.domain.history.repository.HistoryRepository; +import app.allstackproject.privideo.domain.history.service.HistoryService; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.video.entity.Video; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.domain.video.service.LogService; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.LongStream; +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; + +@ExtendWith(MockitoExtension.class) +class HistoryServiceTest { + + @InjectMocks + private HistoryService historyService; + + @Mock + private HistoryRepository historyRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private VideoRepository videoRepository; + @Mock + private LogService logService; + @Mock + private CdnUrlProvider cdnUrlProvider; + + @Test + @DisplayName("멤버가 조직에 속해있지 않으면 MEMBER_NOT_IN_ORGANIZATION 예외") + void readMyVideoReport_memberNotInOrg() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(memberRepository.existsByIdAndOrganizationIdAndStatus( + memberId, orgId, app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE)) + .willReturn(false); + + ApiException ex = assertThrows(ApiException.class, + () -> historyService.readMyVideoReport(memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(MEMBER_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("다른 사용자가 만든 영상이면 VIDEO_CREATE_NOT_FOUND 예외") + void readMyVideoReport_notCreator() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member memberCreator = org.mockito.Mockito.mock(Member.class); + Member otherMember = org.mockito.Mockito.mock(Member.class); + Video video = org.mockito.Mockito.mock(Video.class); + + given(memberRepository.existsByIdAndOrganizationIdAndStatus( + memberId, orgId, app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE)) + .willReturn(true); + + given(videoRepository.findByIdAndOrganizationId(videoId, orgId)) + .willReturn(Optional.of(video)); + + given(video.getCreator()).willReturn(memberCreator); + given(memberCreator.getId()).willReturn(999L); + + ApiException ex = assertThrows(ApiException.class, + () -> historyService.readMyVideoReport(memberId, orgId, videoId)); + + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_CREATE_NOT_FOUND); + } + + @Test + @DisplayName("정상 조회 시 구간별 리포트 리스트 반환") + void readMyVideoReport_success() { + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + Member memberCreator = org.mockito.Mockito.mock(Member.class); + Video video = org.mockito.Mockito.mock(Video.class); + + given(memberRepository.existsByIdAndOrganizationIdAndStatus( + memberId, orgId, app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE)) + .willReturn(true); + + given(videoRepository.findByIdAndOrganizationId(videoId, orgId)) + .willReturn(Optional.of(video)); + + given(video.getCreator()).willReturn(memberCreator); + given(memberCreator.getId()).willReturn(memberId); + + long wholeTime = 2L * SEGMENT_SECONDS; + int segCnt = (int) Math.ceil((double) wholeTime / SEGMENT_SECONDS); + + List viewCounts = LongStream.of(10L, 20L).boxed().collect(Collectors.toList()); + List quitCounts = LongStream.of(1L, 2L).boxed().collect(Collectors.toList()); + + given(video.getWholeTime()).willReturn(wholeTime); + given(logService.getSegViewCounts(videoId, segCnt)).willReturn(viewCounts); + given(logService.getSegQuitCounts(videoId, segCnt)).willReturn(quitCounts); + given(logService.getSegViewCounts(videoId, 2)).willReturn(viewCounts); + given(logService.getSegQuitCounts(videoId, 2)).willReturn(quitCounts); + + List result = historyService.readMyVideoReport(memberId, orgId, videoId); + + assertThat(result).hasSize(2); + } +} From 2389148f26397bba7d935d1ecef4f106993b9f37 Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Fri, 5 Dec 2025 11:12:50 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20Comment=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/CommentServiceTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/test/java/app/allstackproject/privideo/domain/comment/CommentServiceTest.java diff --git a/src/test/java/app/allstackproject/privideo/domain/comment/CommentServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/comment/CommentServiceTest.java new file mode 100644 index 0000000..99b895f --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/comment/CommentServiceTest.java @@ -0,0 +1,99 @@ +package app.allstackproject.privideo.domain.comment; + +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.COMMENT_NOT_FOUND; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.COMMENT_UNAUTHORIZED_DELETE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import app.allstackproject.privideo.domain.comment.entity.Comment; +import app.allstackproject.privideo.domain.comment.repository.CommentRepository; +import app.allstackproject.privideo.domain.comment.service.CommentService; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private CommentRepository commentRepository; + @Mock + private VideoRepository videoRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private CdnUrlProvider cdnUrlProvider; + + @Test + @DisplayName("존재하지 않는 댓글이면 COMMENT_NOT_FOUND 예외") + void deleteComment_notFound() { + Long memberId = 1L; + Long orgId = 10L; + Long commentId = 100L; + + given(commentRepository.findById(commentId)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> commentService.deleteComment(memberId, orgId, commentId)); + + assertThat(ex.getResponseStatus()).isEqualTo(COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("다른 사용자의 댓글 삭제 시 COMMENT_UNAUTHORIZED_DELETE 예외") + void deleteComment_unauthorized() { + Long memberId = 1L; + Long orgId = 10L; + Long commentId = 100L; + + Comment comment = org.mockito.Mockito.mock(Comment.class); + Member owner = org.mockito.Mockito.mock(Member.class); + + given(commentRepository.findById(commentId)) + .willReturn(Optional.of(comment)); + given(comment.getMember()).willReturn(owner); + given(owner.getId()).willReturn(999L); // 다른 사람 + + ApiException ex = assertThrows(ApiException.class, + () -> commentService.deleteComment(memberId, orgId, commentId)); + + assertThat(ex.getResponseStatus()).isEqualTo(COMMENT_UNAUTHORIZED_DELETE); + } + + @Test + @DisplayName("본인 댓글 삭제 성공 시 자식 댓글 + 본인 댓글 삭제") + void deleteComment_success() { + Long memberId = 1L; + Long orgId = 10L; + Long commentId = 100L; + + Comment comment = org.mockito.Mockito.mock(Comment.class); + Member owner = org.mockito.Mockito.mock(Member.class); + + given(commentRepository.findById(commentId)) + .willReturn(Optional.of(comment)); + given(comment.getMember()).willReturn(owner); + given(owner.getId()).willReturn(memberId); + + boolean result = commentService.deleteComment(memberId, orgId, commentId); + + assertThat(result).isTrue(); + verify(commentRepository).deleteAllByParentCommentId(commentId); + verify(commentRepository).deleteById(commentId); + } +} From f2c438b61f5e3d07d7d07813d987e7afe659c3bc Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Fri, 5 Dec 2025 15:30:50 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20Scrap=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/scrap/ScrapServiceTest.java | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/test/java/app/allstackproject/privideo/domain/scrap/ScrapServiceTest.java diff --git a/src/test/java/app/allstackproject/privideo/domain/scrap/ScrapServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/scrap/ScrapServiceTest.java new file mode 100644 index 0000000..cbeb6ce --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/scrap/ScrapServiceTest.java @@ -0,0 +1,234 @@ +package app.allstackproject.privideo.domain.scrap; + +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_SCRAP_REQUEST; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.VIDEO_ALREADY_SCRAPPED; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.VIDEO_NOT_SCRAPPED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import app.allstackproject.privideo.domain.history.dto.HistoryItem; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.scrap.dto.ScrapResponse; +import app.allstackproject.privideo.domain.scrap.entity.Scrap; +import app.allstackproject.privideo.domain.scrap.repository.ScrapRepository; +import app.allstackproject.privideo.domain.scrap.service.ScrapService; +import app.allstackproject.privideo.domain.video.entity.Video; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import java.util.List; +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.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class ScrapServiceTest { + + @InjectMocks + private ScrapService scrapService; + + @Mock + private ScrapRepository scrapRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private VideoRepository videoRepository; + + @Mock + private CdnUrlProvider cdnUrlProvider; + + @Test + @DisplayName("getUserScraps - 썸네일 키를 CDN URL 로 변환해서 반환") + void getUserScraps_convertImg() { + // given + Long memberId = 1L; + Long orgId = 10L; + + HistoryItem item1 = new HistoryItem(); + item1.setImg("img1.png"); + HistoryItem item2 = new HistoryItem(); + item2.setImg("img2.png"); + + given(scrapRepository.findByMemberIdAndOrganizationId(memberId, orgId)) + .willReturn(List.of(item1, item2)); + given(cdnUrlProvider.generateImgUrl("img1.png")).willReturn("https://cdn/img1.png"); + given(cdnUrlProvider.generateImgUrl("img2.png")).willReturn("https://cdn/img2.png"); + + // when + ScrapResponse res = scrapService.getUserScraps(memberId, orgId); + + // then + assertThat(res.getAllScrap()) + .extracting(HistoryItem::getImg) + .containsExactly("https://cdn/img1.png", "https://cdn/img2.png"); + } + + @Test + @DisplayName("addVideoScrap - member/org/video 조합이 유효하지 않으면 INVALID_SCRAP_REQUEST") + void addVideoScrap_invalidRequest() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(false); + + // when + ApiException ex = assertThrows(ApiException.class, + () -> scrapService.addVideoScrap(memberId, orgId, videoId)); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(INVALID_SCRAP_REQUEST); + } + + @Test + @DisplayName("addVideoScrap - 이미 스크랩 되어 있으면 VIDEO_ALREADY_SCRAPPED") + void addVideoScrap_alreadyScrapped() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(true); + given(scrapRepository.existsByMemberIdAndVideoId(memberId, videoId)) + .willReturn(true); + + // when + ApiException ex = assertThrows(ApiException.class, + () -> scrapService.addVideoScrap(memberId, orgId, videoId)); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_ALREADY_SCRAPPED); + } + + @Test + @DisplayName("addVideoScrap - 경쟁 상태로 DataIntegrityViolationException 발생 시에도 VIDEO_ALREADY_SCRAPPED") + void addVideoScrap_raceCondition() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(true); + given(scrapRepository.existsByMemberIdAndVideoId(memberId, videoId)) + .willReturn(false); + + Member memberRef = mock(Member.class); + Video videoRef = mock(Video.class); + given(memberRepository.getReferenceById(memberId)).willReturn(memberRef); + given(videoRepository.getReferenceById(videoId)).willReturn(videoRef); + + given(scrapRepository.save(any(Scrap.class))) + .willThrow(new DataIntegrityViolationException("duplicate")); + + // when + ApiException ex = assertThrows(ApiException.class, + () -> scrapService.addVideoScrap(memberId, orgId, videoId)); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_ALREADY_SCRAPPED); + } + + @Test + @DisplayName("addVideoScrap - 정상 스크랩 시 true 반환") + void addVideoScrap_success() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(true); + given(scrapRepository.existsByMemberIdAndVideoId(memberId, videoId)) + .willReturn(false); + + Member memberRef = mock(Member.class); + Video videoRef = mock(Video.class); + given(memberRepository.getReferenceById(memberId)).willReturn(memberRef); + given(videoRepository.getReferenceById(videoId)).willReturn(videoRef); + given(scrapRepository.save(any(Scrap.class))) + .willReturn(mock(Scrap.class)); + + // when + boolean result = scrapService.addVideoScrap(memberId, orgId, videoId); + + // then + assertThat(result).isTrue(); + verify(scrapRepository).save(any(Scrap.class)); + } + + @Test + @DisplayName("deleteVideoScrap - member/org/video 조합이 유효하지 않으면 INVALID_SCRAP_REQUEST") + void deleteVideoScrap_invalidRequest() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(false); + + // when + ApiException ex = assertThrows(ApiException.class, + () -> scrapService.deleteVideoScrap(memberId, orgId, videoId)); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(INVALID_SCRAP_REQUEST); + } + + @Test + @DisplayName("deleteVideoScrap - 삭제된 행이 없으면 VIDEO_NOT_SCRAPPED") + void deleteVideoScrap_notScrapped() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(true); + given(scrapRepository.deleteByMemberIdAndVideoId(memberId, videoId)) + .willReturn(0); + + // when + ApiException ex = assertThrows(ApiException.class, + () -> scrapService.deleteVideoScrap(memberId, orgId, videoId)); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(VIDEO_NOT_SCRAPPED); + } + + @Test + @DisplayName("deleteVideoScrap - 정상 삭제 시 true 반환") + void deleteVideoScrap_success() { + // given + Long memberId = 1L; + Long orgId = 10L; + Long videoId = 100L; + + given(scrapRepository.isValidMemberAndOrgAndVideo(memberId, orgId, videoId)) + .willReturn(true); + given(scrapRepository.deleteByMemberIdAndVideoId(memberId, videoId)) + .willReturn(1); + + // when + boolean result = scrapService.deleteVideoScrap(memberId, orgId, videoId); + + // then + assertThat(result).isTrue(); + verify(scrapRepository).deleteByMemberIdAndVideoId(memberId, videoId); + } +} \ No newline at end of file From b5e3aa10999766c72f74e1464da6ea5d1dbbad07 Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Mon, 8 Dec 2025 12:07:25 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20Organization=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organization/OrganizationServiceTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/test/java/app/allstackproject/privideo/domain/organization/OrganizationServiceTest.java diff --git a/src/test/java/app/allstackproject/privideo/domain/organization/OrganizationServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/organization/OrganizationServiceTest.java new file mode 100644 index 0000000..6dd9596 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/organization/OrganizationServiceTest.java @@ -0,0 +1,116 @@ +package app.allstackproject.privideo.domain.organization; + +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ALREADY_LEAVED_MEMBER; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ORG_CODE_NOT_AVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.repository.MemberGroupRepository; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrgRedisRepository; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.domain.organization.service.OrganizationService; +import app.allstackproject.privideo.domain.organization.service.PermissionService; +import app.allstackproject.privideo.domain.user.entity.User; +import app.allstackproject.privideo.domain.user.repository.UserRepository; +import app.allstackproject.privideo.domain.video.repository.CategoryRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.security.JwtProvider; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import app.allstackproject.privideo.global.util.S3Util; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OrganizationServiceTest { + + @InjectMocks + private OrganizationService organizationService; + + @Mock + private UserRepository userRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private MemberGroupRepository memberGroupRepository; + @Mock + private CategoryRepository categoryRepository; + @Mock + private OrgRedisRepository orgRedisRepository; + @Mock + private PermissionService permissionService; + @Mock + private JwtProvider jwtProvider; + @Mock + private CdnUrlProvider cdnUrlProvider; + @Mock + private S3Util s3Util; + + @Test + @DisplayName("조직 코드가 Redis에 없으면 ORG_CODE_NOT_AVAILABLE 예외") + void joinOrg_orgCodeNotFound() { + // given + Long userId = 1L; + String orgCode = "ABC123"; + String nickname = "닉네임"; + + User user = Mockito.mock(User.class); + + given(userRepository.findById(userId)) + .willReturn(Optional.of(user)); + given(orgRedisRepository.getOrgIdByCode(orgCode)) + .willReturn(null); // orgId 없음 + + // when + ApiException ex = assertThrows(ApiException.class, + () -> organizationService.joinOrg(userId, orgCode, nickname)); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(ORG_CODE_NOT_AVAILABLE); + } + + @Test + @DisplayName("INACTIVE 상태의 멤버가 존재하면 ALREADY_LEAVED_MEMBER 예외") + void joinOrg_alreadyLeavedMember() { + // given + Long userId = 1L; + String orgCode = "ABC123"; + Long orgId = 10L; + + User user = Mockito.mock(User.class); + Organization org = Mockito.mock(Organization.class); + Member inactiveMember = Mockito.mock(Member.class); + + given(userRepository.findById(userId)) + .willReturn(Optional.of(user)); + given(orgRedisRepository.getOrgIdByCode(orgCode)) + .willReturn(orgId); + given(organizationRepository.findById(orgId)) + .willReturn(Optional.of(org)); + // INACTIVE 멤버 존재 + given(memberRepository.findByUserIdAndOrganizationIdAndStatus( + ArgumentMatchers.eq(userId), + ArgumentMatchers.eq(orgId), + ArgumentMatchers.eq(app.allstackproject.privideo.shared.enums.BaseStatusType.INACTIVE) + )).willReturn(Optional.of(inactiveMember)); + + // when + ApiException ex = assertThrows(ApiException.class, + () -> organizationService.joinOrg(userId, orgCode, "닉네임")); + + // then + assertThat(ex.getResponseStatus()).isEqualTo(ALREADY_LEAVED_MEMBER); + } +} From 011a935a9baf7e45d4624c08513a5aac3324e83a Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Sat, 6 Dec 2025 11:19:50 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20Home=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../privideo/domain/home/HomeServiceTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/test/java/app/allstackproject/privideo/domain/home/HomeServiceTest.java diff --git a/src/test/java/app/allstackproject/privideo/domain/home/HomeServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/home/HomeServiceTest.java new file mode 100644 index 0000000..a74ead6 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/home/HomeServiceTest.java @@ -0,0 +1,85 @@ +package app.allstackproject.privideo.domain.home; + +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.MEMBER_NOT_IN_ORGANIZATION; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ORGANIZATION_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +import app.allstackproject.privideo.domain.home.service.HomeService; +import app.allstackproject.privideo.domain.member.repository.MemberGroupMappingRepository; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.notice.repository.NoticeMemberGroupMappingRepository; +import app.allstackproject.privideo.domain.notice.repository.NoticeRepository; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import java.util.Optional; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class HomeServiceTest { + + @InjectMocks + private HomeService homeService; + + @Mock + private MemberRepository memberRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private VideoRepository videoRepository; + @Mock + private NoticeRepository noticeRepository; + @Mock + private NoticeMemberGroupMappingRepository noticeMemberGroupMappingRepository; + @Mock + private MemberGroupMappingRepository memberGroupMappingRepository; + @Mock + private CdnUrlProvider cdnUrlProvider; + + @Test + @DisplayName("조직이 없으면 ORGANIZATION_NOT_FOUND 예외") + void readHome_orgNotFound() { + Long memberId = 1L; + Long orgId = 10L; + + given(organizationRepository.findById(orgId)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> homeService.readHome(memberId, orgId, "ALL")); + + assertThat(ex.getResponseStatus()).isEqualTo(ORGANIZATION_NOT_FOUND); + } + + @Test + @DisplayName("멤버가 조직에 속해있지 않으면 MEMBER_NOT_IN_ORGANIZATION 예외") + void readHome_memberNotInOrg() { + Long memberId = 1L; + Long orgId = 10L; + + Organization org = Mockito.mock(Organization.class); + + given(organizationRepository.findById(orgId)) + .willReturn(Optional.of(org)); + given(memberRepository.findByIdAndOrganizationIdAndStatus( + memberId, orgId, app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> homeService.readHome(memberId, orgId, "ALL")); + + assertThat(ex.getResponseStatus()).isEqualTo(MEMBER_NOT_IN_ORGANIZATION); + } + + // 정상 케이스는 DTO 매핑이 길어서, 필요하면 나중에 given/thenReturn으로 다 채우는 패턴으로 추가하면 됨 +} From d229d2580529deb94708a1728df72287ec19fa3b Mon Sep 17 00:00:00 2001 From: hyunn522 Date: Sun, 7 Dec 2025 19:20:50 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20Admin=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/admin/NoticeAdminServiceTest.java | 194 +++++++ .../domain/admin/OrgAdminServiceTest.java | 516 ++++++++++++++++++ .../domain/admin/StatsAdminServiceTest.java | 425 +++++++++++++++ .../domain/admin/SuperAdminServiceTest.java | 334 ++++++++++++ .../domain/admin/VideoAdminServiceTest.java | 184 +++++++ 5 files changed, 1653 insertions(+) create mode 100644 src/test/java/app/allstackproject/privideo/domain/admin/NoticeAdminServiceTest.java create mode 100644 src/test/java/app/allstackproject/privideo/domain/admin/OrgAdminServiceTest.java create mode 100644 src/test/java/app/allstackproject/privideo/domain/admin/StatsAdminServiceTest.java create mode 100644 src/test/java/app/allstackproject/privideo/domain/admin/SuperAdminServiceTest.java create mode 100644 src/test/java/app/allstackproject/privideo/domain/admin/VideoAdminServiceTest.java diff --git a/src/test/java/app/allstackproject/privideo/domain/admin/NoticeAdminServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/admin/NoticeAdminServiceTest.java new file mode 100644 index 0000000..858f696 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/admin/NoticeAdminServiceTest.java @@ -0,0 +1,194 @@ +package app.allstackproject.privideo.domain.admin; + +import static app.allstackproject.privideo.domain.organization.dto.enums.OpenScopeType.GROUP; +import static app.allstackproject.privideo.domain.organization.dto.enums.OpenScopeType.PUBLIC; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.INVALID_MEMBER_GROUP_IDS; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.MEMBER_NOT_IN_ORGANIZATION; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.NOTICE_NOT_IN_ORGANIZATION; +import static app.allstackproject.privideo.global.response.status.BaseExceptionResponseStatus.ORGANIZATION_NOT_FOUND; +import static app.allstackproject.privideo.shared.enums.BaseStatusType.ACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import app.allstackproject.privideo.domain.admin.dto.AdminReadNoticeResponse; +import app.allstackproject.privideo.domain.admin.dto.CreateNoticeRequest; +import app.allstackproject.privideo.domain.admin.dto.MemberGroupItem; +import app.allstackproject.privideo.domain.admin.service.NoticeAdminService; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.entity.MemberGroup; +import app.allstackproject.privideo.domain.member.repository.MemberGroupRepository; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.notice.entity.Notice; +import app.allstackproject.privideo.domain.notice.entity.NoticeMemberGroupMapping; +import app.allstackproject.privideo.domain.notice.repository.NoticeMemberGroupMappingRepository; +import app.allstackproject.privideo.domain.notice.repository.NoticeRepository; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import java.util.List; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +public class NoticeAdminServiceTest { + @InjectMocks + private NoticeAdminService noticeAdminService; + + @Mock + private OrganizationRepository organizationRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private MemberGroupRepository memberGroupRepository; + @Mock + private NoticeRepository noticeRepository; + @Mock + private NoticeMemberGroupMappingRepository noticeMemberGroupMappingRepository; + + @Test + @DisplayName("readNotice - 다른 조직 공지는 NOTICE_NOT_IN_ORGANIZATION") + void readNotice_notInOrg() { + Long orgId = 1L; + Long noticeId = 10L; + + given(noticeRepository.findByIdAndOrganizationId(noticeId, orgId)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> noticeAdminService.readNotice(orgId, noticeId)); + + assertThat(ex.getResponseStatus()).isEqualTo(NOTICE_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("readNotice - 조회 시 notice.watch() 호출 및 그룹 정보 포함") + void readNotice_success() { + Long orgId = 1L; + Long noticeId = 10L; + + Organization org = mock(Organization.class); + Notice notice = mock(Notice.class); + MemberGroup group1 = mock(MemberGroup.class); + MemberGroup group2 = mock(MemberGroup.class); + + given(noticeRepository.findByIdAndOrganizationId(noticeId, orgId)) + .willReturn(Optional.of(notice)); + given(memberGroupRepository.findAllByOrganizationId(orgId)) + .willReturn(List.of( + new MemberGroupItem(1L, "A"), + new MemberGroupItem(2L, "B") + )); + + NoticeMemberGroupMapping m1 = mock(NoticeMemberGroupMapping.class); + NoticeMemberGroupMapping m2 = mock(NoticeMemberGroupMapping.class); + given(m1.getMemberGroup()).willReturn(group1); + given(m2.getMemberGroup()).willReturn(group2); + given(group1.getId()).willReturn(1L); + given(group2.getId()).willReturn(2L); + + given(noticeMemberGroupMappingRepository.findAllByNoticeId(noticeId)) + .willReturn(List.of(m1, m2)); + + AdminReadNoticeResponse res = noticeAdminService.readNotice(orgId, noticeId); + + verify(notice).watch(); + assertThat(res.getMemberGroups()) + .extracting("id") + .containsExactlyInAnyOrder(1L, 2L); + } + + @Test + @DisplayName("createNotice - 조직이 없으면 ORGANIZATION_NOT_FOUND") + void createNotice_orgNotFound() { + Long orgId = 1L; + Long memberId = 10L; + CreateNoticeRequest req = new CreateNoticeRequest("t", "c", PUBLIC, List.of()); + + given(organizationRepository.findById(orgId)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> noticeAdminService.createNotice(orgId, memberId, req)); + + assertThat(ex.getResponseStatus()).isEqualTo(ORGANIZATION_NOT_FOUND); + } + + @Test + @DisplayName("createNotice - 조직 멤버가 아니면 MEMBER_NOT_IN_ORGANIZATION") + void createNotice_memberNotInOrg() { + Long orgId = 1L; + Long memberId = 10L; + CreateNoticeRequest req = new CreateNoticeRequest("t", "c", PUBLIC, List.of()); + + Organization org = mock(Organization.class); + + given(organizationRepository.findById(orgId)).willReturn(Optional.of(org)); + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> noticeAdminService.createNotice(orgId, memberId, req)); + + assertThat(ex.getResponseStatus()).isEqualTo(MEMBER_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("createNotice - GROUP 공개인데 memberGroups 비어 있으면 INVALID_MEMBER_GROUP_IDS") + void createNotice_groupScope_emptyGroups() { + Long orgId = 1L; + Long memberId = 10L; + CreateNoticeRequest req = new CreateNoticeRequest("t", "c", GROUP, List.of()); + + Organization org = mock(Organization.class); + Member member = mock(Member.class); + + given(organizationRepository.findById(orgId)).willReturn(Optional.of(org)); + given(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, ACTIVE)) + .willReturn(Optional.of(member)); + + ApiException ex = assertThrows(ApiException.class, + () -> noticeAdminService.createNotice(orgId, memberId, req)); + + assertThat(ex.getResponseStatus()).isEqualTo(INVALID_MEMBER_GROUP_IDS); + } + + @Test + @DisplayName("deleteNotice - 다른 조직 공지 삭제 시 NOTICE_NOT_IN_ORGANIZATION") + void deleteNotice_notInOrg() { + Long orgId = 1L; + Long noticeId = 10L; + + given(noticeRepository.findByIdAndOrganizationId(noticeId, orgId)) + .willReturn(Optional.empty()); + + ApiException ex = assertThrows(ApiException.class, + () -> noticeAdminService.deleteNotice(orgId, noticeId)); + + assertThat(ex.getResponseStatus()).isEqualTo(NOTICE_NOT_IN_ORGANIZATION); + } + + @Test + @DisplayName("deleteNotice - 정상 삭제 시 매핑 + 공지 삭제") + void deleteNotice_success() { + Long orgId = 1L; + Long noticeId = 10L; + Notice notice = mock(Notice.class); + + given(noticeRepository.findByIdAndOrganizationId(noticeId, orgId)) + .willReturn(Optional.of(notice)); + + boolean result = noticeAdminService.deleteNotice(orgId, noticeId); + + assertThat(result).isTrue(); + verify(noticeMemberGroupMappingRepository).deleteAllByNoticeId(noticeId); + verify(noticeRepository).delete(notice); + } +} \ No newline at end of file diff --git a/src/test/java/app/allstackproject/privideo/domain/admin/OrgAdminServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/admin/OrgAdminServiceTest.java new file mode 100644 index 0000000..c431067 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/admin/OrgAdminServiceTest.java @@ -0,0 +1,516 @@ +package app.allstackproject.privideo.domain.admin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import app.allstackproject.privideo.domain.admin.dto.MemberGroupItem; +import app.allstackproject.privideo.domain.admin.dto.ReadAdminOrganizationInfoResponse; +import app.allstackproject.privideo.domain.admin.dto.ReadAllCategoryItem; +import app.allstackproject.privideo.domain.admin.dto.ReadAllMemberGroupItem; +import app.allstackproject.privideo.domain.admin.service.OrgAdminService; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.entity.MemberGroup; +import app.allstackproject.privideo.domain.member.repository.MemberGroupMappingRepository; +import app.allstackproject.privideo.domain.member.repository.MemberGroupRepository; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.notice.repository.NoticeMemberGroupMappingRepository; +import app.allstackproject.privideo.domain.organization.dto.enums.JoinStatusType; +import app.allstackproject.privideo.domain.organization.dto.response.OrgCodeResponse; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrgRedisRepository; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.domain.video.entity.Category; +import app.allstackproject.privideo.domain.video.repository.CategoryRepository; +import app.allstackproject.privideo.domain.video.repository.VideoCategoryMappingRepository; +import app.allstackproject.privideo.domain.video.repository.VideoMemberGroupMappingRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import app.allstackproject.privideo.global.util.S3Util; +import app.allstackproject.privideo.shared.enums.BaseStatusType; +import java.util.List; +import java.util.Optional; +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.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class OrgAdminServiceTest { + + @Mock + MemberRepository memberRepository; + @Mock + OrganizationRepository organizationRepository; + @Mock + OrgRedisRepository orgRedisRepository; + @Mock + MemberGroupRepository memberGroupRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + MemberGroupMappingRepository memberGroupMappingRepository; + @Mock + VideoMemberGroupMappingRepository videoMemberGroupMappingRepository; + @Mock + VideoCategoryMappingRepository videoCategoryMappingRepository; + @Mock + S3Util s3Util; + @Mock + CdnUrlProvider cdnUrlProvider; + @Mock + NoticeMemberGroupMappingRepository noticeMemberGroupMappingRepository; + + @InjectMocks + OrgAdminService orgAdminService; + + // ========= modifyOrgInfo ========= + + @Test + @DisplayName("modifyOrgInfo - 조직 이미지 변경 성공") + void modifyOrgInfo_success() { + Long orgId = 1L; + MultipartFile img = mock(MultipartFile.class); + Organization org = mock(Organization.class); + + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(org.getImgKey()).thenReturn("old-key"); + when(img.getOriginalFilename()).thenReturn("logo.png"); + when(s3Util.generateImgKey(eq(orgId), anyString(), anyString(), any())) + .thenReturn("new-key"); + + boolean result = orgAdminService.modifyOrgInfo(orgId, img); + + assertTrue(result); + verify(s3Util).uploadImgWithKey(img, "new-key"); + verify(org).setImgKey("new-key"); + verify(s3Util).deleteFileByKey("old-key", true); + } + + @Test + @DisplayName("modifyOrgInfo - 조직이 없으면 예외") + void modifyOrgInfo_orgNotFound() { + Long orgId = 1L; + MultipartFile img = mock(MultipartFile.class); + when(organizationRepository.findById(orgId)).thenReturn(Optional.empty()); + + assertThrows(ApiException.class, + () -> orgAdminService.modifyOrgInfo(orgId, img)); + } + + // ========= regenerateOrgCode ========= + + @Test + @DisplayName("regenerateOrgCode - 승인된 멤버이면 코드 재발급 성공") + void regenerateOrgCode_success() { + Long orgId = 1L; + Long memberId = 2L; + + Organization org = mock(Organization.class); + Member member = mock(Member.class); + + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(Optional.of(member)); + when(member.getJoinStatus()).thenReturn(JoinStatusType.APPROVED); + + OrgCodeResponse response = orgAdminService.regenerateOrgCode(memberId, orgId); + + assertNotNull(response); + assertNotNull(response.getNewCode()); + verify(orgRedisRepository) + .regenerateCode(eq(orgId), eq(response.getNewCode())); + } + + @Test + @DisplayName("regenerateOrgCode - 멤버가 없으면 예외") + void regenerateOrgCode_memberNotFound() { + Long orgId = 1L; + Long memberId = 2L; + + Organization org = mock(Organization.class); + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThrows(ApiException.class, + () -> orgAdminService.regenerateOrgCode(memberId, orgId)); + } + + @Test + @DisplayName("regenerateOrgCode - joinStatus가 APPROVED가 아니면 예외") + void regenerateOrgCode_notApproved() { + Long orgId = 1L; + Long memberId = 2L; + + Organization org = mock(Organization.class); + Member member = mock(Member.class); + + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(Optional.of(member)); + when(member.getJoinStatus()).thenReturn(JoinStatusType.PENDING); + + assertThrows(ApiException.class, + () -> orgAdminService.regenerateOrgCode(memberId, orgId)); + + verify(orgRedisRepository, never()).regenerateCode(anyLong(), anyString()); + } + + // ========= readOrganizationInfo ========= + + @Test + @DisplayName("readOrganizationInfo - 멤버 그룹이 없으면 빈 리스트 반환") + void readOrganizationInfo_noGroups() { + Long orgId = 1L; + Organization org = mock(Organization.class); + + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(org.getName()).thenReturn("테스트 조직"); + when(org.getImgKey()).thenReturn("org-key"); + when(cdnUrlProvider.generateImgUrl("org-key")).thenReturn("https://cdn/org.png"); + when(memberRepository.countByOrganizationIdAndJoinStatusAndStatus(eq(orgId), any(), any())) + .thenReturn(5L); + when(orgRedisRepository.getOrgCodeById(orgId)).thenReturn("ORGCODE"); + when(memberGroupRepository.findAllByOrganizationId(orgId)).thenReturn(List.of()); + + ReadAdminOrganizationInfoResponse response = orgAdminService.readOrganizationInfo(orgId); + + assertEquals("테스트 조직", response.getOrgName()); + assertEquals("https://cdn/org.png", response.getImgUrl()); + assertEquals(5L, response.getMemberCnt()); + assertEquals("ORGCODE", response.getOrgCode()); + assertTrue(response.getMemberGroups().isEmpty()); + } + + @Test + @DisplayName("readOrganizationInfo - 멤버 그룹과 카테고리 묶어서 반환") + void readOrganizationInfo_withGroupsAndCategories() { + Long orgId = 1L; + Organization org = mock(Organization.class); + + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(org.getName()).thenReturn("테스트 조직"); + when(org.getImgKey()).thenReturn("org-key"); + when(cdnUrlProvider.generateImgUrl("org-key")).thenReturn("https://cdn/org.png"); + when(memberRepository.countByOrganizationIdAndJoinStatusAndStatus(eq(orgId), any(), any())) + .thenReturn(5L); + when(orgRedisRepository.getOrgCodeById(orgId)).thenReturn("ORGCODE"); + + MemberGroupItem g1 = new MemberGroupItem(1L, "개발팀"); + MemberGroupItem g2 = new MemberGroupItem(2L, "기획팀"); + when(memberGroupRepository.findAllByOrganizationId(orgId)) + .thenReturn(List.of(g1, g2)); + + Category c1 = mock(Category.class); + when(c1.getId()).thenReturn(100L); + when(c1.getTitle()).thenReturn("백엔드"); + when(c1.getMemberGroupId()).thenReturn(1L); + + Category c2 = mock(Category.class); + when(c2.getId()).thenReturn(200L); + when(c2.getTitle()).thenReturn("프론트엔드"); + when(c2.getMemberGroupId()).thenReturn(2L); + + when(categoryRepository.findByMemberGroupIdIn(List.of(1L, 2L))) + .thenReturn(List.of(c1, c2)); + + ReadAdminOrganizationInfoResponse response = orgAdminService.readOrganizationInfo(orgId); + + assertEquals(2, response.getMemberGroups().size()); + + ReadAllMemberGroupItem r1 = response.getMemberGroups().get(0); + assertEquals("개발팀", r1.getName()); + assertEquals(1, r1.getCategories().size()); + ReadAllCategoryItem rc1 = r1.getCategories().get(0); + assertEquals("백엔드", rc1.getTitle()); + } + + // ========= createMemberGroup / deleteMemberGroup ========= + + @Test + @DisplayName("createMemberGroup - 이름 중복이면 예외") + void createMemberGroup_duplicateName() { + Long orgId = 1L; + String name = "개발팀"; + + when(memberGroupRepository.existsByName(name)).thenReturn(true); + + assertThrows(ApiException.class, + () -> orgAdminService.createMemberGroup(orgId, name)); + } + + @Test + @DisplayName("createMemberGroup - 정상 생성") + void createMemberGroup_success() { + Long orgId = 1L; + String name = "개발팀"; + + Organization org = mock(Organization.class); + when(memberGroupRepository.existsByName(name)).thenReturn(false); + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + + boolean result = orgAdminService.createMemberGroup(orgId, name); + + assertTrue(result); + verify(memberGroupRepository).save(any(MemberGroup.class)); + } + + @Test + @DisplayName("deleteMemberGroup - 그룹이 없으면 예외") + void deleteMemberGroup_notFound() { + Long orgId = 1L; + Long groupId = 10L; + + when(memberGroupRepository.findByIdAndOrganizationId(groupId, orgId)) + .thenReturn(Optional.empty()); + + assertThrows(ApiException.class, + () -> orgAdminService.deleteMemberGroup(orgId, groupId)); + } + + @Test + @DisplayName("deleteMemberGroup - 매핑/공지 매핑 삭제 후 그룹 삭제") + void deleteMemberGroup_success() { + Long orgId = 1L; + Long groupId = 10L; + + MemberGroup group = mock(MemberGroup.class); + when(memberGroupRepository.findByIdAndOrganizationId(groupId, orgId)) + .thenReturn(Optional.of(group)); + + boolean result = orgAdminService.deleteMemberGroup(orgId, groupId); + + assertTrue(result); + verify(memberGroupMappingRepository).deleteByMemberGroupId(groupId); + verify(videoMemberGroupMappingRepository).deleteAllByMemberGroupId(groupId); + verify(noticeMemberGroupMappingRepository).deleteAllByMemberGroupId(groupId); + verify(memberGroupRepository).delete(group); + } + + // ========= readAllCategory ========= + + @Test + @DisplayName("readAllCategory - 그룹이 조직에 없으면 예외") + void readAllCategory_groupNotFound() { + Long orgId = 1L; + Long groupId = 10L; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> orgAdminService.readAllCategory(orgId, groupId)); + } + + @Test + @DisplayName("readAllCategory - 카테고리 목록 조회 성공") + void readAllCategory_success() { + Long orgId = 1L; + Long groupId = 10L; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + + Category c1 = mock(Category.class); + when(c1.getId()).thenReturn(100L); + when(c1.getTitle()).thenReturn("백엔드"); + + when(categoryRepository.findByMemberGroupId(groupId)) + .thenReturn(List.of(c1)); + + List result = orgAdminService.readAllCategory(orgId, groupId); + + assertEquals(1, result.size()); + assertEquals("백엔드", result.get(0).getTitle()); + } + + // ========= createCategory ========= + + @Test + @DisplayName("createCategory - 그룹이 없으면 예외") + void createCategory_groupNotFound() { + Long orgId = 1L; + Long groupId = 10L; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> orgAdminService.createCategory(orgId, groupId, "백엔드")); + } + + @Test + @DisplayName("createCategory - 중복 카테고리면 예외") + void createCategory_alreadyExist() { + Long orgId = 1L; + Long groupId = 10L; + String title = "백엔드"; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.existsByMemberGroupIdAndTitle(groupId, title)) + .thenReturn(true); + + assertThrows(ApiException.class, + () -> orgAdminService.createCategory(orgId, groupId, title)); + } + + @Test + @DisplayName("createCategory - 정상 생성") + void createCategory_success() { + Long orgId = 1L; + Long groupId = 10L; + String title = "백엔드"; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.existsByMemberGroupIdAndTitle(groupId, title)) + .thenReturn(false); + + boolean result = orgAdminService.createCategory(orgId, groupId, title); + + assertTrue(result); + verify(categoryRepository).save(any(Category.class)); + } + + // ========= modifyCategory ========= + + @Test + @DisplayName("modifyCategory - 그룹이 없으면 예외") + void modifyCategory_groupNotFound() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> orgAdminService.modifyCategory(orgId, groupId, categoryId, "새 제목")); + } + + @Test + @DisplayName("modifyCategory - 새로운 제목이 이미 존재하면 예외") + void modifyCategory_duplicateTitle() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + String newTitle = "백엔드"; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.existsByMemberGroupIdAndTitle(groupId, newTitle)) + .thenReturn(true); + + assertThrows(ApiException.class, + () -> orgAdminService.modifyCategory(orgId, groupId, categoryId, newTitle)); + } + + @Test + @DisplayName("modifyCategory - 카테고리가 없으면 예외") + void modifyCategory_categoryNotFound() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + String newTitle = "백엔드"; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.existsByMemberGroupIdAndTitle(groupId, newTitle)) + .thenReturn(false); + when(categoryRepository.findById(categoryId)) + .thenReturn(Optional.empty()); + + assertThrows(ApiException.class, + () -> orgAdminService.modifyCategory(orgId, groupId, categoryId, newTitle)); + } + + @Test + @DisplayName("modifyCategory - 정상 수정") + void modifyCategory_success() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + String newTitle = "백엔드"; + + Category category = mock(Category.class); + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.existsByMemberGroupIdAndTitle(groupId, newTitle)) + .thenReturn(false); + when(categoryRepository.findById(categoryId)) + .thenReturn(Optional.of(category)); + + boolean result = orgAdminService.modifyCategory(orgId, groupId, categoryId, newTitle); + + assertTrue(result); + verify(category).modifyTitle(newTitle); + } + + // ========= deleteCategory ========= + + @Test + @DisplayName("deleteCategory - 그룹이 없으면 예외") + void deleteCategory_groupNotFound() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> orgAdminService.deleteCategory(orgId, groupId, categoryId)); + } + + @Test + @DisplayName("deleteCategory - 카테고리가 없으면 예외") + void deleteCategory_categoryNotFound() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.findById(categoryId)) + .thenReturn(Optional.empty()); + + assertThrows(ApiException.class, + () -> orgAdminService.deleteCategory(orgId, groupId, categoryId)); + } + + @Test + @DisplayName("deleteCategory - 매핑 삭제 후 카테고리 삭제") + void deleteCategory_success() { + Long orgId = 1L; + Long groupId = 10L; + Long categoryId = 100L; + + Category category = mock(Category.class); + + when(memberGroupRepository.existsByIdAndOrganizationId(groupId, orgId)) + .thenReturn(true); + when(categoryRepository.findById(categoryId)) + .thenReturn(Optional.of(category)); + + boolean result = orgAdminService.deleteCategory(orgId, groupId, categoryId); + + assertTrue(result); + verify(videoCategoryMappingRepository).deleteAllByCategoryId(categoryId); + verify(categoryRepository).delete(category); + } +} \ No newline at end of file diff --git a/src/test/java/app/allstackproject/privideo/domain/admin/StatsAdminServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/admin/StatsAdminServiceTest.java new file mode 100644 index 0000000..e84e665 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/admin/StatsAdminServiceTest.java @@ -0,0 +1,425 @@ +package app.allstackproject.privideo.domain.admin; + +import static app.allstackproject.privideo.domain.video.service.LogService.SEGMENT_SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import app.allstackproject.privideo.domain.admin.dto.AgeCountDto; +import app.allstackproject.privideo.domain.admin.dto.AllMemberWatchLogItem; +import app.allstackproject.privideo.domain.admin.dto.AllVideoWatchLogItem; +import app.allstackproject.privideo.domain.admin.dto.GenderCountDto; +import app.allstackproject.privideo.domain.admin.dto.GroupWatchCompleteRate; +import app.allstackproject.privideo.domain.admin.dto.MemberAvgWatchRateDto; +import app.allstackproject.privideo.domain.admin.dto.MemberGroupItem; +import app.allstackproject.privideo.domain.admin.dto.MemberWatchLogItem; +import app.allstackproject.privideo.domain.admin.dto.MemberWatchReport; +import app.allstackproject.privideo.domain.admin.dto.MonthlyWatchItem; +import app.allstackproject.privideo.domain.admin.dto.QuitLogItem; +import app.allstackproject.privideo.domain.admin.dto.ReadAllMemberItem; +import app.allstackproject.privideo.domain.admin.dto.ReadAllVideoIntervalLogItem; +import app.allstackproject.privideo.domain.admin.dto.ReadQuitLogResponse; +import app.allstackproject.privideo.domain.admin.dto.VideoIntervalLogItem; +import app.allstackproject.privideo.domain.admin.dto.VideoRankItem; +import app.allstackproject.privideo.domain.admin.dto.VideoWatchLogItem; +import app.allstackproject.privideo.domain.admin.service.StatsAdminService; +import app.allstackproject.privideo.domain.history.repository.HistoryRepository; +import app.allstackproject.privideo.domain.member.enums.AgeType; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.organization.entity.OrgViewLog; +import app.allstackproject.privideo.domain.user.dto.enums.GenderType; +import app.allstackproject.privideo.domain.video.entity.Video; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.domain.video.service.LogService; +import app.allstackproject.privideo.global.exception.ApiException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +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.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +@ExtendWith(MockitoExtension.class) +class StatsAdminServiceTest { + + @Mock + MemberRepository memberRepository; + @Mock + HistoryRepository historyRepository; + @Mock + VideoRepository videoRepository; + @Mock + MongoTemplate mongoTemplate; + @Mock + LogService logService; + + @InjectMocks + StatsAdminService statsAdminService; + + @Test + @DisplayName("readAllMemberWatchLog - 그룹 이름과 평균 시청률 매핑") + void readAllMemberWatchLog_success() { + Long orgId = 1L; + + // ReadAllMemberItem 은 서비스 내부에서 getId, getNickname, getMemberGroups 사용 → mock으로 처리 + ReadAllMemberItem m1 = mock(ReadAllMemberItem.class); + ReadAllMemberItem m2 = mock(ReadAllMemberItem.class); + + when(m1.getId()).thenReturn(1L); + when(m1.getNickname()).thenReturn("user1"); + when(m1.getMemberGroups()).thenReturn( + List.of(new MemberGroupItem(1L, "개발팀")) + ); + + when(m2.getId()).thenReturn(2L); + when(m2.getNickname()).thenReturn("user2"); + when(m2.getMemberGroups()).thenReturn(null); // 그룹 없음 + + when(memberRepository.findByOrganizationId(orgId)) + .thenReturn(List.of(m1, m2)); + + // 평균 시청률 DTO 도 mock 으로 + MemberAvgWatchRateDto avg1 = mock(MemberAvgWatchRateDto.class); + when(avg1.getMemberId()).thenReturn(1L); + when(avg1.getAvgWatchRate()).thenReturn(80L); + + when(historyRepository.findMemberAvgWatchRateByOrgId(orgId)) + .thenReturn(List.of(avg1)); + + // when + List result = statsAdminService.readAllMemberWatchLog(orgId); + + // then + assertEquals(2, result.size()); + + AllMemberWatchLogItem r1 = result.get(0); + assertEquals(1L, r1.getId()); + assertEquals("user1", r1.getNickname()); + assertEquals(List.of("개발팀"), r1.getGroups()); + assertEquals(80L, r1.getAvgWatchRate()); + + AllMemberWatchLogItem r2 = result.get(1); + assertEquals(2L, r2.getId()); + assertEquals("user2", r2.getNickname()); + assertEquals(List.of(), r2.getGroups()); + assertEquals(0L, r2.getAvgWatchRate()); + } + + @Test + @DisplayName("readMemberWatchLog - 조직에 속하지 않으면 예외") + void readMemberWatchLog_notInOrg() { + Long orgId = 1L; + Long memberId = 10L; + + when(memberRepository.existsByIdAndOrganizationIdAndStatus(eq(memberId), eq(orgId), any())) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> statsAdminService.readMemberWatchLog(orgId, memberId)); + } + + @Test + @DisplayName("readMemberWatchLog - 정상 조회") + void readMemberWatchLog_success() { + Long orgId = 1L; + Long memberId = 10L; + + when(memberRepository.existsByIdAndOrganizationIdAndStatus(eq(memberId), eq(orgId), any())) + .thenReturn(true); + + MemberWatchLogItem item = mock(MemberWatchLogItem.class); + when(historyRepository.findWatchLogByMemberId(memberId)) + .thenReturn(List.of(item)); + + List result = statsAdminService.readMemberWatchLog(orgId, memberId); + + assertEquals(1, result.size()); + verify(historyRepository).findWatchLogByMemberId(memberId); + } + + @Test + @DisplayName("readMemberWatchReport - 조직에 속하지 않으면 예외") + void readMemberWatchReport_notInOrg() { + Long orgId = 1L; + Long memberId = 10L; + + when(memberRepository.existsByIdAndOrganizationIdAndStatus(eq(memberId), eq(orgId), any())) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> statsAdminService.readMemberWatchReport(orgId, memberId)); + } + + @Test + @DisplayName("readMemberWatchReport - 최근 3개월 통계 조회") + void readMemberWatchReport_success() { + Long orgId = 1L; + Long memberId = 10L; + + when(memberRepository.existsByIdAndOrganizationIdAndStatus(eq(memberId), eq(orgId), any())) + .thenReturn(true); + + when(historyRepository.countByMemberIdAndIsCompleteIsTrueAndCompletedAtBetween( + eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(3L); + + when(historyRepository.findTopCategoriesByMemberIdWithinPeriod( + eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of("백엔드", "프론트엔드")); + + MonthlyWatchItem m1 = mock(MonthlyWatchItem.class); + MonthlyWatchItem m2 = mock(MonthlyWatchItem.class); + + when(historyRepository.findMonthlyStatsByMemberIdWithinPeriod( + eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of(m1, m2)); + + MemberWatchReport report = statsAdminService.readMemberWatchReport(orgId, memberId); + + assertEquals(3L, report.getTotalWatchedVideoCnt()); + assertEquals(List.of("백엔드", "프론트엔드"), report.getMostWatchedCategories()); + assertEquals(2, report.getMonthlyWatchedCnts().size()); + } + + @Test + @DisplayName("readAllVideoWatchLog - 히스토리에서 그대로 반환") + void readAllVideoWatchLog_success() { + Long orgId = 1L; + AllVideoWatchLogItem item = mock(AllVideoWatchLogItem.class); + when(historyRepository.findAllVideoWatchLogByOrgId(orgId)) + .thenReturn(List.of(item)); + + List result = statsAdminService.readAllVideoWatchLog(orgId); + + assertEquals(1, result.size()); + assertSame(item, result.get(0)); + } + + @Test + @DisplayName("readVideoWatchLog - 영상이 조직에 없으면 예외") + void readVideoWatchLog_notInOrg() { + Long orgId = 1L; + Long videoId = 20L; + + when(videoRepository.existsByIdAndOrganizationId(videoId, orgId)) + .thenReturn(false); + + assertThrows(ApiException.class, + () -> statsAdminService.readVideoWatchLog(orgId, videoId)); + } + + @Test + @DisplayName("readVideoWatchLog - 정상 조회") + void readVideoWatchLog_success() { + Long orgId = 1L; + Long videoId = 20L; + + when(videoRepository.existsByIdAndOrganizationId(videoId, orgId)) + .thenReturn(true); + + VideoWatchLogItem item = mock(VideoWatchLogItem.class); + when(historyRepository.findVideoWatchLogByVideoId(videoId)) + .thenReturn(List.of(item)); + + List result = statsAdminService.readVideoWatchLog(orgId, videoId); + + assertEquals(1, result.size()); + assertSame(item, result.get(0)); + } + + @Test + @DisplayName("readDayWatchCompleteCnt - 요일별 합계 계산") + void readDayWatchCompleteCnt_success() { + Long orgId = 1L; + String standardMonth = "2025-01"; + + OrgViewLog log = mock(OrgViewLog.class); + // MONDAY + when(log.getDate()).thenReturn(LocalDateTime.of(2025, 1, 6, 10, 0)); + when(log.getBuckets()).thenReturn(Map.of( + "00-03", 1, + "03-06", 2 + )); + + when(mongoTemplate.find(any(Query.class), eq(OrgViewLog.class))) + .thenReturn(List.of(log)); + + List result = statsAdminService.readDayWatchCompleteCnt(orgId, standardMonth); + + assertEquals(7, result.size()); + // 월요일 위치(첫번째) 값 = 3 + assertEquals(3L, result.get(0)); + // 나머지 요일은 0 + result.subList(1, 7).forEach(v -> assertEquals(0L, v)); + } + + @Test + @DisplayName("readHourWatchCompleteCnt - 시간대별 합계 계산") + void readHourWatchCompleteCnt_success() { + Long orgId = 1L; + String standardMonth = "2025-01"; + + OrgViewLog log = mock(OrgViewLog.class); + when(log.getBuckets()).thenReturn(Map.of( + "00-03", 1, + "09-12", 5 + )); + + when(mongoTemplate.find(any(Query.class), eq(OrgViewLog.class))) + .thenReturn(List.of(log)); + + List result = statsAdminService.readHourWatchCompleteCnt(orgId, standardMonth); + + assertEquals(8, result.size()); + assertEquals(1L, result.get(0)); // 00-03 + assertEquals(5L, result.get(3)); // 09-12 + } + + @Test + @DisplayName("readGroupWatchCompleteLog - Repository 결과 그대로 반환") + void readGroupWatchCompleteLog_success() { + Long orgId = 1L; + String standardMonth = "2025-01"; + + GroupWatchCompleteRate item = mock(GroupWatchCompleteRate.class); + when(historyRepository.findGroupAvgWatchRateByOrgIdWithinPeriod( + eq(orgId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of(item)); + + List result = + statsAdminService.readGroupWatchCompleteLog(orgId, standardMonth); + + assertEquals(1, result.size()); + assertSame(item, result.get(0)); + } + + @Test + @DisplayName("readOrgGenderReport - 성별별 인원수 리포트") + void readOrgGenderReport_success() { + Long orgId = 1L; + GenderCountDto dto = mock(GenderCountDto.class); + when(dto.getGender()).thenReturn(GenderType.MALE); + when(dto.getCount()).thenReturn(3L); + + when(memberRepository.countMemberByGender(orgId)) + .thenReturn(List.of(dto)); + + Map result = statsAdminService.readOrgGenderReport(orgId); + + assertEquals(1, result.size()); + assertEquals(3L, result.get(GenderType.MALE)); + } + + @Test + @DisplayName("readOrgAgeReport - 연령대별 인원수 리포트") + void readOrgAgeReport_success() { + Long orgId = 1L; + AgeCountDto dto = mock(AgeCountDto.class); + when(dto.getAge()).thenReturn(AgeType.TWENTY); + when(dto.getCount()).thenReturn(5L); + + when(memberRepository.countMemberByAge(orgId)) + .thenReturn(List.of(dto)); + + Map result = statsAdminService.readOrgAgeReport(orgId); + + assertEquals(1, result.size()); + assertEquals(5L, result.get(AgeType.TWENTY)); + } + + @Test + @DisplayName("readAllVideoIntervalLog - Repository 결과 그대로 반환") + void readAllVideoIntervalLog_success() { + Long orgId = 1L; + ReadAllVideoIntervalLogItem item = mock(ReadAllVideoIntervalLogItem.class); + when(videoRepository.findAllVideoIntervalLogByOrgId(orgId)) + .thenReturn(List.of(item)); + + List result = + statsAdminService.readAllVideoIntervalLog(orgId); + + assertEquals(1, result.size()); + assertSame(item, result.get(0)); + } + + @Test + @DisplayName("readVideoIntervalLog - 영상이 없으면 예외") + void readVideoIntervalLog_videoNotFound() { + Long videoId = 10L; + when(videoRepository.findById(videoId)) + .thenReturn(java.util.Optional.empty()); + + assertThrows(ApiException.class, + () -> statsAdminService.readVideoIntervalLog(videoId)); + } + + @Test + @DisplayName("readVideoIntervalLog - 세그먼트별 조회/이탈/비율 계산") + void readVideoIntervalLog_success() { + Long videoId = 10L; + Video video = mock(Video.class); + + when(videoRepository.findById(videoId)) + .thenReturn(java.util.Optional.of(video)); + // 총 3 segment 로 가정 + when(video.getWholeTime()).thenReturn(SEGMENT_SECONDS * 3L); + + when(logService.getSegViewCounts(eq(videoId), anyInt())) + .thenReturn(List.of(10L, 20L, 30L)); + when(logService.getSegQuitCounts(eq(videoId), anyInt())) + .thenReturn(List.of(1L, 2L, 3L)); + + List intervals = statsAdminService.readVideoIntervalLog(videoId); + + assertEquals(3, intervals.size()); + VideoIntervalLogItem first = intervals.get(0); + assertEquals(0L, first.getSegIdx()); + assertEquals(10L, first.getWatchCnt()); + assertEquals(1L, first.getQuitCnt()); + } + + @Test + @DisplayName("readQuitLog - 상위/하위 이탈률 영상 리포트") + void readQuitLog_success() { + Long orgId = 1L; + + QuitLogItem high = mock(QuitLogItem.class); + QuitLogItem low = mock(QuitLogItem.class); + + when(videoRepository.findTopQuitRateVideosByOrgId(orgId, 3)) + .thenReturn(List.of(high)); + when(videoRepository.findLowQuitRateVideosByOrgId(orgId, 3)) + .thenReturn(List.of(low)); + + ReadQuitLogResponse response = statsAdminService.readQuitLog(orgId); + + assertEquals(1, response.getHighQuitRateLogs().size()); + assertEquals(1, response.getLowQuitRateLogs().size()); + } + + @Test + @DisplayName("readVideoRank - 상위 5개 영상 랭킹 조회") + void readVideoRank_success() { + Long orgId = 1L; + VideoRankItem item = mock(VideoRankItem.class); + + when(videoRepository.findTop5VideoRankByOrgId(orgId)) + .thenReturn(List.of(item)); + + List result = statsAdminService.readVideoRank(orgId); + + assertEquals(1, result.size()); + assertSame(item, result.get(0)); + } +} \ No newline at end of file diff --git a/src/test/java/app/allstackproject/privideo/domain/admin/SuperAdminServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/admin/SuperAdminServiceTest.java new file mode 100644 index 0000000..7d7d531 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/admin/SuperAdminServiceTest.java @@ -0,0 +1,334 @@ +package app.allstackproject.privideo.domain.admin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import app.allstackproject.privideo.domain.admin.dto.MemberGroupItem; +import app.allstackproject.privideo.domain.admin.dto.ReadAllJoinRequestItem; +import app.allstackproject.privideo.domain.admin.dto.ReadAllJoinRequestResponse; +import app.allstackproject.privideo.domain.admin.dto.ReadAllMemberItem; +import app.allstackproject.privideo.domain.admin.service.SuperAdminService; +import app.allstackproject.privideo.domain.member.entity.Member; +import app.allstackproject.privideo.domain.member.entity.MemberGroup; +import app.allstackproject.privideo.domain.member.repository.MemberGroupMappingRepository; +import app.allstackproject.privideo.domain.member.repository.MemberGroupRepository; +import app.allstackproject.privideo.domain.member.repository.MemberRepository; +import app.allstackproject.privideo.domain.organization.dto.enums.JoinStatusType; +import app.allstackproject.privideo.domain.organization.dto.enums.PermissionType; +import app.allstackproject.privideo.domain.organization.dto.request.ChangeJoinStateRequest; +import app.allstackproject.privideo.domain.organization.dto.request.UpdateMemberPermissionRequest; +import app.allstackproject.privideo.domain.organization.entity.Organization; +import app.allstackproject.privideo.domain.organization.repository.OrgRedisRepository; +import app.allstackproject.privideo.domain.organization.repository.OrganizationRepository; +import app.allstackproject.privideo.domain.video.repository.CategoryRepository; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.shared.enums.BaseStatusType; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@ExtendWith(MockitoExtension.class) +class SuperAdminServiceTest { + + @Mock + MemberRepository memberRepository; + @Mock + MemberGroupRepository memberGroupRepository; + @Mock + MemberGroupMappingRepository memberGroupMappingRepository; + @Mock + OrganizationRepository organizationRepository; + @Mock + OrgRedisRepository orgRedisRepository; + @Mock + VideoRepository videoRepository; + @Mock + CategoryRepository categoryRepository; + + @InjectMocks + SuperAdminService superAdminService; + + @AfterEach + void clearSync() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + @DisplayName("readAllMember - 조직 구성원 목록 조회") + void readAllMember_success() { + Long orgId = 1L; + ReadAllMemberItem item = mock(ReadAllMemberItem.class); + when(memberRepository.findByOrganizationId(orgId)) + .thenReturn(List.of(item)); + + List result = superAdminService.readAllMember(orgId); + + assertEquals(1, result.size()); + assertSame(item, result.get(0)); + } + + @Test + @DisplayName("updateMemberPermission - 조직에 속하지 않으면 예외") + void updateMemberPermission_memberNotInOrg() { + Long orgId = 1L; + Long memberId = 10L; + UpdateMemberPermissionRequest req = mock(UpdateMemberPermissionRequest.class); + + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.empty()); + + assertThrows(ApiException.class, + () -> superAdminService.updateMemberPermission(memberId, orgId, req)); + } + + @Test + @DisplayName("updateMemberPermission - creator(관리자)이면 권한 변경 불가") + void updateMemberPermission_creatorCannotChange() { + Long orgId = 1L; + Long memberId = 10L; + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + when(member.isAdmin()).thenReturn(true); + + UpdateMemberPermissionRequest req = mock(UpdateMemberPermissionRequest.class); + + assertThrows(ApiException.class, + () -> superAdminService.updateMemberPermission(memberId, orgId, req)); + + verify(orgRedisRepository, never()).saveMemberPermission(anyLong(), anyLong(), anyLong()); + } + + @Test + @DisplayName("updateMemberPermission - 권한 변경 후 커밋 시 Redis 반영") + void updateMemberPermission_success_syncRedisAfterCommit() { + Long orgId = 1L; + Long memberId = 10L; + + TransactionSynchronizationManager.initSynchronization(); + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + when(member.isAdmin()).thenReturn(false); + when(member.getPermissionCode()).thenReturn(7L); + + UpdateMemberPermissionRequest req = mock(UpdateMemberPermissionRequest.class); + when(req.getVideoManage()).thenReturn(true); + when(req.getNoticeManage()).thenReturn(true); + when(req.getStatsReportManage()).thenReturn(false); + when(req.getOrgSettingManage()).thenReturn(false); + + boolean result = superAdminService.updateMemberPermission(memberId, orgId, req); + + assertTrue(result); + verify(member).replaceWith(any(PermissionType[].class)); + + // afterCommit 수동 호출 + for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) { + sync.afterCommit(); + } + + verify(orgRedisRepository).saveMemberPermission(orgId, memberId, 7L); + } + + @Test + @DisplayName("modifyMemberGroup - 유효하지 않은 그룹이 있으면 예외") + void modifyMemberGroup_invalidGroup() { + Long orgId = 1L; + Long memberId = 10L; + List groupIds = List.of(1L, 2L); + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + + when(memberGroupRepository.countByIdInAndOrganizationId(groupIds, orgId)) + .thenReturn(1L); // 실제 2개 요청 → invalid + + assertThrows(ApiException.class, + () -> superAdminService.modifyMemberGroup(memberId, orgId, groupIds)); + } + + @Test + @DisplayName("modifyMemberGroup - 기존 매핑 삭제 후 새 매핑 저장") + void modifyMemberGroup_success() { + Long orgId = 1L; + Long memberId = 10L; + List groupIds = List.of(1L, 2L); + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + when(member.getId()).thenReturn(memberId); + + when(memberGroupRepository.countByIdInAndOrganizationId(groupIds, orgId)) + .thenReturn((long) groupIds.size()); + + MemberGroup g1 = mock(MemberGroup.class); + MemberGroup g2 = mock(MemberGroup.class); + when(memberGroupRepository.findAllById(groupIds)) + .thenReturn(List.of(g1, g2)); + + boolean result = superAdminService.modifyMemberGroup(memberId, orgId, groupIds); + + assertTrue(result); + verify(memberGroupMappingRepository).deleteByMemberId(memberId); + verify(memberGroupMappingRepository).saveAll(anyList()); + } + + @Test + @DisplayName("changeJoinState - APPROVED 아닌 경우 그룹 매핑/Redis 없이 상태만 변경") + void changeJoinState_notApproved_onlyStatusChange() { + Long orgId = 1L; + Long memberId = 10L; + + ChangeJoinStateRequest req = mock(ChangeJoinStateRequest.class); + when(req.getStatus()).thenReturn(JoinStatusType.REJECTED.name()); + when(req.getMemberGroupIds()).thenReturn(List.of()); + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + + boolean result = superAdminService.changeJoinState(orgId, memberId, req); + + assertTrue(result); + verify(member).changeJoinStatus(JoinStatusType.REJECTED); + verify(memberGroupRepository, never()).countByIdInAndOrganizationId(anyList(), anyLong()); + verify(orgRedisRepository, never()).saveMemberPermission(anyLong(), anyLong(), anyLong()); + } + + @Test + @DisplayName("changeJoinState - APPROVED 이고 그룹 유효 → 매핑 및 Redis 동기화") + void changeJoinState_approved_withGroups_andRedisSync() { + Long orgId = 1L; + Long memberId = 10L; + + TransactionSynchronizationManager.initSynchronization(); + + ChangeJoinStateRequest req = mock(ChangeJoinStateRequest.class); + when(req.getStatus()).thenReturn(JoinStatusType.APPROVED.name()); + when(req.getMemberGroupIds()).thenReturn(List.of(1L, 2L)); + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + when(member.getPermissionCode()).thenReturn(15L); + + when(memberGroupRepository.countByIdInAndOrganizationId(req.getMemberGroupIds(), orgId)) + .thenReturn(2L); + + MemberGroup g1 = mock(MemberGroup.class); + MemberGroup g2 = mock(MemberGroup.class); + when(memberGroupRepository.findAllById(req.getMemberGroupIds())) + .thenReturn(List.of(g1, g2)); + + boolean result = superAdminService.changeJoinState(orgId, memberId, req); + + assertTrue(result); + verify(member).changeJoinStatus(JoinStatusType.APPROVED); + verify(memberGroupMappingRepository).saveAll(anyList()); + + // afterCommit 수동 호출 + for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) { + sync.afterCommit(); + } + + verify(orgRedisRepository).saveMemberPermission(orgId, memberId, 15L); + } + + @Test + @DisplayName("changeJoinState - APPROVED 이지만 그룹 ID가 유효하지 않으면 예외") + void changeJoinState_approved_invalidGroups() { + Long orgId = 1L; + Long memberId = 10L; + + ChangeJoinStateRequest req = mock(ChangeJoinStateRequest.class); + when(req.getStatus()).thenReturn(JoinStatusType.APPROVED.name()); + when(req.getMemberGroupIds()).thenReturn(List.of(1L, 2L)); + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + + when(memberGroupRepository.countByIdInAndOrganizationId(req.getMemberGroupIds(), orgId)) + .thenReturn(1L); // invalid + + assertThrows(ApiException.class, + () -> superAdminService.changeJoinState(orgId, memberId, req)); + + verify(memberGroupMappingRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("readAllJoinRequest - 가입 요청 + 멤버 그룹 함께 반환") + void readAllJoinRequest_success() { + Long orgId = 1L; + + ReadAllJoinRequestItem item = mock(ReadAllJoinRequestItem.class); + MemberGroupItem groupItem = new MemberGroupItem(1L, "개발팀"); + + when(memberRepository.findByOrganizationIdAndJoinStatus(orgId, JoinStatusType.PENDING)) + .thenReturn(List.of(item)); + when(memberGroupRepository.findAllByOrganizationId(orgId)) + .thenReturn(List.of(groupItem)); + + ReadAllJoinRequestResponse response = superAdminService.readAllJoinRequest(orgId); + + assertEquals(1, response.getJoinRequests().size()); + assertEquals(1, response.getAllMemberGroups().size()); + } + + @Test + @DisplayName("withdrawMember - Redis 권한 삭제 후 멤버 비활성화") + void withdrawMember_success() { + Long orgId = 1L; + Long memberId = 10L; + + Member member = mock(Member.class); + when(memberRepository.findByIdAndOrganizationIdAndStatus(memberId, orgId, BaseStatusType.ACTIVE)) + .thenReturn(java.util.Optional.of(member)); + when(member.getId()).thenReturn(memberId); + + boolean result = superAdminService.withdrawMember(orgId, memberId); + + assertTrue(result); + verify(orgRedisRepository).deleteMemberPermission(orgId, memberId); + verify(member).updateToInactive(); + } + + @Test + @DisplayName("deleteOrganization - 조직 비활성화") + void deleteOrganization_success() { + Long orgId = 1L; + Organization org = mock(Organization.class); + when(organizationRepository.findById(orgId)) + .thenReturn(java.util.Optional.of(org)); + + boolean result = superAdminService.deleteOrganization(orgId); + + assertTrue(result); + verify(org).updateToInactive(); + } +} \ No newline at end of file diff --git a/src/test/java/app/allstackproject/privideo/domain/admin/VideoAdminServiceTest.java b/src/test/java/app/allstackproject/privideo/domain/admin/VideoAdminServiceTest.java new file mode 100644 index 0000000..41c3713 --- /dev/null +++ b/src/test/java/app/allstackproject/privideo/domain/admin/VideoAdminServiceTest.java @@ -0,0 +1,184 @@ +package app.allstackproject.privideo.domain.admin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import app.allstackproject.privideo.domain.admin.dto.ReadAllVideoItem; +import app.allstackproject.privideo.domain.admin.service.VideoAdminService; +import app.allstackproject.privideo.domain.comment.repository.CommentRepository; +import app.allstackproject.privideo.domain.history.repository.HistoryRepository; +import app.allstackproject.privideo.domain.quiz.repository.QuizRepository; +import app.allstackproject.privideo.domain.scrap.repository.ScrapRepository; +import app.allstackproject.privideo.domain.video.entity.Video; +import app.allstackproject.privideo.domain.video.repository.VideoCategoryMappingRepository; +import app.allstackproject.privideo.domain.video.repository.VideoMemberGroupMappingRepository; +import app.allstackproject.privideo.domain.video.repository.VideoRepository; +import app.allstackproject.privideo.global.exception.ApiException; +import app.allstackproject.privideo.global.util.CdnUrlProvider; +import app.allstackproject.privideo.global.util.S3Util; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class VideoAdminServiceTest { + + @Mock + private VideoRepository videoRepository; + + @Mock + private VideoCategoryMappingRepository videoCategoryMappingRepository; + + @Mock + private CdnUrlProvider cdnUrlProvider; + + @Mock + private S3Util s3Util; + + @Mock + private VideoMemberGroupMappingRepository videoMemberGroupMappingRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private ScrapRepository scrapRepository; + + @Mock + private HistoryRepository historyRepository; + + @Mock + private QuizRepository quizRepository; + + @InjectMocks + private VideoAdminService videoAdminService; + + @Test + @DisplayName("readAllVideos - 썸네일 키를 CDN URL로 변환해서 반환한다") + void readAllVideos_success() { + // given + Long orgId = 1L; + + ReadAllVideoItem item = new ReadAllVideoItem( + 1L, + "테스트 영상", + "thumb-key", + LocalDateTime.now(), + LocalDate.now().plusDays(1), + "PUBLIC", + 10L + ); + + when(videoRepository.findByOrgId(orgId)) + .thenReturn(List.of(item)); + when(cdnUrlProvider.generateImgUrl("thumb-key")) + .thenReturn("https://cdn.example.com/thumb-key"); + + // when + List result = videoAdminService.readAllVideos(orgId); + + // then + assertEquals(1, result.size()); + ReadAllVideoItem resultItem = result.get(0); + assertEquals("테스트 영상", resultItem.getTitle()); + assertEquals("https://cdn.example.com/thumb-key", resultItem.getThumbnailUrl()); + + verify(videoRepository).findByOrgId(orgId); + verify(cdnUrlProvider).generateImgUrl("thumb-key"); + } + + @Test + @DisplayName("deleteVideo - 영상이 존재하지 않으면 ApiException(VIDEO_NOT_FOUND)") + void deleteVideo_videoNotFound_throwsException() { + // given + Long orgId = 1L; + Long videoId = 10L; + + when(videoRepository.findById(videoId)) + .thenReturn(Optional.empty()); + + // when & then + assertThrows(ApiException.class, + () -> videoAdminService.deleteVideo(orgId, videoId)); + + verify(videoRepository).findById(videoId); + verify(videoRepository, never()).existsByIdAndOrganizationId(anyLong(), anyLong()); + verifyNoInteractions(s3Util, commentRepository, historyRepository, + quizRepository, scrapRepository, + videoMemberGroupMappingRepository, videoCategoryMappingRepository); + } + + @Test + @DisplayName("deleteVideo - 영상이 해당 조직에 속해있지 않으면 ApiException(VIDEO_NOT_IN_ORGANIZATION)") + void deleteVideo_videoNotInOrg_throwsException() { + // given + Long orgId = 1L; + Long videoId = 10L; + + Video video = mock(Video.class); + when(videoRepository.findById(videoId)) + .thenReturn(Optional.of(video)); + when(videoRepository.existsByIdAndOrganizationId(videoId, orgId)) + .thenReturn(false); + + // when & then + assertThrows(ApiException.class, + () -> videoAdminService.deleteVideo(orgId, videoId)); + + verify(videoRepository).findById(videoId); + verify(videoRepository).existsByIdAndOrganizationId(videoId, orgId); + verifyNoInteractions(s3Util, commentRepository, historyRepository, + quizRepository, scrapRepository, + videoMemberGroupMappingRepository, videoCategoryMappingRepository); + } + + @Test + @DisplayName("deleteVideo - S3 파일과 관련 데이터 삭제 후 Video 삭제 성공") + void deleteVideo_success() { + // given + Long orgId = 1L; + Long videoId = 10L; + + Video video = mock(Video.class); + when(video.getThumbnailKey()).thenReturn("thumb-key"); + when(video.getVideoKey()).thenReturn("video-key"); + + when(videoRepository.findById(videoId)) + .thenReturn(Optional.of(video)); + when(videoRepository.existsByIdAndOrganizationId(videoId, orgId)) + .thenReturn(true); + + // when + boolean result = videoAdminService.deleteVideo(orgId, videoId); + + // then + assertTrue(result); + + verify(s3Util).deleteFileByKey("thumb-key", true); + verify(s3Util).deleteFileByKey("video-key", false); + + verify(commentRepository).deleteAllByVideoId(videoId); + verify(historyRepository).deleteAllByVideoId(videoId); + verify(quizRepository).deleteAllByVideoId(videoId); + verify(scrapRepository).deleteAllByVideoId(videoId); + + verify(videoMemberGroupMappingRepository).deleteAllByVideoId(videoId); + verify(videoCategoryMappingRepository).deleteAllByVideoId(videoId); + + verify(videoRepository).delete(video); + } +} \ No newline at end of file