ITN Dev 07-24
feat: 채널 프로비저닝 시 담당자 Mattermost 계정 자동 생성 및 채널 초대
기관의 신청기관/문정원/아이티앤/변호사 담당자가 채널 생성(또는 재확인) 시
이메일 기준으로 Mattermost 계정을 찾거나 새로 만들고, 문정원/법률검토 채널에
알맞게 추가하도록 ChannelProvisionService.ensureMembers 훅을 추가했다.
계정 생성 실패(아이디 중복 등)와 채널 추가 실패는 담당자별로 격리되어
provision 결과를 뒤집지 않고 메시지에만 남는다.

Co-Authored-By: ITN Dev 
@4c35bca967c4c2b4698f744142e7a39b903187c9
src/main/java/kr/itn/itnhub/config/MattermostProperties.java
--- src/main/java/kr/itn/itnhub/config/MattermostProperties.java
+++ src/main/java/kr/itn/itnhub/config/MattermostProperties.java
@@ -10,5 +10,6 @@
         String teamId,
         @DefaultValue("문정원") String channelNameMj,
         @DefaultValue("법률검토") String channelNameLaw,
-        @DefaultValue("itn-hub") String teamName) {
+        @DefaultValue("itn-hub") String teamName,
+        @DefaultValue("test1234!") String defaultUserPassword) {
 }
src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
@@ -39,4 +39,16 @@
     List<PostView> getPinnedPosts(String channelId);
 
     void updateChannelHeader(String channelId, String header);
+
+    /** 이메일로 사용자를 찾는다. 없으면 빈 값이다. */
+    Optional<String> findUserIdByEmail(String email);
+
+    /** 사용자를 새로 만들고 user id를 돌려준다. */
+    String createUser(String email, String username, String password);
+
+    /** 사용자를 (설정된) 팀에 추가한다. 채널 추가 전에 반드시 선행되어야 한다. */
+    void addUserToTeam(String userId);
+
+    /** 사용자를 채널에 추가한다. 이미 멤버여도 안전하다(idempotent). */
+    void addUserToChannel(String channelId, String userId);
 }
src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
@@ -482,6 +482,97 @@
         });
     }
 
