From 18d4a8d35bbc7b0ab9f4f62b65651fac12512acf Mon Sep 17 00:00:00 2001 From: Sihun23 Date: Fri, 7 Feb 2025 15:02:05 +0900 Subject: [PATCH] =?UTF-8?q?CLAP-320=20fix:nickname=20=EB=B0=8F=20email=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistense/MemberPersistenceAdapter.java | 8 +- .../repository/member/MemberRepository.java | 2 + .../port/outbound/member/LoadMemberPort.java | 3 + .../admin/RegisterMemberCSVService.java | 14 ++ .../exception/code/MemberErrorCode.java | 5 +- .../admin/RegisterMemberCsvServiceTest.java | 154 ++++++++++++++++++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/test/java/clap/server/application/service/admin/RegisterMemberCsvServiceTest.java diff --git a/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java b/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java index e6fbafbe..95f01bd6 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java @@ -113,5 +113,11 @@ public Optional findByNicknameAndEmail(String nickname, String email) { public Optional findByNameAndEmail(String name, String email) { return memberRepository.findByNameAndEmail(name, email).map(memberPersistenceMapper::toDomain); } -} + @Override + public Optional findByEmail(String email) { + return memberRepository.findByEmail(email) + .map(memberPersistenceMapper::toDomain); + } + +} \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java b/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java index 4f608bd9..d7d967aa 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java @@ -26,5 +26,7 @@ public interface MemberRepository extends JpaRepository, Me Optional findByNicknameAndEmail(String nickname, String email); Optional findByNameAndEmail(String name, String email); + + Optional findByEmail(String email); } diff --git a/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java b/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java index 972a5edd..dff8b206 100644 --- a/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java +++ b/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java @@ -34,4 +34,7 @@ public interface LoadMemberPort { Optional findByNicknameAndEmail(String nickname, String email); Optional findByNameAndEmail(String name, String email); + + Optional findByEmail(String email); + } diff --git a/src/main/java/clap/server/application/service/admin/RegisterMemberCSVService.java b/src/main/java/clap/server/application/service/admin/RegisterMemberCSVService.java index 0723c7dd..6557c3ec 100644 --- a/src/main/java/clap/server/application/service/admin/RegisterMemberCSVService.java +++ b/src/main/java/clap/server/application/service/admin/RegisterMemberCSVService.java @@ -3,8 +3,11 @@ import clap.server.application.port.inbound.admin.RegisterMemberCSVUsecase; import clap.server.application.port.inbound.domain.MemberService; import clap.server.application.port.outbound.member.CommandMemberPort; +import clap.server.application.port.outbound.member.LoadMemberPort; import clap.server.common.annotation.architecture.ApplicationService; import clap.server.domain.model.member.Member; +import clap.server.exception.ApplicationException; +import clap.server.exception.code.MemberErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -17,6 +20,8 @@ public class RegisterMemberCSVService implements RegisterMemberCSVUsecase { private final MemberService memberService; private final CommandMemberPort commandMemberPort; private final CsvParseService csvParser; + private final LoadMemberPort loadMemberPort; + @Override @Transactional @@ -24,6 +29,15 @@ public int registerMembersFromCsv(Long adminId, MultipartFile file) { List members = csvParser.parseDataAndMapToMember(file); Member admin = memberService.findActiveMember(adminId); + members.forEach(member -> { + String nickname = member.getMemberInfo().getNickname(); + String email = member.getMemberInfo().getEmail(); + if (loadMemberPort.findByNickname(nickname).isPresent() || + loadMemberPort.findByEmail(email).isPresent()) { + throw new ApplicationException(MemberErrorCode.DUPLICATE_NICKNAME_OR_EMAIL); + } + }); + List newMembers = members.stream() .map(memberData -> Member.createMember(admin, memberData.getMemberInfo())) .toList(); diff --git a/src/main/java/clap/server/exception/code/MemberErrorCode.java b/src/main/java/clap/server/exception/code/MemberErrorCode.java index 865fc7d5..47bd9743 100644 --- a/src/main/java/clap/server/exception/code/MemberErrorCode.java +++ b/src/main/java/clap/server/exception/code/MemberErrorCode.java @@ -16,8 +16,9 @@ public enum MemberErrorCode implements BaseErrorCode { CSV_PARSING_ERROR(HttpStatus.BAD_REQUEST, "MEMBER_008", "CSV 데이터 파싱 중 오류가 발생했습니다."), MEMBER_REGISTRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MEMBER_009", "담당자만 리뷰 권한이 있습니다."), NAME_CANNOT_BE_EMPTY(HttpStatus.BAD_REQUEST, "MEMBER_010", "이름은 공백일 수 없습니다."), - DUPLICATE_NICKNAME(HttpStatus.BAD_REQUEST,"MEMBER_011", "중복된 닉네임입니다") - ; + DUPLICATE_NICKNAME(HttpStatus.BAD_REQUEST,"MEMBER_011", "중복된 닉네임입니다"), + DUPLICATE_NICKNAME_OR_EMAIL(HttpStatus.BAD_REQUEST, "MEMBER_012", "중복된 닉네임이나 email이 존재합니다") + ; private final HttpStatus httpStatus; private final String customCode; diff --git a/src/test/java/clap/server/application/service/admin/RegisterMemberCsvServiceTest.java b/src/test/java/clap/server/application/service/admin/RegisterMemberCsvServiceTest.java new file mode 100644 index 00000000..4188197e --- /dev/null +++ b/src/test/java/clap/server/application/service/admin/RegisterMemberCsvServiceTest.java @@ -0,0 +1,154 @@ +package clap.server.application.service.admin; + +import clap.server.application.port.inbound.domain.MemberService; +import clap.server.application.port.outbound.member.CommandMemberPort; +import clap.server.application.port.outbound.member.LoadMemberPort; +import clap.server.domain.model.member.Department; +import clap.server.domain.model.member.Member; +import clap.server.domain.model.member.MemberInfo; +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; +import clap.server.exception.ApplicationException; +import clap.server.exception.code.MemberErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class RegisterMemberCSVServiceTest { + + @Mock + private MemberService memberService; + + @Mock + private CommandMemberPort commandMemberPort; + + @Mock + private CsvParseService csvParser; + + @Mock + private LoadMemberPort loadMemberPort; + + @InjectMocks + private RegisterMemberCSVService registerMemberCSVService; + + // 더미 Department: department는 not null이어야 하므로, 예시로 departmentId가 채워진 객체를 생성합니다. + private Department dummyDepartment; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + // 예시로 Department가 빌더를 제공한다고 가정 + dummyDepartment = Department.builder().departmentId(100L).build(); + } + + @Test + void testRegisterMembersFromCsv_success() throws Exception { + // given + Long adminId = 1L; + MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", + "header\nrow1\nrow2".getBytes()); + + // CSV 파싱 결과로 반환할 회원 객체들 (각 MemberInfo에 dummyDepartment 적용) + Member csvMember1 = mock(Member.class); + Member csvMember2 = mock(Member.class); + + MemberInfo dummyMemberInfo1 = MemberInfo.builder() + .name("John Doe") + .email("john@example.com") + .nickname("johnny") + .isReviewer(false) + .department(dummyDepartment) + .role(MemberRole.ROLE_USER) + .departmentRole("Dept Role") + .build(); + + MemberInfo dummyMemberInfo2 = MemberInfo.builder() + .name("Jane Doe") + .email("jane@example.com") + .nickname("janie") + .isReviewer(false) + .department(dummyDepartment) + .role(MemberRole.ROLE_USER) + .departmentRole("Dept Role") + .build(); + + when(csvMember1.getMemberInfo()).thenReturn(dummyMemberInfo1); + when(csvMember2.getMemberInfo()).thenReturn(dummyMemberInfo2); + List csvMembers = Arrays.asList(csvMember1, csvMember2); + when(csvParser.parseDataAndMapToMember(file)).thenReturn(csvMembers); + + when(loadMemberPort.findByNickname(anyString())).thenReturn(Optional.empty()); + when(loadMemberPort.findByEmail(anyString())).thenReturn(Optional.empty()); + + Member adminMember = mock(Member.class); + when(memberService.findActiveMember(adminId)).thenReturn(adminMember); + + Member newMember1 = mock(Member.class); + Member newMember2 = mock(Member.class); + + try (MockedStatic mockedStatic = Mockito.mockStatic(Member.class)) { + mockedStatic.when(() -> Member.createMember(eq(adminMember), eq(dummyMemberInfo1))) + .thenReturn(newMember1); + mockedStatic.when(() -> Member.createMember(eq(adminMember), eq(dummyMemberInfo2))) + .thenReturn(newMember2); + + // when + int result = registerMemberCSVService.registerMembersFromCsv(adminId, file); + + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(commandMemberPort).saveAll(captor.capture()); + List savedMembers = captor.getValue(); + + assertEquals(2, savedMembers.size(), "CSV 파싱된 회원 수 만큼 새로운 회원이 생성"); + assertEquals(2, result, "등록된 회원 수는 CSV 파일의 회원 수와 동일"); + + mockedStatic.verify(() -> Member.createMember(adminMember, dummyMemberInfo1), times(1)); + mockedStatic.verify(() -> Member.createMember(adminMember, dummyMemberInfo2), times(1)); + } + } + + @Test + void testRegisterMembersFromCsv_duplicateThrowsException() throws Exception { + // given + Long adminId = 1L; + MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", + "header\nrow1".getBytes()); + + Member csvMember1 = mock(Member.class); + MemberInfo dummyMemberInfo1 = MemberInfo.builder() + .name("John Doe") + .email("john@example.com") + .nickname("johnny") + .isReviewer(false) + .department(dummyDepartment) + .role(MemberRole.ROLE_USER) + .departmentRole("Dept Role") + .build(); + when(csvMember1.getMemberInfo()).thenReturn(dummyMemberInfo1); + List csvMembers = Arrays.asList(csvMember1); + when(csvParser.parseDataAndMapToMember(file)).thenReturn(csvMembers); + + Member adminMember = mock(Member.class); + when(memberService.findActiveMember(adminId)).thenReturn(adminMember); + + // 중복 체크: 닉네임 또는 email 중 하나라도 중복이 있으면 에러 발생 + when(loadMemberPort.findByNickname(dummyMemberInfo1.getNickname())) + .thenReturn(Optional.of(mock(Member.class))); + + ApplicationException exception = assertThrows(ApplicationException.class, () -> + registerMemberCSVService.registerMembersFromCsv(adminId, file) + ); + assertEquals(MemberErrorCode.DUPLICATE_NICKNAME_OR_EMAIL.getMessage(), exception.getMessage(), + "중복된 닉네임이나 email이 존재하면 MEMBER_012 예외가 발생해야 합니다."); + } +}