ITN Dev 07-23
feat: 회원정보 xlsx로 담당자를 일괄 등록하는 업로드 API를 추가
POST /api/contacts/import로 회원정보 시트(Sheet1)를 업로드하면 구분(주관기관/
변호사/신청기관/수행기관)에 따라 담당자를 등록·갱신하고, 신청기관 담당자는
기관명이 정확히 일치하는 기관에 자동 배정한다. 이미 등록된 사람은
findMatching으로 재사용하고 소속/부서/직급은 비어 있을 때만 채워 재업로드해도
안전하다. 확장자·빈 파일 검증은 SeedController와 동일한 방식을 따르고
SeedParseException을 재사용해 400으로 처리한다.
@a77ed96e8f773ced0fa04f5105c5ba7a7c34bff7
 
src/main/java/kr/itn/itnhub/contact/MemberDirectoryParser.java (added)
+++ src/main/java/kr/itn/itnhub/contact/MemberDirectoryParser.java
@@ -0,0 +1,118 @@
+package kr.itn.itnhub.contact;
+
+import kr.itn.itnhub.seed.SeedParseException;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 회원정보 xlsx(Sheet1)를 읽는다.
+ *
+ * 시트 배치 (1행 헤더, 2행부터 데이터):
+ *   A=순번  B=구분  C=기관명  D=부서명  E=담당자명  F=직급/직함  G=유선전화  H=이메일
+ *
+ * 담당자명(E)이 빈 첫 행에서 데이터가 끝난 것으로 보고 읽기를 멈춘다. 구분(B)이
+ * 알려진 값(주관기관/변호사/신청기관/수행기관)이 아니면 그 행은 건너뛰고 skipped로
+ * 센다 - 시트 끝은 아니므로 이어서 다음 행을 계속 읽는다.
+ */
+@Component
+public class MemberDirectoryParser {
+
+    public static final String SHEET_NAME = "Sheet1";
+
+    private static final int DATA_START_ROW_INDEX = 1; // 2행
+    private static final int COL_CATEGORY = 1;  // B
+    private static final int COL_ORG_NAME = 2;  // C
+    private static final int COL_DEPT = 3;      // D
+    private static final int COL_NAME = 4;      // E
+    private static final int COL_TITLE = 5;     // F
+    private static final int COL_PHONE = 6;     // G
+    private static final int COL_EMAIL = 7;     // H
+
+    private static final Map<String, String> CATEGORY_LABELS = Map.of(
+            "주관기관", "MJ",
+            "변호사", "LAWYER",
+            "신청기관", "APPLICANT",
+            "수행기관", "OPERATOR");
+
+    public MemberParseResult parse(InputStream xlsx) {
+        try (Workbook wb = WorkbookFactory.create(xlsx)) {
+            Sheet sheet = wb.getSheet(SHEET_NAME);
+            if (sheet == null) {
+                throw new SeedParseException(
+                        "통합문서에 '" + SHEET_NAME + "' 시트가 없습니다.");
+            }
+            return readRows(sheet);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    private MemberParseResult readRows(Sheet sheet) {
+        List<MemberRow> rows = new ArrayList<>();
+        int skipped = 0;
+
+        for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) {
+            Row row = sheet.getRow(i);
+            String name = row == null ? "" : string(row, COL_NAME);
+            if (name.isBlank()) {
+                break; // 담당자명이 빈 첫 행에서 데이터 끝
+            }
+
+            String category = CATEGORY_LABELS.get(string(row, COL_CATEGORY));
+            if (category == null) {
+                skipped++;
+                continue;
+            }
+
+            rows.add(new MemberRow(
+                    category,
+                    nullIfBlank(string(row, COL_ORG_NAME)),
+                    normalizeDept(string(row, COL_DEPT)),
+                    name,
+                    nullIfBlank(string(row, COL_TITLE)),
+                    nullIfBlank(string(row, COL_PHONE)),
+                    nullIfBlank(string(row, COL_EMAIL))));
+        }
+        return new MemberParseResult(rows, skipped);
+    }
+
+    /** 부서명은 "-"와 공백 모두 값 없음으로 취급한다. */
+    private String normalizeDept(String value) {
+        String v = nullIfBlank(value);
+        return "-".equals(v) ? null : v;
+    }
+
+    private String string(Row row, int columnIndex) {
+        Cell cell = row.getCell(columnIndex);
+        if (cell == null) {
+            return "";
+        }
+        return cellValue(cell, cell.getCellType());
+    }
+
+    /** SeedParser와 동일한 셀 값 변환 규칙(FORMULA는 캐시된 결과 타입으로 위임). */
+    private String cellValue(Cell cell, CellType cellType) {
+        return switch (cellType) {
+            case STRING -> cell.getStringCellValue().trim();
+            case NUMERIC -> String.valueOf(Math.round(cell.getNumericCellValue()));
+            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
+            case FORMULA -> cellValue(cell, cell.getCachedFormulaResultType());
+            default -> "";
+        };
+    }
+
+    private String nullIfBlank(String value) {
+        return (value == null || value.isBlank()) ? null : value;
+    }
+}
 
src/main/java/kr/itn/itnhub/contact/MemberImportController.java (added)
+++ src/main/java/kr/itn/itnhub/contact/MemberImportController.java
@@ -0,0 +1,42 @@
+package kr.itn.itnhub.contact;
+
+import kr.itn.itnhub.seed.SeedParseException;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/** 회원정보 xlsx 업로드. SeedController와 동일한 확장자/빈 파일 검증을 쓴다. */
+@RestController
+public class MemberImportController {
+
+    private final MemberImportService importService;
+
+    public MemberImportController(MemberImportService importService) {
+        this.importService = importService;
+    }
+
+    @PostMapping("/api/contacts/import")
+    public MemberImportReport upload(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            throw new SeedParseException("업로드된 파일이 비어 있습니다.");
+        }
+        String filename = file.getOriginalFilename();
+        if (filename == null || !hasAllowedExtension(filename)) {
+            throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다.");
+        }
+        try (InputStream in = file.getInputStream()) {
+            return importService.importFile(in);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    private static boolean hasAllowedExtension(String filename) {
+        String lower = filename.toLowerCase();
+        return lower.endsWith(".xlsx") || lower.endsWith(".xlsm");
+    }
+}
 
src/main/java/kr/itn/itnhub/contact/MemberImportReport.java (added)
+++ src/main/java/kr/itn/itnhub/contact/MemberImportReport.java
@@ -0,0 +1,5 @@
+package kr.itn.itnhub.contact;
+
+/** 회원명단 업로드 결과. assigned는 신청기관 담당자로 자동 배정된 기관 수. */
+public record MemberImportReport(int created, int updated, int skipped, int assigned) {
+}
 
src/main/java/kr/itn/itnhub/contact/MemberImportService.java (added)
+++ src/main/java/kr/itn/itnhub/contact/MemberImportService.java
@@ -0,0 +1,103 @@
+package kr.itn.itnhub.contact;
+
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.io.InputStream;
+
+/**
+ * 회원정보 xlsx를 읽어 담당자를 등록/갱신하고, 신청기관 담당자는 같은 이름의 기관에
+ * 자동 배정한다. 재업로드해도 안전해야 한다(SeedService와 같은 원칙) - 같은 사람이
+ * 다시 올라오면 새로 만들지 않고 기존 담당자를 찾아 재사용한다.
+ *
+ * <p>엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다(kr.itn.itnhub.seed.SeedService와
+ * 동일한 이유 - 같은 빈의 @Transactional 메서드를 self-invocation으로 호출하면 프록시를
+ * 우회해 트랜잭션이 적용되지 않으므로 TransactionTemplate으로 감싼다).</p>
+ */
+@Service
+public class MemberImportService {
+
+    private final MemberDirectoryParser parser;
+    private final ContactMapper contactMapper;
+    private final OrganizationMapper orgMapper;
+    private final TransactionTemplate transactionTemplate;
+
+    public MemberImportService(MemberDirectoryParser parser, ContactMapper contactMapper,
+                                OrganizationMapper orgMapper, PlatformTransactionManager transactionManager) {
+        this.parser = parser;
+        this.contactMapper = contactMapper;
+        this.orgMapper = orgMapper;
+        this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    public MemberImportReport importFile(InputStream xlsx) {
+        MemberParseResult parsed = parser.parse(xlsx);
+        return transactionTemplate.execute(status -> importRows(parsed));
+    }
+
+    private MemberImportReport importRows(MemberParseResult parsed) {
+        int created = 0;
+        int updated = 0;
+        int assigned = 0;
+
+        for (MemberRow row : parsed.rows()) {
+            Contact existing = contactMapper.findMatching(
+                    row.category(), row.name(), row.phone(), row.email());
+
+            Contact contact;
+            if (existing == null) {
+                contact = new Contact();
+                contact.setCategory(row.category());
+                contact.setName(row.name());
+                contact.setAffiliation(row.affiliation());
+                contact.setDeptName(row.deptName());
+                contact.setTitle(row.title());
+                contact.setPhone(row.phone());
+                contact.setEmail(row.email());
+                contactMapper.insert(contact);
+                created++;
+            } else {
+                fillMissingFields(existing, row);
+                contactMapper.update(existing);
+                updated++;
+                contact = existing;
+            }
+
+            if ("APPLICANT".equals(row.category()) && tryAssignApplicant(row, contact)) {
+                assigned++;
+            }
+        }
+
+        return new MemberImportReport(created, updated, parsed.skipped(), assigned);
+    }
+
+    /** 기존 담당자의 소속/부서/직급은 이미 값이 있으면 건드리지 않고, 비어 있을 때만 채운다. */
+    private void fillMissingFields(Contact existing, MemberRow row) {
+        if (existing.getAffiliation() == null && row.affiliation() != null) {
+            existing.setAffiliation(row.affiliation());
+        }
+        if (existing.getDeptName() == null && row.deptName() != null) {
+            existing.setDeptName(row.deptName());
+        }
+        if (existing.getTitle() == null && row.title() != null) {
+            existing.setTitle(row.title());
+        }
+    }
+
+    /** 기관명이 정확히 일치하고 아직 신청기관 담당자가 배정되지 않은 기관에만 배정한다. */
+    private boolean tryAssignApplicant(MemberRow row, Contact contact) {
+        if (row.affiliation() == null) {
+            return false;
+        }
+        Organization org = orgMapper.findByOrgName(row.affiliation());
+        if (org == null || org.getApplicantContactId() != null) {
+            return false;
+        }
+        orgMapper.updateAssignments(org.getId(), contact.getId(),
+                org.getMjContactId(), org.getLawyerContactId(), org.getLawyerAssignedDate());
+        return true;
+    }
+}
 
src/main/java/kr/itn/itnhub/contact/MemberParseResult.java (added)
+++ src/main/java/kr/itn/itnhub/contact/MemberParseResult.java
@@ -0,0 +1,7 @@
+package kr.itn.itnhub.contact;
+
+import java.util.List;
+
+/** 회원명단 파싱 결과. skipped는 구분(카테고리)을 알아볼 수 없어 건너뛴 행 수다. */
+public record MemberParseResult(List<MemberRow> rows, int skipped) {
+}
 
src/main/java/kr/itn/itnhub/contact/MemberRow.java (added)
+++ src/main/java/kr/itn/itnhub/contact/MemberRow.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.contact;
+
+/** 회원명단 시트 한 행. category는 이미 내부 코드(APPLICANT/MJ/LAWYER/OPERATOR)로 매핑된 값이다. */
+public record MemberRow(
+        String category,
+        String affiliation,
+        String deptName,
+        String name,
+        String title,
+        String phone,
+        String email) {
+}
 
src/test/java/kr/itn/itnhub/contact/MemberDirectoryParserTest.java (added)
+++ src/test/java/kr/itn/itnhub/contact/MemberDirectoryParserTest.java
@@ -0,0 +1,75 @@
+package kr.itn.itnhub.contact;
+
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MemberDirectoryParserTest {
+
+    private final MemberDirectoryParser parser = new MemberDirectoryParser();
+
+    private byte[] workbook(String[]... dataRows) throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook();
+             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+            Sheet sheet = wb.createSheet(MemberDirectoryParser.SHEET_NAME);
+            sheet.createRow(0); // 헤더
+
+            int rowNum = 1;
+            for (String[] data : dataRows) {
+                Row row = sheet.createRow(rowNum++);
+                for (int i = 0; i < data.length; i++) {
+                    if (data[i] == null) {
+                        continue;
+                    }
+                    row.createCell(i).setCellValue(data[i]);
+                }
+            }
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    @Test
+    void 담당자명이_빈_첫_행에서_읽기를_멈춘다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"1", "신청기관", "기관A", "팀", "김담당", "과장", "02-1", "a@b.kr"},
+                new String[]{"2", "신청기관", "기관B", null, null, null, null, null},
+                new String[]{"3", "신청기관", "기관C", "팀", "이담당", "대리", "02-3", "c@d.kr"});
+
+        MemberParseResult result = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(result.rows()).hasSize(1);
+        assertThat(result.rows().get(0).name()).isEqualTo("김담당");
+    }
+
+    @Test
+    void 부서명이_대시나_공백이면_null로_취급한다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"1", "변호사", null, "-", "이변호", null, "02-1", "a@b.kr"});
+
+        List<MemberRow> rows = parser.parse(new ByteArrayInputStream(xlsx)).rows();
+
+        assertThat(rows.get(0).deptName()).isNull();
+    }
+
+    @Test
+    void 알수없는_구분은_건너뛰고_계속_읽는다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"1", "이상한구분", "기관A", "팀", "김담당", "과장", "02-1", "a@b.kr"},
+                new String[]{"2", "변호사", null, null, "이변호", null, "02-2", "b@c.kr"});
+
+        MemberParseResult result = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(result.skipped()).isEqualTo(1);
+        assertThat(result.rows()).hasSize(1);
+        assertThat(result.rows().get(0).name()).isEqualTo("이변호");
+    }
+}
 