+    @Override
+    public Optional<String> findUserIdByEmail(String email) {
+        try {
+            JsonNode body = rest.get()
+                    .uri("/api/v4/users/email/{email}", email)
+                    .retrieve()
+                    .onStatus(status -> status.value() == 404, (req, res) -> {
+                        throw new NotFound();
+                    })
+                    .body(JsonNode.class);
+
+            return idOf(body);
+        } catch (NotFound e) {
+            return Optional.empty();
+        } catch (RestClientException e) {
+            throw new MattermostException("사용자 조회 실패: " + email, e);
+        }
+    }
+
+    @Override
+    public String createUser(String email, String username, String password) {
+        Map<String, String> body = new LinkedHashMap<>();
+        body.put("email", email);
+        body.put("username", username);
+        body.put("password", password);
+
+        try {
+            JsonNode created = rest.post()
+                    .uri("/api/v4/users")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(body)
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            return idOf(created).orElseThrow(() ->
+                    new MattermostException("사용자 생성 응답에 id가 없습니다: " + email));
+        } catch (RestClientException e) {
+            throw new MattermostException("사용자 생성 실패: " + email + " - " + errorBody(e), e);
+        }
+    }
+
+    /**
+     * 에러 응답 본문을 그대로 메시지에 담는다. 사용자 생성 실패 시 호출자(ChannelProvisionService)가
+     * {@code username_exists} 등 Mattermost 오류 코드로 재시도 여부를 판단해야 하는데,
+     * 기본 RestClientException 메시지에는 응답 본문이 실리지 않기 때문이다.
+     */
+    private String errorBody(RestClientException e) {
+        if (e instanceof org.springframework.web.client.RestClientResponseException rse) {
+            String bodyText = rse.getResponseBodyAsString();
+            if (bodyText != null && !bodyText.isBlank()) {
+                return bodyText;
+            }
+        }
+        return e.getMessage();
+    }
+
+    @Override
+    public void addUserToTeam(String userId) {
+        Map<String, String> body = new LinkedHashMap<>();
+        body.put("team_id", teamId);
+        body.put("user_id", userId);
+
+        try {
+            rest.post()
+                    .uri("/api/v4/teams/{teamId}/members", teamId)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(body)
+                    .retrieve()
+                    .toBodilessEntity();
+        } catch (RestClientException e) {
+            throw new MattermostException("팀 추가 실패: " + userId, e);
+        }
+    }
+
+    @Override
+    public void addUserToChannel(String channelId, String userId) {
+        Map<String, String> body = new LinkedHashMap<>();
+        body.put("user_id", userId);
+
+        try {
+            rest.post()
+                    .uri("/api/v4/channels/{channelId}/members", channelId)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(body)
+                    .retrieve()
+                    .toBodilessEntity();
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 멤버 추가 실패: " + channelId, e);
+        }
+    }
+
     private Optional<String> idOf(JsonNode node) {
         if (node == null) {
             return Optional.empty();
src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
@@ -11,7 +11,10 @@
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 
 /**
@@ -70,10 +73,144 @@
 
         maybeStartStage(org.getId());
 
+        ensureMembers(org.getId(), messages);
+
         return new ProvisionResult(mj, law, messages.toString().trim());
     }
 
     /**
+     * 채널에 배정된 담당자들의 Mattermost 계정을 보장하고 알맞은 채널에 추가한다. VBA에는
+     * 없던 신규 기능으로, 채널 존재 여부와 무관하게 (CACHED 포함) 매번 실행되어 멤버십
+     * 드리프트를 치유한다 - findUserIdByEmail이 계정 존재를 먼저 확인하고 채널 추가도
+     * 멱등이라 재실행해도 안전하다.
+     *
+     * <p>이 훅도 postIntro와 같은 원칙을 따른다: 담당자 한 명(또는 채널 추가 한 건)의 실패가
+     * 나머지 처리를 막지 않고, provision() 결과(CREATED/RECOVERED/CACHED/FAILED)도 뒤집지
+     * 않는다 - 실패는 messages에만 남는다.</p>
+     */
+    private void ensureMembers(Long orgId, StringBuilder messages) {
+        Organization org = mapper.findById(orgId);
+        if (org == null) {
+            return;
+        }
+
+        Map<String, List<Contact>> channelContacts = new LinkedHashMap<>();
+        addChannelContacts(channelContacts, org.getChannelIdMj(),
+                org.getApplicant(), org.getMj(), org.getItn());
+        addChannelContacts(channelContacts, org.getChannelIdLaw(),
+                org.getLawyer(), org.getMj(), org.getItn());
+
+        // 이메일(대소문자 무시) 기준으로 담당자를 중복 없이 모은다 - 같은 사람이 양쪽
+        // 채널(예: 문정원 담당자)에 동시에 배정될 수 있으므로 계정 조회/생성은 한 번만 한다.
+        Map<String, Contact> distinctContacts = new LinkedHashMap<>();
+        for (List<Contact> contacts : channelContacts.values()) {
+            for (Contact contact : contacts) {
+                distinctContacts.putIfAbsent(contact.getEmail().toLowerCase(), contact);
+            }
+        }
+
+        Map<String, String> resolvedUserIds = new LinkedHashMap<>();
+        for (Contact contact : distinctContacts.values()) {
+            try {
+                resolvedUserIds.put(contact.getEmail().toLowerCase(), resolveUser(contact));
+            } catch (RuntimeException e) {
+                log.warn("멤버 계정 준비 실패 org={} contact={}", org.getOrgName(), contact.getEmail(), e);
+                appendMemberFailure(messages, contact, e);
+            }
+        }
+
+        for (Map.Entry<String, List<Contact>> entry : channelContacts.entrySet()) {
+            String channelId = entry.getKey();
+            for (Contact contact : entry.getValue()) {
+                String userId = resolvedUserIds.get(contact.getEmail().toLowerCase());
+                if (userId == null) {
+                    // 계정 준비 단계에서 이미 실패해 메시지에 남겼다 - 채널 추가는 시도하지 않는다.
+                    continue;
+                }
+                try {
+                    mattermost.addUserToChannel(channelId, userId);
+                } catch (RuntimeException e) {
+                    log.warn("채널 멤버 추가 실패 org={} channel={} contact={}",
+                            org.getOrgName(), channelId, contact.getEmail(), e);
+                    appendMemberFailure(messages, contact, e);
+                }
+            }
+        }
+    }
+
+    /** null 계약(계약자가 없거나 이메일이 비어 있으면)는 후보에서 제외하고, 채널 ID가 빈칸이면 그 채널 자체를 건너뛴다. */
+    private void addChannelContacts(Map<String, List<Contact>> channelContacts,
+                                    String channelId, Contact... candidates) {
+        if (!filled(channelId)) {
+            return;
+        }
+        List<Contact> contacts = new ArrayList<>();
+        for (Contact candidate : candidates) {
+            if (candidate != null && filled(candidate.getEmail())) {
+                contacts.add(candidate);
+            }
+        }
+        channelContacts.put(channelId, contacts);
+    }
+
+    /**
+     * 이메일로 기존 계정을 찾고, 없으면 새로 만든 뒤 팀에 추가한다. 계정명이 이미 쓰이고
+     * 있으면({@code username_exists}) 로컬파트에 도메인 첫 라벨을 덧붙여 한 번만 재시도한다.
+     */
+    private String resolveUser(Contact contact) {
+        String email = contact.getEmail();
+
+        String userId = mattermost.findUserIdByEmail(email).orElse(null);
+        if (userId == null) {
+            String username = usernameFromEmail(email);
+            try {
+                userId = mattermost.createUser(email, username, props.defaultUserPassword());
+            } catch (RuntimeException e) {
+                if (e.getMessage() != null && e.getMessage().contains("username_exists")) {
+                    userId = mattermost.createUser(email, retryUsernameFromEmail(email),
+                            props.defaultUserPassword());
+                } else {
+                    throw e;
+                }
+            }
+        }
+
+        mattermost.addUserToTeam(userId);
+        return userId;
+    }
+
+    private void appendMemberFailure(StringBuilder messages, Contact contact, RuntimeException e) {
+        messages.append("[멤버 초대] ").append(orDash(contact.getName())).append(": ")
+                .append(e.getMessage()).append(System.lineSeparator());
+    }
+
+    private static String localPart(String email) {
+        int at = email.indexOf('@');
+        return at >= 0 ? email.substring(0, at) : email;
+    }
+
+    private static String domainPart(String email) {
+        int at = email.indexOf('@');
+        return at >= 0 ? email.substring(at + 1) : "";
+    }
+
+    /** [a-z0-9._-] 밖의 문자는 전부 '-'로 치환한다. */
+    private static String sanitizeUsernameSegment(String raw) {
+        return raw.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
+    }
+
+    private static String usernameFromEmail(String email) {
+        return sanitizeUsernameSegment(localPart(email));
+    }
+
+    private static String retryUsernameFromEmail(String email) {
+        String domain = domainPart(email);
+        int dot = domain.indexOf('.');
+        String firstLabel = dot >= 0 ? domain.substring(0, dot) : domain;
+        return usernameFromEmail(email) + "." + sanitizeUsernameSegment(firstLabel);
+    }
+
+    /**
      * 채널 2개가 이번 호출로 처음 모두 갖춰지고 아직 어떤 단계도 시작되지 않았다면
      * 진행단계를 1(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다
      * (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다).
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -34,6 +34,7 @@
   token: ${MATTERMOST_TOKEN}
   team-id: ${MATTERMOST_TEAM_ID}
   team-name: ${MATTERMOST_TEAM_NAME:itn-hub}
+  default-user-password: ${MATTERMOST_DEFAULT_USER_PASSWORD:test1234!}
 
 app:
   admin:
src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
@@ -35,7 +35,8 @@
                 "team123",
                 "문정원",
                 "법률검토",
-                "itn-hub");
+                "itn-hub",
+                "test1234!");
 
         // 이 HTTP/1.1 고정은 테스트 하네스(WireMock/Jetty)만의 제약이다: JDK 21 HttpClient의
         // 기본 h2c 업그레이드 협상이 WireMock 상대로 POST 요청에서 간헐적으로 RST_STREAM을
@@ -469,4 +470,96 @@
                 .withRequestBody(matchingJsonPath("$.header",
                         equalTo("📢 공지: 안녕하세요 — [자세히](http://x/pl/p1)"))));
     }
+
+    @Test
+    void 이메일로_사용자를_찾으면_id를_돌려준다() {
+        // RestClient는 URI 변수를 인코딩해서 채우므로 "@"는 "%40"으로 전송된다.
+        server.stubFor(get(urlEqualTo("/api/v4/users/email/hong%40x.com"))
+                .willReturn(okJson("{\"id\":\"user1\",\"email\":\"hong@x.com\"}")));
+
+        assertThat(client.findUserIdByEmail("hong@x.com")).contains("user1");
+    }
+
+    @Test
+    void 이메일로_사용자가_없으면_빈값이다() {
+        server.stubFor(get(urlEqualTo("/api/v4/users/email/none%40x.com"))
+                .willReturn(aResponse().withStatus(404)));
+
+        assertThat(client.findUserIdByEmail("none@x.com")).isEmpty();
+    }
+
+    @Test
+    void 사용자를_생성하면_id를_돌려주고_요청_본문을_올바르게_보낸다() {
+        server.stubFor(post(urlEqualTo("/api/v4/users"))
+                .willReturn(okJson("{\"id\":\"newuser\"}")));
+
+        String id = client.createUser("hong@x.com", "hong", "test1234!");
+
+        assertThat(id).isEqualTo("newuser");
+        server.verify(postRequestedFor(urlEqualTo("/api/v4/users"))
+                .withRequestBody(matchingJsonPath("$.email", equalTo("hong@x.com")))
+                .withRequestBody(matchingJsonPath("$.username", equalTo("hong")))
+                .withRequestBody(matchingJsonPath("$.password", equalTo("test1234!"))));
+    }
+
+    @Test
+    void 사용자_생성이_서버_오류면_MattermostException을_던진다() {
+        server.stubFor(post(urlEqualTo("/api/v4/users"))
+                .willReturn(aResponse().withStatus(500).withBody("boom")));
+
+        assertThatThrownBy(() -> client.createUser("hong@x.com", "hong", "pw"))
+                .isInstanceOf(MattermostException.class);
+    }
+
+    @Test
+    void 사용자_생성이_아이디중복이면_응답본문이_예외메시지에_담긴다() {
+        server.stubFor(post(urlEqualTo("/api/v4/users"))
+                .willReturn(aResponse().withStatus(400)
+                        .withBody("{\"id\":\"app.user.save.username_exists.app_error\"}")));
+
+        assertThatThrownBy(() -> client.createUser("hong@x.com", "hong", "pw"))
+                .isInstanceOf(MattermostException.class)
+                .hasMessageContaining("username_exists");
+    }
+
+    @Test
+    void 사용자를_팀에_추가한다() {
+        server.stubFor(post(urlEqualTo("/api/v4/teams/team123/members"))
+                .willReturn(okJson("{\"team_id\":\"team123\",\"user_id\":\"user1\"}")));
+
+        client.addUserToTeam("user1");
+
+        server.verify(postRequestedFor(urlEqualTo("/api/v4/teams/team123/members"))
+                .withRequestBody(matchingJsonPath("$.team_id", equalTo("team123")))
+                .withRequestBody(matchingJsonPath("$.user_id", equalTo("user1"))));
+    }
+
+    @Test
+    void 팀_추가가_서버_오류면_MattermostException을_던진다() {
+        server.stubFor(post(urlEqualTo("/api/v4/teams/team123/members"))
+                .willReturn(aResponse().withStatus(500).withBody("boom")));
+
+        assertThatThrownBy(() -> client.addUserToTeam("user1"))
+                .isInstanceOf(MattermostException.class);
+    }
+
+    @Test
+    void 사용자를_채널에_추가한다() {
+        server.stubFor(post(urlEqualTo("/api/v4/channels/chan001mj/members"))
+                .willReturn(okJson("{\"channel_id\":\"chan001mj\",\"user_id\":\"user1\"}")));
+
+        client.addUserToChannel("chan001mj", "user1");
+
+        server.verify(postRequestedFor(urlEqualTo("/api/v4/channels/chan001mj/members"))
+                .withRequestBody(matchingJsonPath("$.user_id", equalTo("user1"))));
+    }
+
+    @Test
+    void 채널_추가가_서버_오류면_MattermostException을_던진다() {
+        server.stubFor(post(urlEqualTo("/api/v4/channels/chan001mj/members"))
+                .willReturn(aResponse().withStatus(500).withBody("boom")));
+
+        assertThatThrownBy(() -> client.addUserToChannel("chan001mj", "user1"))
+                .isInstanceOf(MattermostException.class);
+    }
 }
src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
@@ -71,6 +71,16 @@
         return contact.getId();
     }
 
+    /** 멤버 초대 테스트용: 이메일이 채워진 담당자 행을 만들고 id를 돌려준다. */
+    private Long createContact(String category, String name, String email) {
+        Contact contact = new Contact();
+        contact.setCategory(category);
+        contact.setName(name);
+        contact.setEmail(email);
+        contactMapper.insert(contact);
+        return contact.getId();
+    }
+
     @Test
     void 채널이_없으면_두_개를_생성하고_ID를_저장한다() {
         when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
@@ -404,4 +414,154 @@
         assertThat(result.message()).contains("안내글 등록에 실패");
         verify(mattermost, times(2)).createPost(anyString(), anyString(), eq(List.of()));
     }
+
+    @Test
+    void 배정된_담당자들의_Mattermost_계정을_보장하고_알맞은_채널에_추가한다() {
+        Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
+        Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
+        // 신청기관 담당자(setUp의 "송민지")는 이메일이 없으므로 새로 이메일이 있는 담당자로 교체한다.
+        Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
+        mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null);
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
+
+        when(mattermost.findUserIdByEmail("a@x.com")).thenReturn(Optional.empty());
+        when(mattermost.findUserIdByEmail("m@y.com")).thenReturn(Optional.empty());
+        when(mattermost.findUserIdByEmail("l@z.com")).thenReturn(Optional.empty());
+        when(mattermost.createUser(eq("a@x.com"), eq("a"), eq("test1234!"))).thenReturn("uid-a");
+        when(mattermost.createUser(eq("m@y.com"), eq("m"), eq("test1234!"))).thenReturn("uid-m");
+        when(mattermost.createUser(eq("l@z.com"), eq("l"), eq("test1234!"))).thenReturn("uid-l");
+
+        service.provision(orgId);
+
+        verify(mattermost).findUserIdByEmail("a@x.com");
+        verify(mattermost).findUserIdByEmail("m@y.com");
+        verify(mattermost).findUserIdByEmail("l@z.com");
+        verify(mattermost).createUser("a@x.com", "a", "test1234!");
+        verify(mattermost).createUser("m@y.com", "m", "test1234!");
+        verify(mattermost).createUser("l@z.com", "l", "test1234!");
+        verify(mattermost).addUserToTeam("uid-a");
+        verify(mattermost).addUserToTeam("uid-m");
+        verify(mattermost).addUserToTeam("uid-l");
+        verify(mattermost, times(3)).addUserToTeam(anyString());
+
+        verify(mattermost).addUserToChannel("chan-mj", "uid-a");
+        verify(mattermost).addUserToChannel("chan-mj", "uid-m");
+        verify(mattermost).addUserToChannel("chan-law", "uid-l");
+        verify(mattermost).addUserToChannel("chan-law", "uid-m");
+        verify(mattermost, times(4)).addUserToChannel(anyString(), anyString());
+    }
+
+    @Test
+    void 이미_존재하는_사용자는_생성하지_않고_팀_채널_추가만_한다() {
+        Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
+        Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
+        Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
+        mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null);
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
+
+        when(mattermost.findUserIdByEmail("a@x.com")).thenReturn(Optional.of("existing-a"));
+        when(mattermost.findUserIdByEmail("m@y.com")).thenReturn(Optional.of("existing-m"));
+        when(mattermost.findUserIdByEmail("l@z.com")).thenReturn(Optional.of("existing-l"));
+
+        service.provision(orgId);
+
+        verify(mattermost, never()).createUser(anyString(), anyString(), anyString());
+        verify(mattermost).addUserToTeam("existing-a");
+        verify(mattermost).addUserToTeam("existing-m");
+        verify(mattermost).addUserToTeam("existing-l");
+        verify(mattermost).addUserToChannel("chan-mj", "existing-a");
+        verify(mattermost).addUserToChannel("chan-mj", "existing-m");
+        verify(mattermost).addUserToChannel("chan-law", "existing-l");
+        verify(mattermost).addUserToChannel("chan-law", "existing-m");
+    }
+
+    @Test
+    void 계정명이_중복되면_로컬파트_점_도메인첫라벨로_한번만_재시도한다() {
+        Long applicantContactId = createContact("APPLICANT", "신청담당자", "hong@naver.com");
+        mapper.updateAssignments(orgId, applicantContactId, null, null, null, null);
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
+
+        when(mattermost.findUserIdByEmail("hong@naver.com")).thenReturn(Optional.empty());
+        when(mattermost.createUser(eq("hong@naver.com"), eq("hong"), anyString()))
+                .thenThrow(new MattermostException(
+                        "사용자 생성 실패: hong@naver.com - {\"id\":\"app.user.save.username_exists.app_error\"}"));
+        when(mattermost.createUser(eq("hong@naver.com"), eq("hong.naver"), anyString()))
+                .thenReturn("uid-hong");
+
+        service.provision(orgId);
+
+        verify(mattermost).createUser("hong@naver.com", "hong", "test1234!");
+        verify(mattermost).createUser("hong@naver.com", "hong.naver", "test1234!");
+        verify(mattermost, times(2)).createUser(eq("hong@naver.com"), anyString(), anyString());
+        verify(mattermost).addUserToTeam("uid-hong");
+        verify(mattermost).addUserToChannel("chan-mj", "uid-hong");
+    }
+
+    @Test
+    void 담당자_한명의_초대_실패는_다른_담당자_처리를_막지_않는다() {
+        Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
+        Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
+        Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
+        mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null);
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
+
+        when(mattermost.findUserIdByEmail("a@x.com"))
+                .thenThrow(new MattermostException("사용자 조회 실패: a@x.com"));
+        when(mattermost.findUserIdByEmail("m@y.com")).thenReturn(Optional.empty());
+        when(mattermost.findUserIdByEmail("l@z.com")).thenReturn(Optional.empty());
+        when(mattermost.createUser(eq("m@y.com"), eq("m"), anyString())).thenReturn("uid-m");
+        when(mattermost.createUser(eq("l@z.com"), eq("l"), anyString())).thenReturn("uid-l");
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.CREATED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED);
+        assertThat(result.message()).contains("[멤버 초대]").contains("신청담당자");
+
+        verify(mattermost, never()).addUserToChannel(anyString(), eq("uid-a"));
+        verify(mattermost).addUserToChannel("chan-mj", "uid-m");
+        verify(mattermost).addUserToChannel("chan-law", "uid-l");
+        verify(mattermost).addUserToChannel("chan-law", "uid-m");
+    }
+
+    @Test
+    void 채널_생성이_모두_실패하면_멤버_초대도_시도되지_않는다() {
+        Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
+        Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
+        Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
+        mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null);
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString()))
+                .thenThrow(new MattermostException("문정원 서버 오류"));
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenThrow(new MattermostException("법률검토 서버 오류"));
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.FAILED);
+
+        verify(mattermost, never()).findUserIdByEmail(anyString());
+        verify(mattermost, never()).createUser(anyString(), anyString(), anyString());
+        verify(mattermost, never()).addUserToTeam(anyString());
+        verify(mattermost, never()).addUserToChannel(anyString(), anyString());
+    }
 }
Add a comment
List