Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public void handle(
) throws IOException, ServletException {
AuthErrorCode errorCode = AuthErrorCode.FORBIDDEN;

response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getHttpStatus().value());
response.getWriter().write(errorCode.getCustomCode());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import clap.server.adapter.inbound.security.service.SecurityUserDetails;
import clap.server.application.port.inbound.admin.RegisterMemberCSVUsecase;
import clap.server.common.annotation.architecture.WebAdapter;
import clap.server.common.utils.FileTypeValidator;
import clap.server.exception.AdapterException;
import clap.server.exception.ApplicationException;
import clap.server.exception.code.FileErrorcode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
Expand All @@ -13,6 +17,8 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@Tag(name = "05. Admin")
@WebAdapter
@RequestMapping("/api/managements")
Expand All @@ -28,7 +34,10 @@ public RegisterMemberCsvController(RegisterMemberCSVUsecase registerMemberCSVUse
@Secured("ROLE_ADMIN")
public ResponseEntity<String> registerMembersFromCsv(
@AuthenticationPrincipal SecurityUserDetails userInfo,
@RequestParam("file") MultipartFile file) {
@RequestParam("file") MultipartFile file) throws IOException {
if (!FileTypeValidator.validCSVFile(file.getInputStream())) {
throw new AdapterException(FileErrorcode.UNSUPPORTED_FILE_TYPE);
}
int addedCount = registerMemberCSVUsecase.registerMembersFromCsv(userInfo.getUserId(), file);
return ResponseEntity.ok(addedCount + "명의 회원이 등록되었습니다.");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package clap.server.adapter.outbound.persistense;

import clap.server.adapter.outbound.persistense.entity.member.DepartmentEntity;
import clap.server.adapter.outbound.persistense.entity.member.constant.DepartmentStatus;
import clap.server.adapter.outbound.persistense.mapper.DepartmentPersistenceMapper;
import clap.server.adapter.outbound.persistense.repository.member.DepartmentRepository;
import clap.server.application.port.outbound.member.CommandDepartmentPort;
Expand All @@ -9,6 +10,7 @@
import clap.server.domain.model.member.Department;
import lombok.RequiredArgsConstructor;

import java.util.List;
import java.util.Optional;

@PersistenceAdapter
Expand All @@ -22,4 +24,11 @@ public Optional<Department> findById(final Long id) {
Optional<DepartmentEntity> departmentEntity = departmentRepository.findById(id);
return departmentEntity.map(departmentPersistenceMapper::toDomain);
}

@Override
public List<Department> findActiveDepartments() {
return departmentRepository.findAllByStatusIs(DepartmentStatus.ACTIVE).stream()
.map(departmentPersistenceMapper::toDomain).toList();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ public void save(final Member member) {
memberRepository.save(memberEntity);
}

@Override
public void saveAll(List<Member> members) {
List<MemberEntity> memberEntities = members.stream().map(memberPersistenceMapper::toEntity).toList();
memberRepository.saveAll(memberEntities);
}

@Override
public List<Member> findActiveManagers() {
List<MemberEntity> memberEntities = memberRepository.findByRoleAndStatus(MemberRole.valueOf("ROLE_MANAGER"), MemberStatus.ACTIVE);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package clap.server.adapter.outbound.persistense.repository.member;

import clap.server.adapter.outbound.persistense.entity.member.DepartmentEntity;
import clap.server.adapter.outbound.persistense.entity.member.constant.DepartmentStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface DepartmentRepository extends JpaRepository<DepartmentEntity, Long> {
List<DepartmentEntity> findAllByStatusIs(DepartmentStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import clap.server.domain.model.member.Member;

import java.util.List;

public interface CommandMemberPort {
void save(Member member);
void saveAll(List<Member> members);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import clap.server.domain.model.member.Department;

import java.util.List;
import java.util.Optional;

public interface LoadDepartmentPort {
Optional<Department> findById(Long id);
List<Department> findActiveDepartments();
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package clap.server.application.service.admin;

import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole;
import clap.server.application.port.outbound.member.LoadDepartmentPort;
import clap.server.common.annotation.architecture.ApplicationService;
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.DepartmentErrorCode;
import clap.server.exception.code.MemberErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.BufferedReader;
Expand All @@ -24,38 +24,41 @@


@Slf4j
@ApplicationService
@Service
@RequiredArgsConstructor
public class CsvParseService {

private final LoadDepartmentPort loadDepartmentPort;

public List<Member> parse(MultipartFile file) {
public List<Member> parseDataAndMapToMember(MultipartFile file) {
List<Member> members = new ArrayList<>();
List<Department> departments = loadDepartmentPort.findActiveDepartments();

try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream()))) {
// 첫 번째 줄은 헤더로 간주하고 다음 줄부터 파싱
String headerLine = reader.readLine();
if (headerLine == null) {
throw ApplicationException.from(MemberErrorCode.INVALID_CSV_FORMAT);
throw new ApplicationException(MemberErrorCode.INVALID_CSV_FORMAT);
}
String line;
while ((line = reader.readLine()) != null) {
String[] fields = line.split(",");
if (fields.length != 7) {
throw ApplicationException.from(MemberErrorCode.INVALID_CSV_FORMAT);
throw new ApplicationException(MemberErrorCode.INVALID_CSV_FORMAT);
}
members.add(mapToMember(fields));
members.add(mapToMember(fields, departments));
}
} catch (IOException e) {
throw ApplicationException.from(MemberErrorCode.CSV_PARSING_ERROR);
throw new ApplicationException(MemberErrorCode.CSV_PARSING_ERROR);
}
return members;
}

private Member mapToMember(String[] fields) {
// 부서 ID로 Department 객체 조회
private Member mapToMember(String[] fields, List<Department> departments) {
Long departmentId = Long.parseLong(fields[2].trim());
Department department = loadDepartmentPort.findById(departmentId)
Department department = departments.stream()
.filter(dept -> dept.getDepartmentId().equals(departmentId))
.findFirst()
.orElseThrow(() -> new ApplicationException(DepartmentErrorCode.DEPARTMENT_NOT_FOUND));

MemberInfo memberInfo = toMemberInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ public class RegisterMemberCSVService implements RegisterMemberCSVUsecase {
@Override
@Transactional
public int registerMembersFromCsv(Long adminId, MultipartFile file) {
List<Member> members = csvParser.parse(file);
List<Member> members = csvParser.parseDataAndMapToMember(file);
Member admin = memberService.findActiveMember(adminId);
members.forEach(member -> {
member.register(admin);
commandMemberPort.save(member);
});
members.forEach(member -> {member.register(admin);});

commandMemberPort.saveAll(members);
return members.size();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import clap.server.application.port.outbound.s3.S3UploadPort;
import clap.server.common.annotation.architecture.ApplicationService;
import clap.server.domain.policy.attachment.FilePathPolicy;
import clap.server.common.utils.FileUtils;
import clap.server.common.utils.FileTypeValidator;
import clap.server.domain.model.member.Member;
import clap.server.exception.ApplicationException;
import clap.server.exception.code.FileErrorcode;
Expand All @@ -25,7 +25,7 @@ class UpdateMemberInfoService implements UpdateMemberInfoUsecase {

@Override
public void updateMemberInfo(Long memberId, UpdateMemberInfoRequest request, MultipartFile profileImage) throws IOException {
if (!FileUtils.validImageFile(profileImage.getInputStream())) {
if (!FileTypeValidator.validImageFile(profileImage.getInputStream())) {
throw new ApplicationException(FileErrorcode.UNSUPPORTED_FILE_TYPE);
}
Member member = memberService.findActiveMember(memberId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,33 @@
import java.util.Arrays;
import java.util.List;

public class FileUtils {
private FileUtils() {
public class FileTypeValidator {
private FileTypeValidator() {
throw new IllegalStateException("Utility class");
}

private static final Tika tika = new Tika();
private static final List<String> VALID_IMAGE_TYPES = Arrays.asList(
"image/jpeg", "image/pjpeg", "image/png", "image/gif", "image/bmp", "image/x-windows-bmp"
);
private static final List<String> VALID_DOCS_TYPES = Arrays.asList(
"text/csv",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);

public static boolean validImageFile(InputStream inputStream) {
public static boolean validCSVFile(InputStream inputStream) {
try {
String mimeType = tika.detect(inputStream);
return VALID_DOCS_TYPES.contains(mimeType.toLowerCase());
} catch (IOException e) {
return false;
}
}

public static boolean validImageFile(InputStream inputStream) {
try {
String mimeType = tika.detect(inputStream);
return VALID_IMAGE_TYPES.contains(mimeType.toLowerCase());
} catch (IOException e) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
@RequiredArgsConstructor
public enum FileErrorcode implements BaseErrorCode {
FILE_UPLOAD_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_001", "파일 업로드에 실패하였습니다."),
UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE_002", "이미지가 아닌 파일 유형입니다."),;
UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE_002", "유효하지 않은 파일 유형입니다."),;

private final HttpStatus httpStatus;
private final String customCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
import clap.server.exception.ApplicationException;
import clap.server.exception.code.MemberErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
Expand All @@ -37,6 +39,7 @@ void setup() {
* - 주어진 CSV 파일을 정상적으로 파싱하여 회원이 등록되는지 검증
*/
@Test
@DisplayName("CSV 파일에서 회원 정보를 성공적으로 파싱하고 등록하는지 검증한다.")
void testRegisterMembersFromCsvSuccess() {
Long adminId = 1L;
MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", "dummy-content".getBytes());
Expand All @@ -45,20 +48,22 @@ void testRegisterMembersFromCsvSuccess() {
List<Member> parsedMembers = List.of(Mockito.mock(Member.class), Mockito.mock(Member.class));

when(memberService.findActiveMember(adminId)).thenReturn(admin);
when(csvParseService.parse(file)).thenReturn(parsedMembers);
when(csvParseService.parseDataAndMapToMember(file)).thenReturn(parsedMembers);

int addedCount = registerMemberCSVService.registerMembersFromCsv(adminId, file);

assertEquals(2, addedCount);
verify(commandMemberPort, times(2)).save(any(Member.class));
verify(parsedMembers.get(0), times(1)).register(admin);
verify(parsedMembers.get(1), times(1)).register(admin);
verify(commandMemberPort).saveAll(parsedMembers);
verify(parsedMembers.get(0)).register(admin);
verify(parsedMembers.get(1)).register(admin);
}


/**
* ❌ 관리자 찾기 실패 (MEMBER_NOT_FOUND)
*/
@Test
@DisplayName("관리자가 존재하지 않을 때 CSV 회원 등록 시 예외가 발생한다.")
void testRegisterMembersFromCsvThrowsWhenAdminNotFound() {
Long adminId = 99L;
MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", "dummy-content".getBytes());
Expand All @@ -78,12 +83,13 @@ void testRegisterMembersFromCsvThrowsWhenAdminNotFound() {
* ❌ CSV 파싱 실패 (CSV_PARSING_ERROR)
*/
@Test
@DisplayName("CSV 파싱 실패 시 예외 발생 및 회원 등록이 실패한다.")
void testRegisterMembersFromCsvThrowsWhenCsvParsingFails() {
Long adminId = 1L;
MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", "dummy-content".getBytes());

// ✅ Mock 객체 설정: CSV 파싱 과정에서 예외 발생하도록 설정
when(csvParseService.parse(file)).thenThrow(new ApplicationException(MemberErrorCode.CSV_PARSING_ERROR));
when(csvParseService.parseDataAndMapToMember(file)).thenThrow(new ApplicationException(MemberErrorCode.CSV_PARSING_ERROR));

// 🔹 유스케이스 실행 및 예외 검증
ApplicationException exception = assertThrows(ApplicationException.class, () -> {
Expand All @@ -99,27 +105,28 @@ void testRegisterMembersFromCsvThrowsWhenCsvParsingFails() {
* ❌ 회원 등록 실패 (MEMBER_REGISTRATION_FAILED)
*
*/
@Test
void testRegisterMembersFromCsvThrowsWhenSavingMemberFails() {
Long adminId = 1L;
MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", "dummy-content".getBytes());

Member admin = Mockito.mock(Member.class);
Member failingMember = Mockito.mock(Member.class);
List<Member> parsedMembers = List.of(failingMember, Mockito.mock(Member.class));

// 특정 회원 등록 중 예외 발생
when(memberService.findActiveMember(adminId)).thenReturn(admin);
when(csvParseService.parse(file)).thenReturn(parsedMembers);
doThrow(new ApplicationException(MemberErrorCode.MEMBER_REGISTRATION_FAILED))
.when(commandMemberPort).save(failingMember);

// Usecase 실행
ApplicationException exception = assertThrows(ApplicationException.class, () -> {
registerMemberCSVService.registerMembersFromCsv(adminId, file);
});

assertEquals(MemberErrorCode.MEMBER_REGISTRATION_FAILED.getCustomCode(), exception.getCode().getCustomCode());
verify(commandMemberPort, times(1)).save(failingMember); // ❗ 실패한 회원만 저장 시도해야 함
}
// @Test
// @DisplayName("회원 등록 과정 중 실패 시 예외 발생 및 부분 저장 된다.")
// void testRegisterMembersFromCsvThrowsWhenSavingMemberFails() {
// Long adminId = 1L;
// MultipartFile file = new MockMultipartFile("file", "members.csv", "text/csv", "dummy-content".getBytes());
//
// Member admin = Mockito.mock(Member.class);
// Member failingMember = Mockito.mock(Member.class);
// List<Member> parsedMembers = List.of(failingMember, Mockito.mock(Member.class));
//
// // 특정 회원 등록 중 예외 발생
// when(memberService.findActiveMember(adminId)).thenReturn(admin);
// when(csvParseService.parseDataAndMapToMember(file)).thenReturn(parsedMembers);
// doThrow(new ApplicationException(MemberErrorCode.MEMBER_REGISTRATION_FAILED))
// .when(commandMemberPort).save(failingMember);
//
// // Usecase 실행
// ApplicationException exception = assertThrows(ApplicationException.class, () -> {
// registerMemberCSVService.registerMembersFromCsv(adminId, file);
// });
//
// assertEquals(MemberErrorCode.MEMBER_REGISTRATION_FAILED.getCustomCode(), exception.getCode().getCustomCode());
// verify(commandMemberPort, times(1)).save(failingMember); // ❗ 실패한 회원만 저장 시도해야 함
// }
}