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
... | ... | @@ -10,5 +10,6 @@ |
| 10 | 10 |
String teamId, |
| 11 | 11 |
@DefaultValue("문정원") String channelNameMj,
|
| 12 | 12 |
@DefaultValue("법률검토") String channelNameLaw,
|
| 13 |
- @DefaultValue("itn-hub") String teamName) {
|
|
| 13 |
+ @DefaultValue("itn-hub") String teamName,
|
|
| 14 |
+ @DefaultValue("test1234!") String defaultUserPassword) {
|
|
| 14 | 15 |
} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
... | ... | @@ -39,4 +39,16 @@ |
| 39 | 39 |
List<PostView> getPinnedPosts(String channelId); |
| 40 | 40 |
|
| 41 | 41 |
void updateChannelHeader(String channelId, String header); |
| 42 |
+ |
|
| 43 |
+ /** 이메일로 사용자를 찾는다. 없으면 빈 값이다. */ |
|
| 44 |
+ Optional<String> findUserIdByEmail(String email); |
|
| 45 |
+ |
|
| 46 |
+ /** 사용자를 새로 만들고 user id를 돌려준다. */ |
|
| 47 |
+ String createUser(String email, String username, String password); |
|
| 48 |
+ |
|
| 49 |
+ /** 사용자를 (설정된) 팀에 추가한다. 채널 추가 전에 반드시 선행되어야 한다. */ |
|
| 50 |
+ void addUserToTeam(String userId); |
|
| 51 |
+ |
|
| 52 |
+ /** 사용자를 채널에 추가한다. 이미 멤버여도 안전하다(idempotent). */ |
|
| 53 |
+ void addUserToChannel(String channelId, String userId); |
|
| 42 | 54 |
} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -482,6 +482,97 @@ |
| 482 | 482 |
}); |
| 483 | 483 |
} |
| 484 | 484 |
|
| 485 |
+ @Override |
|
| 486 |
+ public Optional<String> findUserIdByEmail(String email) {
|
|
| 487 |
+ try {
|
|
| 488 |
+ JsonNode body = rest.get() |
|
| 489 |
+ .uri("/api/v4/users/email/{email}", email)
|
|
| 490 |
+ .retrieve() |
|
| 491 |
+ .onStatus(status -> status.value() == 404, (req, res) -> {
|
|
| 492 |
+ throw new NotFound(); |
|
| 493 |
+ }) |
|
| 494 |
+ .body(JsonNode.class); |
|
| 495 |
+ |
|
| 496 |
+ return idOf(body); |
|
| 497 |
+ } catch (NotFound e) {
|
|
| 498 |
+ return Optional.empty(); |
|
| 499 |
+ } catch (RestClientException e) {
|
|
| 500 |
+ throw new MattermostException("사용자 조회 실패: " + email, e);
|
|
| 501 |
+ } |
|
| 502 |
+ } |
|
| 503 |
+ |
|
| 504 |
+ @Override |
|
| 505 |
+ public String createUser(String email, String username, String password) {
|
|
| 506 |
+ Map<String, String> body = new LinkedHashMap<>(); |
|
| 507 |
+ body.put("email", email);
|
|
| 508 |
+ body.put("username", username);
|
|
| 509 |
+ body.put("password", password);
|
|
| 510 |
+ |
|
| 511 |
+ try {
|
|
| 512 |
+ JsonNode created = rest.post() |
|
| 513 |
+ .uri("/api/v4/users")
|
|
| 514 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 515 |
+ .body(body) |
|
| 516 |
+ .retrieve() |
|
| 517 |
+ .body(JsonNode.class); |
|
| 518 |
+ |
|
| 519 |
+ return idOf(created).orElseThrow(() -> |
|
| 520 |
+ new MattermostException("사용자 생성 응답에 id가 없습니다: " + email));
|
|
| 521 |
+ } catch (RestClientException e) {
|
|
| 522 |
+ throw new MattermostException("사용자 생성 실패: " + email + " - " + errorBody(e), e);
|
|
| 523 |
+ } |
|
| 524 |
+ } |
|
| 525 |
+ |
|
| 526 |
+ /** |
|
| 527 |
+ * 에러 응답 본문을 그대로 메시지에 담는다. 사용자 생성 실패 시 호출자(ChannelProvisionService)가 |
|
| 528 |
+ * {@code username_exists} 등 Mattermost 오류 코드로 재시도 여부를 판단해야 하는데,
|
|
| 529 |
+ * 기본 RestClientException 메시지에는 응답 본문이 실리지 않기 때문이다. |
|
| 530 |
+ */ |
|
| 531 |
+ private String errorBody(RestClientException e) {
|
|
| 532 |
+ if (e instanceof org.springframework.web.client.RestClientResponseException rse) {
|
|
| 533 |
+ String bodyText = rse.getResponseBodyAsString(); |
|
| 534 |
+ if (bodyText != null && !bodyText.isBlank()) {
|
|
| 535 |
+ return bodyText; |
|
| 536 |
+ } |
|
| 537 |
+ } |
|
| 538 |
+ return e.getMessage(); |
|
| 539 |
+ } |
|
| 540 |
+ |
|
| 541 |
+ @Override |
|
| 542 |
+ public void addUserToTeam(String userId) {
|
|
| 543 |
+ Map<String, String> body = new LinkedHashMap<>(); |
|
| 544 |
+ body.put("team_id", teamId);
|
|
| 545 |
+ body.put("user_id", userId);
|
|
| 546 |
+ |
|
| 547 |
+ try {
|
|
| 548 |
+ rest.post() |
|
| 549 |
+ .uri("/api/v4/teams/{teamId}/members", teamId)
|
|
| 550 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 551 |
+ .body(body) |
|
| 552 |
+ .retrieve() |
|
| 553 |
+ .toBodilessEntity(); |
|
| 554 |
+ } catch (RestClientException e) {
|
|
| 555 |
+ throw new MattermostException("팀 추가 실패: " + userId, e);
|
|
| 556 |
+ } |
|
| 557 |
+ } |
|
| 558 |
+ |
|
| 559 |
+ @Override |
|
| 560 |
+ public void addUserToChannel(String channelId, String userId) {
|
|
| 561 |
+ Map<String, String> body = new LinkedHashMap<>(); |
|
| 562 |
+ body.put("user_id", userId);
|
|
| 563 |
+ |
|
| 564 |
+ try {
|
|
| 565 |
+ rest.post() |
|
| 566 |
+ .uri("/api/v4/channels/{channelId}/members", channelId)
|
|
| 567 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 568 |
+ .body(body) |
|
| 569 |
+ .retrieve() |
|
| 570 |
+ .toBodilessEntity(); |
|
| 571 |
+ } catch (RestClientException e) {
|
|
| 572 |
+ throw new MattermostException("채널 멤버 추가 실패: " + channelId, e);
|
|
| 573 |
+ } |
|
| 574 |
+ } |
|
| 575 |
+ |
|
| 485 | 576 |
private Optional<String> idOf(JsonNode node) {
|
| 486 | 577 |
if (node == null) {
|
| 487 | 578 |
return Optional.empty(); |
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
... | ... | @@ -11,7 +11,10 @@ |
| 11 | 11 |
import org.slf4j.LoggerFactory; |
| 12 | 12 |
import org.springframework.stereotype.Service; |
| 13 | 13 |
|
| 14 |
+import java.util.ArrayList; |
|
| 15 |
+import java.util.LinkedHashMap; |
|
| 14 | 16 |
import java.util.List; |
| 17 |
+import java.util.Map; |
|
| 15 | 18 |
import java.util.Optional; |
| 16 | 19 |
|
| 17 | 20 |
/** |
... | ... | @@ -70,10 +73,144 @@ |
| 70 | 73 |
|
| 71 | 74 |
maybeStartStage(org.getId()); |
| 72 | 75 |
|
| 76 |
+ ensureMembers(org.getId(), messages); |
|
| 77 |
+ |
|
| 73 | 78 |
return new ProvisionResult(mj, law, messages.toString().trim()); |
| 74 | 79 |
} |
| 75 | 80 |
|
| 76 | 81 |
/** |
| 82 |
+ * 채널에 배정된 담당자들의 Mattermost 계정을 보장하고 알맞은 채널에 추가한다. VBA에는 |
|
| 83 |
+ * 없던 신규 기능으로, 채널 존재 여부와 무관하게 (CACHED 포함) 매번 실행되어 멤버십 |
|
| 84 |
+ * 드리프트를 치유한다 - findUserIdByEmail이 계정 존재를 먼저 확인하고 채널 추가도 |
|
| 85 |
+ * 멱등이라 재실행해도 안전하다. |
|
| 86 |
+ * |
|
| 87 |
+ * <p>이 훅도 postIntro와 같은 원칙을 따른다: 담당자 한 명(또는 채널 추가 한 건)의 실패가 |
|
| 88 |
+ * 나머지 처리를 막지 않고, provision() 결과(CREATED/RECOVERED/CACHED/FAILED)도 뒤집지 |
|
| 89 |
+ * 않는다 - 실패는 messages에만 남는다.</p> |
|
| 90 |
+ */ |
|
| 91 |
+ private void ensureMembers(Long orgId, StringBuilder messages) {
|
|
| 92 |
+ Organization org = mapper.findById(orgId); |
|
| 93 |
+ if (org == null) {
|
|
| 94 |
+ return; |
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ Map<String, List<Contact>> channelContacts = new LinkedHashMap<>(); |
|
| 98 |
+ addChannelContacts(channelContacts, org.getChannelIdMj(), |
|
| 99 |
+ org.getApplicant(), org.getMj(), org.getItn()); |
|
| 100 |
+ addChannelContacts(channelContacts, org.getChannelIdLaw(), |
|
| 101 |
+ org.getLawyer(), org.getMj(), org.getItn()); |
|
| 102 |
+ |
|
| 103 |
+ // 이메일(대소문자 무시) 기준으로 담당자를 중복 없이 모은다 - 같은 사람이 양쪽 |
|
| 104 |
+ // 채널(예: 문정원 담당자)에 동시에 배정될 수 있으므로 계정 조회/생성은 한 번만 한다. |
|
| 105 |
+ Map<String, Contact> distinctContacts = new LinkedHashMap<>(); |
|
| 106 |
+ for (List<Contact> contacts : channelContacts.values()) {
|
|
| 107 |
+ for (Contact contact : contacts) {
|
|
| 108 |
+ distinctContacts.putIfAbsent(contact.getEmail().toLowerCase(), contact); |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ Map<String, String> resolvedUserIds = new LinkedHashMap<>(); |
|
| 113 |
+ for (Contact contact : distinctContacts.values()) {
|
|
| 114 |
+ try {
|
|
| 115 |
+ resolvedUserIds.put(contact.getEmail().toLowerCase(), resolveUser(contact)); |
|
| 116 |
+ } catch (RuntimeException e) {
|
|
| 117 |
+ log.warn("멤버 계정 준비 실패 org={} contact={}", org.getOrgName(), contact.getEmail(), e);
|
|
| 118 |
+ appendMemberFailure(messages, contact, e); |
|
| 119 |
+ } |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ for (Map.Entry<String, List<Contact>> entry : channelContacts.entrySet()) {
|
|
| 123 |
+ String channelId = entry.getKey(); |
|
| 124 |
+ for (Contact contact : entry.getValue()) {
|
|
| 125 |
+ String userId = resolvedUserIds.get(contact.getEmail().toLowerCase()); |
|
| 126 |
+ if (userId == null) {
|
|
| 127 |
+ // 계정 준비 단계에서 이미 실패해 메시지에 남겼다 - 채널 추가는 시도하지 않는다. |
|
| 128 |
+ continue; |
|
| 129 |
+ } |
|
| 130 |
+ try {
|
|
| 131 |
+ mattermost.addUserToChannel(channelId, userId); |
|
| 132 |
+ } catch (RuntimeException e) {
|
|
| 133 |
+ log.warn("채널 멤버 추가 실패 org={} channel={} contact={}",
|
|
| 134 |
+ org.getOrgName(), channelId, contact.getEmail(), e); |
|
| 135 |
+ appendMemberFailure(messages, contact, e); |
|
| 136 |
+ } |
|
| 137 |
+ } |
|
| 138 |
+ } |
|
| 139 |
+ } |
|
| 140 |
+ |
|
| 141 |
+ /** null 계약(계약자가 없거나 이메일이 비어 있으면)는 후보에서 제외하고, 채널 ID가 빈칸이면 그 채널 자체를 건너뛴다. */ |
|
| 142 |
+ private void addChannelContacts(Map<String, List<Contact>> channelContacts, |
|
| 143 |
+ String channelId, Contact... candidates) {
|
|
| 144 |
+ if (!filled(channelId)) {
|
|
| 145 |
+ return; |
|
| 146 |
+ } |
|
| 147 |
+ List<Contact> contacts = new ArrayList<>(); |
|
| 148 |
+ for (Contact candidate : candidates) {
|
|
| 149 |
+ if (candidate != null && filled(candidate.getEmail())) {
|
|
| 150 |
+ contacts.add(candidate); |
|
| 151 |
+ } |
|
| 152 |
+ } |
|
| 153 |
+ channelContacts.put(channelId, contacts); |
|
| 154 |
+ } |
|
| 155 |
+ |
|
| 156 |
+ /** |
|
| 157 |
+ * 이메일로 기존 계정을 찾고, 없으면 새로 만든 뒤 팀에 추가한다. 계정명이 이미 쓰이고 |
|
| 158 |
+ * 있으면({@code username_exists}) 로컬파트에 도메인 첫 라벨을 덧붙여 한 번만 재시도한다.
|
|
| 159 |
+ */ |
|
| 160 |
+ private String resolveUser(Contact contact) {
|
|
| 161 |
+ String email = contact.getEmail(); |
|
| 162 |
+ |
|
| 163 |
+ String userId = mattermost.findUserIdByEmail(email).orElse(null); |
|
| 164 |
+ if (userId == null) {
|
|
| 165 |
+ String username = usernameFromEmail(email); |
|
| 166 |
+ try {
|
|
| 167 |
+ userId = mattermost.createUser(email, username, props.defaultUserPassword()); |
|
| 168 |
+ } catch (RuntimeException e) {
|
|
| 169 |
+ if (e.getMessage() != null && e.getMessage().contains("username_exists")) {
|
|
| 170 |
+ userId = mattermost.createUser(email, retryUsernameFromEmail(email), |
|
| 171 |
+ props.defaultUserPassword()); |
|
| 172 |
+ } else {
|
|
| 173 |
+ throw e; |
|
| 174 |
+ } |
|
| 175 |
+ } |
|
| 176 |
+ } |
|
| 177 |
+ |
|
| 178 |
+ mattermost.addUserToTeam(userId); |
|
| 179 |
+ return userId; |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ private void appendMemberFailure(StringBuilder messages, Contact contact, RuntimeException e) {
|
|
| 183 |
+ messages.append("[멤버 초대] ").append(orDash(contact.getName())).append(": ")
|
|
| 184 |
+ .append(e.getMessage()).append(System.lineSeparator()); |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ private static String localPart(String email) {
|
|
| 188 |
+ int at = email.indexOf('@');
|
|
| 189 |
+ return at >= 0 ? email.substring(0, at) : email; |
|
| 190 |
+ } |
|
| 191 |
+ |
|
| 192 |
+ private static String domainPart(String email) {
|
|
| 193 |
+ int at = email.indexOf('@');
|
|
| 194 |
+ return at >= 0 ? email.substring(at + 1) : ""; |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 197 |
+ /** [a-z0-9._-] 밖의 문자는 전부 '-'로 치환한다. */ |
|
| 198 |
+ private static String sanitizeUsernameSegment(String raw) {
|
|
| 199 |
+ return raw.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
|
|
| 200 |
+ } |
|
| 201 |
+ |
|
| 202 |
+ private static String usernameFromEmail(String email) {
|
|
| 203 |
+ return sanitizeUsernameSegment(localPart(email)); |
|
| 204 |
+ } |
|
| 205 |
+ |
|
| 206 |
+ private static String retryUsernameFromEmail(String email) {
|
|
| 207 |
+ String domain = domainPart(email); |
|
| 208 |
+ int dot = domain.indexOf('.');
|
|
| 209 |
+ String firstLabel = dot >= 0 ? domain.substring(0, dot) : domain; |
|
| 210 |
+ return usernameFromEmail(email) + "." + sanitizeUsernameSegment(firstLabel); |
|
| 211 |
+ } |
|
| 212 |
+ |
|
| 213 |
+ /** |
|
| 77 | 214 |
* 채널 2개가 이번 호출로 처음 모두 갖춰지고 아직 어떤 단계도 시작되지 않았다면 |
| 78 | 215 |
* 진행단계를 1(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다 |
| 79 | 216 |
* (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다). |
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -34,6 +34,7 @@ |
| 34 | 34 |
token: ${MATTERMOST_TOKEN}
|
| 35 | 35 |
team-id: ${MATTERMOST_TEAM_ID}
|
| 36 | 36 |
team-name: ${MATTERMOST_TEAM_NAME:itn-hub}
|
| 37 |
+ default-user-password: ${MATTERMOST_DEFAULT_USER_PASSWORD:test1234!}
|
|
| 37 | 38 |
|
| 38 | 39 |
app: |
| 39 | 40 |
admin: |
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
... | ... | @@ -35,7 +35,8 @@ |
| 35 | 35 |
"team123", |
| 36 | 36 |
"문정원", |
| 37 | 37 |
"법률검토", |
| 38 |
- "itn-hub"); |
|
| 38 |
+ "itn-hub", |
|
| 39 |
+ "test1234!"); |
|
| 39 | 40 |
|
| 40 | 41 |
// 이 HTTP/1.1 고정은 테스트 하네스(WireMock/Jetty)만의 제약이다: JDK 21 HttpClient의 |
| 41 | 42 |
// 기본 h2c 업그레이드 협상이 WireMock 상대로 POST 요청에서 간헐적으로 RST_STREAM을 |
... | ... | @@ -469,4 +470,96 @@ |
| 469 | 470 |
.withRequestBody(matchingJsonPath("$.header",
|
| 470 | 471 |
equalTo("📢 공지: 안녕하세요 — [자세히](http://x/pl/p1)"))));
|
| 471 | 472 |
} |
| 473 |
+ |
|
| 474 |
+ @Test |
|
| 475 |
+ void 이메일로_사용자를_찾으면_id를_돌려준다() {
|
|
| 476 |
+ // RestClient는 URI 변수를 인코딩해서 채우므로 "@"는 "%40"으로 전송된다. |
|
| 477 |
+ server.stubFor(get(urlEqualTo("/api/v4/users/email/hong%40x.com"))
|
|
| 478 |
+ .willReturn(okJson("{\"id\":\"user1\",\"email\":\"hong@x.com\"}")));
|
|
| 479 |
+ |
|
| 480 |
+ assertThat(client.findUserIdByEmail("hong@x.com")).contains("user1");
|
|
| 481 |
+ } |
|
| 482 |
+ |
|
| 483 |
+ @Test |
|
| 484 |
+ void 이메일로_사용자가_없으면_빈값이다() {
|
|
| 485 |
+ server.stubFor(get(urlEqualTo("/api/v4/users/email/none%40x.com"))
|
|
| 486 |
+ .willReturn(aResponse().withStatus(404))); |
|
| 487 |
+ |
|
| 488 |
+ assertThat(client.findUserIdByEmail("none@x.com")).isEmpty();
|
|
| 489 |
+ } |
|
| 490 |
+ |
|
| 491 |
+ @Test |
|
| 492 |
+ void 사용자를_생성하면_id를_돌려주고_요청_본문을_올바르게_보낸다() {
|
|
| 493 |
+ server.stubFor(post(urlEqualTo("/api/v4/users"))
|
|
| 494 |
+ .willReturn(okJson("{\"id\":\"newuser\"}")));
|
|
| 495 |
+ |
|
| 496 |
+ String id = client.createUser("hong@x.com", "hong", "test1234!");
|
|
| 497 |
+ |
|
| 498 |
+ assertThat(id).isEqualTo("newuser");
|
|
| 499 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/users"))
|
|
| 500 |
+ .withRequestBody(matchingJsonPath("$.email", equalTo("hong@x.com")))
|
|
| 501 |
+ .withRequestBody(matchingJsonPath("$.username", equalTo("hong")))
|
|
| 502 |
+ .withRequestBody(matchingJsonPath("$.password", equalTo("test1234!"))));
|
|
| 503 |
+ } |
|
| 504 |
+ |
|
| 505 |
+ @Test |
|
| 506 |
+ void 사용자_생성이_서버_오류면_MattermostException을_던진다() {
|
|
| 507 |
+ server.stubFor(post(urlEqualTo("/api/v4/users"))
|
|
| 508 |
+ .willReturn(aResponse().withStatus(500).withBody("boom")));
|
|
| 509 |
+ |
|
| 510 |
+ assertThatThrownBy(() -> client.createUser("hong@x.com", "hong", "pw"))
|
|
| 511 |
+ .isInstanceOf(MattermostException.class); |
|
| 512 |
+ } |
|
| 513 |
+ |
|
| 514 |
+ @Test |
|
| 515 |
+ void 사용자_생성이_아이디중복이면_응답본문이_예외메시지에_담긴다() {
|
|
| 516 |
+ server.stubFor(post(urlEqualTo("/api/v4/users"))
|
|
| 517 |
+ .willReturn(aResponse().withStatus(400) |
|
| 518 |
+ .withBody("{\"id\":\"app.user.save.username_exists.app_error\"}")));
|
|
| 519 |
+ |
|
| 520 |
+ assertThatThrownBy(() -> client.createUser("hong@x.com", "hong", "pw"))
|
|
| 521 |
+ .isInstanceOf(MattermostException.class) |
|
| 522 |
+ .hasMessageContaining("username_exists");
|
|
| 523 |
+ } |
|
| 524 |
+ |
|
| 525 |
+ @Test |
|
| 526 |
+ void 사용자를_팀에_추가한다() {
|
|
| 527 |
+ server.stubFor(post(urlEqualTo("/api/v4/teams/team123/members"))
|
|
| 528 |
+ .willReturn(okJson("{\"team_id\":\"team123\",\"user_id\":\"user1\"}")));
|
|
| 529 |
+ |
|
| 530 |
+ client.addUserToTeam("user1");
|
|
| 531 |
+ |
|
| 532 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/teams/team123/members"))
|
|
| 533 |
+ .withRequestBody(matchingJsonPath("$.team_id", equalTo("team123")))
|
|
| 534 |
+ .withRequestBody(matchingJsonPath("$.user_id", equalTo("user1"))));
|
|
| 535 |
+ } |
|
| 536 |
+ |
|
| 537 |
+ @Test |
|
| 538 |
+ void 팀_추가가_서버_오류면_MattermostException을_던진다() {
|
|
| 539 |
+ server.stubFor(post(urlEqualTo("/api/v4/teams/team123/members"))
|
|
| 540 |
+ .willReturn(aResponse().withStatus(500).withBody("boom")));
|
|
| 541 |
+ |
|
| 542 |
+ assertThatThrownBy(() -> client.addUserToTeam("user1"))
|
|
| 543 |
+ .isInstanceOf(MattermostException.class); |
|
| 544 |
+ } |
|
| 545 |
+ |
|
| 546 |
+ @Test |
|
| 547 |
+ void 사용자를_채널에_추가한다() {
|
|
| 548 |
+ server.stubFor(post(urlEqualTo("/api/v4/channels/chan001mj/members"))
|
|
| 549 |
+ .willReturn(okJson("{\"channel_id\":\"chan001mj\",\"user_id\":\"user1\"}")));
|
|
| 550 |
+ |
|
| 551 |
+ client.addUserToChannel("chan001mj", "user1");
|
|
| 552 |
+ |
|
| 553 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/channels/chan001mj/members"))
|
|
| 554 |
+ .withRequestBody(matchingJsonPath("$.user_id", equalTo("user1"))));
|
|
| 555 |
+ } |
|
| 556 |
+ |
|
| 557 |
+ @Test |
|
| 558 |
+ void 채널_추가가_서버_오류면_MattermostException을_던진다() {
|
|
| 559 |
+ server.stubFor(post(urlEqualTo("/api/v4/channels/chan001mj/members"))
|
|
| 560 |
+ .willReturn(aResponse().withStatus(500).withBody("boom")));
|
|
| 561 |
+ |
|
| 562 |
+ assertThatThrownBy(() -> client.addUserToChannel("chan001mj", "user1"))
|
|
| 563 |
+ .isInstanceOf(MattermostException.class); |
|
| 564 |
+ } |
|
| 472 | 565 |
} |
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
... | ... | @@ -71,6 +71,16 @@ |
| 71 | 71 |
return contact.getId(); |
| 72 | 72 |
} |
| 73 | 73 |
|
| 74 |
+ /** 멤버 초대 테스트용: 이메일이 채워진 담당자 행을 만들고 id를 돌려준다. */ |
|
| 75 |
+ private Long createContact(String category, String name, String email) {
|
|
| 76 |
+ Contact contact = new Contact(); |
|
| 77 |
+ contact.setCategory(category); |
|
| 78 |
+ contact.setName(name); |
|
| 79 |
+ contact.setEmail(email); |
|
| 80 |
+ contactMapper.insert(contact); |
|
| 81 |
+ return contact.getId(); |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 74 | 84 |
@Test |
| 75 | 85 |
void 채널이_없으면_두_개를_생성하고_ID를_저장한다() {
|
| 76 | 86 |
when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
... | ... | @@ -404,4 +414,154 @@ |
| 404 | 414 |
assertThat(result.message()).contains("안내글 등록에 실패");
|
| 405 | 415 |
verify(mattermost, times(2)).createPost(anyString(), anyString(), eq(List.of())); |
| 406 | 416 |
} |
| 417 |
+ |
|
| 418 |
+ @Test |
|
| 419 |
+ void 배정된_담당자들의_Mattermost_계정을_보장하고_알맞은_채널에_추가한다() {
|
|
| 420 |
+ Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
|
|
| 421 |
+ Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
|
|
| 422 |
+ // 신청기관 담당자(setUp의 "송민지")는 이메일이 없으므로 새로 이메일이 있는 담당자로 교체한다. |
|
| 423 |
+ Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
|
|
| 424 |
+ mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null); |
|
| 425 |
+ |
|
| 426 |
+ when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
|
| 427 |
+ when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); |
|
| 428 |
+ when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
|
|
| 429 |
+ when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
|
|
| 430 |
+ |
|
| 431 |
+ when(mattermost.findUserIdByEmail("a@x.com")).thenReturn(Optional.empty());
|
|
| 432 |
+ when(mattermost.findUserIdByEmail("m@y.com")).thenReturn(Optional.empty());
|
|
| 433 |
+ when(mattermost.findUserIdByEmail("l@z.com")).thenReturn(Optional.empty());
|
|
| 434 |
+ when(mattermost.createUser(eq("a@x.com"), eq("a"), eq("test1234!"))).thenReturn("uid-a");
|
|
| 435 |
+ when(mattermost.createUser(eq("m@y.com"), eq("m"), eq("test1234!"))).thenReturn("uid-m");
|
|
| 436 |
+ when(mattermost.createUser(eq("l@z.com"), eq("l"), eq("test1234!"))).thenReturn("uid-l");
|
|
| 437 |
+ |
|
| 438 |
+ service.provision(orgId); |
|
| 439 |
+ |
|
| 440 |
+ verify(mattermost).findUserIdByEmail("a@x.com");
|
|
| 441 |
+ verify(mattermost).findUserIdByEmail("m@y.com");
|
|
| 442 |
+ verify(mattermost).findUserIdByEmail("l@z.com");
|
|
| 443 |
+ verify(mattermost).createUser("a@x.com", "a", "test1234!");
|
|
| 444 |
+ verify(mattermost).createUser("m@y.com", "m", "test1234!");
|
|
| 445 |
+ verify(mattermost).createUser("l@z.com", "l", "test1234!");
|
|
| 446 |
+ verify(mattermost).addUserToTeam("uid-a");
|
|
| 447 |
+ verify(mattermost).addUserToTeam("uid-m");
|
|
| 448 |
+ verify(mattermost).addUserToTeam("uid-l");
|
|
| 449 |
+ verify(mattermost, times(3)).addUserToTeam(anyString()); |
|
| 450 |
+ |
|
| 451 |
+ verify(mattermost).addUserToChannel("chan-mj", "uid-a");
|
|
| 452 |
+ verify(mattermost).addUserToChannel("chan-mj", "uid-m");
|
|
| 453 |
+ verify(mattermost).addUserToChannel("chan-law", "uid-l");
|
|
| 454 |
+ verify(mattermost).addUserToChannel("chan-law", "uid-m");
|
|
| 455 |
+ verify(mattermost, times(4)).addUserToChannel(anyString(), anyString()); |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 458 |
+ @Test |
|
| 459 |
+ void 이미_존재하는_사용자는_생성하지_않고_팀_채널_추가만_한다() {
|
|
| 460 |
+ Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
|
|
| 461 |
+ Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
|
|
| 462 |
+ Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
|
|
| 463 |
+ mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null); |
|
| 464 |
+ |
|
| 465 |
+ when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
|
| 466 |
+ when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); |
|
| 467 |
+ when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
|
|
| 468 |
+ when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
|
|
| 469 |
+ |
|
| 470 |
+ when(mattermost.findUserIdByEmail("a@x.com")).thenReturn(Optional.of("existing-a"));
|
|
| 471 |
+ when(mattermost.findUserIdByEmail("m@y.com")).thenReturn(Optional.of("existing-m"));
|
|
| 472 |
+ when(mattermost.findUserIdByEmail("l@z.com")).thenReturn(Optional.of("existing-l"));
|
|
| 473 |
+ |
|
| 474 |
+ service.provision(orgId); |
|
| 475 |
+ |
|
| 476 |
+ verify(mattermost, never()).createUser(anyString(), anyString(), anyString()); |
|
| 477 |
+ verify(mattermost).addUserToTeam("existing-a");
|
|
| 478 |
+ verify(mattermost).addUserToTeam("existing-m");
|
|
| 479 |
+ verify(mattermost).addUserToTeam("existing-l");
|
|
| 480 |
+ verify(mattermost).addUserToChannel("chan-mj", "existing-a");
|
|
| 481 |
+ verify(mattermost).addUserToChannel("chan-mj", "existing-m");
|
|
| 482 |
+ verify(mattermost).addUserToChannel("chan-law", "existing-l");
|
|
| 483 |
+ verify(mattermost).addUserToChannel("chan-law", "existing-m");
|
|
| 484 |
+ } |
|
| 485 |
+ |
|
| 486 |
+ @Test |
|
| 487 |
+ void 계정명이_중복되면_로컬파트_점_도메인첫라벨로_한번만_재시도한다() {
|
|
| 488 |
+ Long applicantContactId = createContact("APPLICANT", "신청담당자", "hong@naver.com");
|
|
| 489 |
+ mapper.updateAssignments(orgId, applicantContactId, null, null, null, null); |
|
| 490 |
+ |
|
| 491 |
+ when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
|
| 492 |
+ when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); |
|
| 493 |
+ when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
|
|
| 494 |
+ when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
|
|
| 495 |
+ |
|
| 496 |
+ when(mattermost.findUserIdByEmail("hong@naver.com")).thenReturn(Optional.empty());
|
|
| 497 |
+ when(mattermost.createUser(eq("hong@naver.com"), eq("hong"), anyString()))
|
|
| 498 |
+ .thenThrow(new MattermostException( |
|
| 499 |
+ "사용자 생성 실패: hong@naver.com - {\"id\":\"app.user.save.username_exists.app_error\"}"));
|
|
| 500 |
+ when(mattermost.createUser(eq("hong@naver.com"), eq("hong.naver"), anyString()))
|
|
| 501 |
+ .thenReturn("uid-hong");
|
|
| 502 |
+ |
|
| 503 |
+ service.provision(orgId); |
|
| 504 |
+ |
|
| 505 |
+ verify(mattermost).createUser("hong@naver.com", "hong", "test1234!");
|
|
| 506 |
+ verify(mattermost).createUser("hong@naver.com", "hong.naver", "test1234!");
|
|
| 507 |
+ verify(mattermost, times(2)).createUser(eq("hong@naver.com"), anyString(), anyString());
|
|
| 508 |
+ verify(mattermost).addUserToTeam("uid-hong");
|
|
| 509 |
+ verify(mattermost).addUserToChannel("chan-mj", "uid-hong");
|
|
| 510 |
+ } |
|
| 511 |
+ |
|
| 512 |
+ @Test |
|
| 513 |
+ void 담당자_한명의_초대_실패는_다른_담당자_처리를_막지_않는다() {
|
|
| 514 |
+ Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
|
|
| 515 |
+ Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
|
|
| 516 |
+ Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
|
|
| 517 |
+ mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null); |
|
| 518 |
+ |
|
| 519 |
+ when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
|
| 520 |
+ when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); |
|
| 521 |
+ when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("chan-mj");
|
|
| 522 |
+ when(mattermost.createPrivateChannel(eq("org-001-law"), anyString())).thenReturn("chan-law");
|
|
| 523 |
+ |
|
| 524 |
+ when(mattermost.findUserIdByEmail("a@x.com"))
|
|
| 525 |
+ .thenThrow(new MattermostException("사용자 조회 실패: a@x.com"));
|
|
| 526 |
+ when(mattermost.findUserIdByEmail("m@y.com")).thenReturn(Optional.empty());
|
|
| 527 |
+ when(mattermost.findUserIdByEmail("l@z.com")).thenReturn(Optional.empty());
|
|
| 528 |
+ when(mattermost.createUser(eq("m@y.com"), eq("m"), anyString())).thenReturn("uid-m");
|
|
| 529 |
+ when(mattermost.createUser(eq("l@z.com"), eq("l"), anyString())).thenReturn("uid-l");
|
|
| 530 |
+ |
|
| 531 |
+ ProvisionResult result = service.provision(orgId); |
|
| 532 |
+ |
|
| 533 |
+ assertThat(result.mj()).isEqualTo(ProvisionOutcome.CREATED); |
|
| 534 |
+ assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED); |
|
| 535 |
+ assertThat(result.message()).contains("[멤버 초대]").contains("신청담당자");
|
|
| 536 |
+ |
|
| 537 |
+ verify(mattermost, never()).addUserToChannel(anyString(), eq("uid-a"));
|
|
| 538 |
+ verify(mattermost).addUserToChannel("chan-mj", "uid-m");
|
|
| 539 |
+ verify(mattermost).addUserToChannel("chan-law", "uid-l");
|
|
| 540 |
+ verify(mattermost).addUserToChannel("chan-law", "uid-m");
|
|
| 541 |
+ } |
|
| 542 |
+ |
|
| 543 |
+ @Test |
|
| 544 |
+ void 채널_생성이_모두_실패하면_멤버_초대도_시도되지_않는다() {
|
|
| 545 |
+ Long mjContactId = createContact("MJ", "문정원담당자", "m@y.com");
|
|
| 546 |
+ Long lawyerContactId = createContact("LAWYER", "변호사", "l@z.com");
|
|
| 547 |
+ Long applicantContactId = createContact("APPLICANT", "신청담당자", "a@x.com");
|
|
| 548 |
+ mapper.updateAssignments(orgId, applicantContactId, mjContactId, null, lawyerContactId, null); |
|
| 549 |
+ |
|
| 550 |
+ when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty()); |
|
| 551 |
+ when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty()); |
|
| 552 |
+ when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString()))
|
|
| 553 |
+ .thenThrow(new MattermostException("문정원 서버 오류"));
|
|
| 554 |
+ when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
|
|
| 555 |
+ .thenThrow(new MattermostException("법률검토 서버 오류"));
|
|
| 556 |
+ |
|
| 557 |
+ ProvisionResult result = service.provision(orgId); |
|
| 558 |
+ |
|
| 559 |
+ assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED); |
|
| 560 |
+ assertThat(result.law()).isEqualTo(ProvisionOutcome.FAILED); |
|
| 561 |
+ |
|
| 562 |
+ verify(mattermost, never()).findUserIdByEmail(anyString()); |
|
| 563 |
+ verify(mattermost, never()).createUser(anyString(), anyString(), anyString()); |
|
| 564 |
+ verify(mattermost, never()).addUserToTeam(anyString()); |
|
| 565 |
+ verify(mattermost, never()).addUserToChannel(anyString(), anyString()); |
|
| 566 |
+ } |
|
| 407 | 567 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?