src/test/java/kr/itn/itnhub/contact/MemberImportControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/contact/MemberImportControllerTest.java
@@ -0,0 +1,186 @@
+package kr.itn.itnhub.contact;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class MemberImportControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    ContactMapper contactMapper;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @BeforeEach
+    void clean() {
+        jdbc.update("delete from organization");
+        jdbc.update("delete from contact");
+    }
+
+    /** 회원정보 시트: 1행 헤더, 2행부터 데이터. A=순번 B=구분 C=기관명 D=부서명 E=담당자명 F=직급 G=전화 H=이메일 */
+    private MockMultipartFile workbook(String[]... dataRows) throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook();
+             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+            Sheet sheet = wb.createSheet(MemberDirectoryParser.SHEET_NAME);
+            Row header = sheet.createRow(0);
+            String[] headers = {"순번", "구분", "기관명", "부서명", "담당자명", "직급/직함", "유선전화", "이메일"};
+            for (int i = 0; i < headers.length; i++) {
+                header.createCell(i).setCellValue(headers[i]);
+            }
+
+            int rowNum = 1;
+            for (String[] data : dataRows) {
+                Row row = sheet.createRow(rowNum++);
+                for (int i = 0; i < data.length; i++) {
+                    if (data[i] == null) {
+                        continue;
+                    }
+                    row.createCell(i).setCellValue(data[i]);
+                }
+            }
+            wb.write(out);
+            return new MockMultipartFile("file", "members.xlsx",
+                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+                    out.toByteArray());
+        }
+    }
+
+    @Test
+    void 구분을_주관기관_변호사_신청기관_수행기관으로_각각_매핑한다() throws Exception {
+        MockMultipartFile file = workbook(
+                new String[]{"1", "주관기관", null, "저작권정책과", "김문정", "사무관", "02-1", "mj@mcst.go.kr"},
+                new String[]{"2", "변호사", null, null, "이변호", null, "02-2", "law@firm.kr"},
+                new String[]{"3", "신청기관", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-3", "ming@arirang.com"},
+                new String[]{"4", "수행기관", "수행사", "운영팀", "홍수행", "대리", "02-4", "op@vendor.kr"});
+
+        mvc.perform(multipart("/api/contacts/import").file(file).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(4))
+                .andExpect(jsonPath("$.skipped").value(0));
+
+        List<Contact> all = contactMapper.findAll(null);
+        assertThat(all).hasSize(4);
+        assertThat(all).extracting(Contact::getCategory)
+                .containsExactlyInAnyOrder("MJ", "LAWYER", "APPLICANT", "OPERATOR");
+
+        Contact operator = all.stream().filter(c -> "OPERATOR".equals(c.getCategory())).findFirst().orElseThrow();
+        assertThat(operator.getName()).isEqualTo("홍수행");
+        assertThat(operator.getAffiliation()).isEqualTo("수행사");
+        assertThat(operator.getDeptName()).isEqualTo("운영팀");
+    }
+
+    @Test
+    void 같은_사람이_여러_행에_있으면_한_명만_생성되고_두번째부터는_갱신으로_집계된다() throws Exception {
+        MockMultipartFile file = workbook(
+                new String[]{"1", "신청기관", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"},
+                new String[]{"2", "신청기관", "서울청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"},
+                new String[]{"3", "신청기관", "부산청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"});
+
+        mvc.perform(multipart("/api/contacts/import").file(file).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(1))
+                .andExpect(jsonPath("$.updated").value(2));
+
+        assertThat(jdbc.queryForObject("select count(*) from contact", Integer.class)).isEqualTo(1);
+    }
+
+    @Test
+    void 신청기관_담당자는_기관명이_같은_기관에_자동_배정된다() throws Exception {
+        Organization org = new Organization();
+        org.setOrgNo("001_00");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001_00");
+        orgMapper.upsertBySeed(org);
+        Long orgId = orgMapper.findAll().get(0).getId();
+        assertThat(orgMapper.findById(orgId).getApplicantContactId()).isNull();
+
+        MockMultipartFile file = workbook(new String[]{
+                "1", "신청기관", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-3475-5434", "ming@arirang.com"});
+
+        mvc.perform(multipart("/api/contacts/import").file(file).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.assigned").value(1));
+
+        Organization after = orgMapper.findById(orgId);
+        assertThat(after.getApplicantContactId()).isNotNull();
+        assertThat(after.getApplicant().getName()).isEqualTo("송민지");
+    }
+
+    @Test
+    void 이미_배정된_기관은_재업로드해도_배정이_바뀌지_않는다() throws Exception {
+        Organization org = new Organization();
+        org.setOrgNo("001_00");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001_00");
+        orgMapper.upsertBySeed(org);
+        Long orgId = orgMapper.findAll().get(0).getId();
+
+        MockMultipartFile file = workbook(new String[]{
+                "1", "신청기관", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-3475-5434", "ming@arirang.com"});
+
+        mvc.perform(multipart("/api/contacts/import").file(file).with(csrf()))
+                .andExpect(status().isOk());
+        Long firstContactId = orgMapper.findById(orgId).getApplicantContactId();
+
+        mvc.perform(multipart("/api/contacts/import").file(file).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.assigned").value(0));
+
+        assertThat(orgMapper.findById(orgId).getApplicantContactId()).isEqualTo(firstContactId);
+    }
+
+    @Test
+    void 알수없는_구분은_건너뛰고_skipped로_집계된다() throws Exception {
+        MockMultipartFile file = workbook(
+                new String[]{"1", "신청기관", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"},
+                new String[]{"2", "알수없음", "어떤기관", "팀", "홍길동", "대리", "02-2", "b@c.kr"});
+
+        mvc.perform(multipart("/api/contacts/import").file(file).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(1))
+                .andExpect(jsonPath("$.skipped").value(1));
+
+        assertThat(jdbc.queryForObject("select count(*) from contact", Integer.class)).isEqualTo(1);
+    }
+
+    @Test
+    void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception {
+        MockMultipartFile txt = new MockMultipartFile("file", "members.txt",
+                "text/plain", "아무 내용".getBytes());
+
+        mvc.perform(multipart("/api/contacts/import").file(txt).with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("xlsx")));
+    }
+}
Add a comment
List