package kr.itn.itnhub.mattermost; import com.github.tomakehurst.wiremock.WireMockServer; import kr.itn.itnhub.config.MattermostProperties; import kr.itn.itnhub.feed.FileRef; import kr.itn.itnhub.feed.FileView; import kr.itn.itnhub.feed.PostView; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; import java.net.http.HttpClient; import java.util.List; import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class MattermostRestClientTest { private WireMockServer server; private MattermostRestClient client; @BeforeEach void setUp() { server = new WireMockServer(options().dynamicPort()); server.start(); MattermostProperties props = new MattermostProperties( "http://localhost:" + server.port(), "test-token", "team123", "문정원", "법률검토", "itn-hub", "test1234!"); // 이 HTTP/1.1 고정은 테스트 하네스(WireMock/Jetty)만의 제약이다: JDK 21 HttpClient의 // 기본 h2c 업그레이드 협상이 WireMock 상대로 POST 요청에서 간헐적으로 RST_STREAM을 // 유발하기 때문에 여기서만 고정한다. 운영 환경은 HTTPS/ALPN으로 협상하므로 해당되지 않는다. HttpClient httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .build(); RestClient.Builder builder = RestClient.builder() .requestFactory(new JdkClientHttpRequestFactory(httpClient)); client = new MattermostRestClient(props, builder); } @AfterEach void tearDown() { server.stop(); } @Test void 내부명으로_채널을_찾으면_id를_돌려준다() { server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj")) .willReturn(okJson("{\"id\":\"chan001mj\",\"name\":\"org-001-mj\"}"))); assertThat(client.findChannelIdByInternalName("org-001-mj")) .contains("chan001mj"); } @Test void 내부명으로_채널이_없으면_빈값이다() { server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-999-mj")) .willReturn(aResponse().withStatus(404))); assertThat(client.findChannelIdByInternalName("org-999-mj")).isEmpty(); } @Test void 토큰을_Authorization_헤더로_보낸다() { server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj")) .willReturn(okJson("{\"id\":\"chan001mj\"}"))); client.findChannelIdByInternalName("org-001-mj"); server.verify(getRequestedFor(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj")) .withHeader("Authorization", equalTo("Bearer test-token"))); } @Test void 표시명으로_팀전체_채널목록에서_id를_찾는다() { server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels")) .withQueryParam("page", equalTo("0")) .withQueryParam("per_page", equalTo("200")) .willReturn(okJson(""" [ {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"}, {"id":"bbb","display_name":"001_국제방송교류재단 (법률검토)"} ] """))); assertThat(client.findChannelIdByDisplayName("001_국제방송교류재단 (법률검토)")) .contains("bbb"); } @Test void 표시명이_첫페이지에_없으면_빈값이다() { server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels")) .withQueryParam("page", equalTo("0")) .withQueryParam("per_page", equalTo("200")) .willReturn(okJson(""" [ {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"} ] """))); assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)")).isEmpty(); } @Test void 표시명_조회가_두번째_페이지에서_찾으면_id를_돌려준다() { server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels")) .withQueryParam("page", equalTo("0")) .withQueryParam("per_page", equalTo("200")) .willReturn(okJson(fullPageJson(200, "p0", null)))); server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels")) .withQueryParam("page", equalTo("1")) .withQueryParam("per_page", equalTo("200")) .willReturn(okJson(fullPageJson(5, "p1", "004_찾는기관 (법률검토)")))); assertThat(client.findChannelIdByDisplayName("004_찾는기관 (법률검토)")) .contains("p1-match"); } @Test void 표시명_조회가_두_페이지를_모두_소진하면_빈값이다() { server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels")) .withQueryParam("page", equalTo("0")) .withQueryParam("per_page", equalTo("200")) .willReturn(okJson(fullPageJson(200, "p0", null)))); server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels")) .withQueryParam("page", equalTo("1")) .withQueryParam("per_page", equalTo("200")) .willReturn(okJson(fullPageJson(10, "p1", null)))); assertThat(client.findChannelIdByDisplayName("005_존재하지않는기관 (법률검토)")).isEmpty(); } /** 팀 채널 목록 한 페이지를 흉내낸 JSON을 만든다. matchDisplayName이 있으면 마지막에 추가한다. */ private static String fullPageJson(int fillerCount, String idPrefix, String matchDisplayName) { StringBuilder sb = new StringBuilder("["); for (int i = 0; i < fillerCount; i++) { if (i > 0) { sb.append(","); } sb.append("{\"id\":\"").append(idPrefix).append("-filler").append(i) .append("\",\"display_name\":\"filler-").append(idPrefix).append("-").append(i) .append("\"}"); } if (matchDisplayName != null) { if (fillerCount > 0) { sb.append(","); } sb.append("{\"id\":\"").append(idPrefix).append("-match\",\"display_name\":\"") .append(matchDisplayName).append("\"}"); } sb.append("]"); return sb.toString(); } @Test void 비공개_채널을_생성하고_id를_돌려준다() { server.stubFor(post(urlEqualTo("/api/v4/channels")) .willReturn(okJson("{\"id\":\"newchan\"}"))); String id = client.createPrivateChannel("org-002-mj", "002_세종학당재단 (문정원)"); assertThat(id).isEqualTo("newchan"); server.verify(postRequestedFor(urlEqualTo("/api/v4/channels")) .withRequestBody(matchingJsonPath("$.team_id", equalTo("team123"))) .withRequestBody(matchingJsonPath("$.name", equalTo("org-002-mj"))) .withRequestBody(matchingJsonPath("$.display_name", equalTo("002_세종학당재단 (문정원)"))) .withRequestBody(matchingJsonPath("$.type", equalTo("P")))); } @Test void 서버가_오류를_주면_MattermostException을_던진다() { server.stubFor(post(urlEqualTo("/api/v4/channels")) .willReturn(aResponse().withStatus(500).withBody("boom"))); assertThatThrownBy(() -> client.createPrivateChannel("org-003-mj", "003_기관 (문정원)")) .isInstanceOf(MattermostException.class); } @Test void 게시글_조회는_최신순_응답을_오래된순으로_뒤집어_돌려준다() { server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("60")) .willReturn(okJson(""" { "order": ["p2", "p1"], "posts": { "p1": {"id":"p1","user_id":"u1","message":"먼저 씀","create_at":100,"type":""}, "p2": {"id":"p2","user_id":"u1","message":"나중에 씀","create_at":200,"type":""} } } """))); server.stubFor(get(urlPathEqualTo("/api/v4/users/u1")) .willReturn(okJson("{\"username\":\"user1\",\"nickname\":\"\"}"))); List posts = client.getRecentPosts("chan001mj", 60); assertThat(posts).extracting(PostView::id).containsExactly("p1", "p2"); assertThat(posts).extracting(PostView::message).containsExactly("먼저 씀", "나중에 씀"); } @Test void 시스템_메시지는_system_플래그가_참이다() { server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("60")) .willReturn(okJson(""" { "order": ["p1"], "posts": { "p1": {"id":"p1","user_id":"u1","message":"님이 입장했습니다.","create_at":100,"type":"system_join_channel"} } } """))); server.stubFor(get(urlPathEqualTo("/api/v4/users/u1")) .willReturn(okJson("{\"username\":\"user1\"}"))); List posts = client.getRecentPosts("chan001mj", 60); assertThat(posts).hasSize(1); assertThat(posts.get(0).system()).isTrue(); } @Test void 게시글의_첨부파일_메타데이터를_읽는다() { server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("60")) .willReturn(okJson(""" { "order": ["p1"], "posts": { "p1": { "id":"p1","user_id":"u1","message":"자료 첨부","create_at":100,"type":"", "metadata": { "files": [ {"id":"f1","name":"보고서.pdf","size":1234,"mime_type":"application/pdf"} ] } } } } """))); server.stubFor(get(urlPathEqualTo("/api/v4/users/u1")) .willReturn(okJson("{\"username\":\"user1\"}"))); List posts = client.getRecentPosts("chan001mj", 60); assertThat(posts.get(0).files()).hasSize(1); FileRef file = posts.get(0).files().get(0); assertThat(file.id()).isEqualTo("f1"); assertThat(file.name()).isEqualTo("보고서.pdf"); assertThat(file.size()).isEqualTo(1234L); } @Test void 같은_작성자의_게시글_두개면_사용자조회는_한번만_한다() { server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("60")) .willReturn(okJson(""" { "order": ["p2", "p1"], "posts": { "p1": {"id":"p1","user_id":"u1","message":"첫번째","create_at":100,"type":""}, "p2": {"id":"p2","user_id":"u1","message":"두번째","create_at":200,"type":""} } } """))); server.stubFor(get(urlPathEqualTo("/api/v4/users/u1")) .willReturn(okJson("{\"username\":\"user1\",\"nickname\":\"유저원\"}"))); List posts = client.getRecentPosts("chan001mj", 60); assertThat(posts).extracting(PostView::user).containsExactly("유저원", "유저원"); server.verify(1, getRequestedFor(urlPathEqualTo("/api/v4/users/u1"))); } @Test void 닉네임이_없으면_성명을_그마저_없으면_계정명을_표시한다() { server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("60")) .willReturn(okJson(""" { "order": ["p2", "p1"], "posts": { "p1": {"id":"p1","user_id":"uFull","message":"성명 사용자","create_at":100,"type":""}, "p2": {"id":"p2","user_id":"uBare","message":"계정명 사용자","create_at":200,"type":""} } } """))); // Mattermost 화면과 같은 우선순위: 닉네임 → 성명(first+last) → 계정명 server.stubFor(get(urlPathEqualTo("/api/v4/users/uFull")) .willReturn(okJson( "{\"username\":\"itnadmin\",\"nickname\":\"\"," + "\"first_name\":\"아이티앤\",\"last_name\":\"관리자\"}"))); server.stubFor(get(urlPathEqualTo("/api/v4/users/uBare")) .willReturn(okJson( "{\"username\":\"bareuser\",\"nickname\":\"\"," + "\"first_name\":\"\",\"last_name\":\"\"}"))); List posts = client.getRecentPosts("chan001mj", 60); assertThat(posts).extracting(PostView::user) .containsExactly("아이티앤 관리자", "bareuser"); } @Test void 파일목록_조회는_두번째_페이지까지_모아_최신순으로_돌려준다() { server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("200")) .withQueryParam("page", equalTo("0")) .willReturn(okJson(postsPageJson(200, "p0", true)))); server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts")) .withQueryParam("per_page", equalTo("200")) .withQueryParam("page", equalTo("1")) .willReturn(okJson(postsPageJson(5, "p1", true)))); server.stubFor(get(urlPathEqualTo("/api/v4/users/uploader")) .willReturn(okJson("{\"username\":\"uploader\"}"))); List files = client.collectChannelFiles("chan001mj"); assertThat(files).extracting(FileView::name).containsExactly("p0-file.txt", "p1-file.txt"); } /** * 채널 게시글 목록 한 페이지를 흉내낸 JSON을 만든다. 마지막 게시글에 첨부파일을 하나 붙여 * 페이지네이션과 파일 수집을 동시에 검증할 수 있게 한다. */ private static String postsPageJson(int count, String idPrefix, boolean withFile) { StringBuilder order = new StringBuilder("["); StringBuilder posts = new StringBuilder("{"); for (int i = 0; i < count; i++) { String id = idPrefix + "-" + i; if (i > 0) { order.append(","); posts.append(","); } order.append("\"").append(id).append("\""); posts.append("\"").append(id).append("\":{\"id\":\"").append(id) .append("\",\"user_id\":\"uploader\",\"message\":\"\",\"create_at\":1,\"type\":\"\""); if (withFile && i == count - 1) { posts.append(",\"metadata\":{\"files\":[{\"id\":\"").append(id) .append("-file\",\"name\":\"").append(idPrefix) .append("-file.txt\",\"size\":10,\"mime_type\":\"text/plain\"}]}"); } posts.append("}"); } order.append("]"); posts.append("}"); return "{\"order\":" + order + ",\"posts\":" + posts + "}"; } @Test void 파일을_다운로드하면_바이트를_그대로_돌려준다() { server.stubFor(get(urlEqualTo("/api/v4/files/f1")) .willReturn(aResponse().withStatus(200).withBody("binary-content"))); byte[] bytes = client.downloadFile("f1"); assertThat(new String(bytes)).isEqualTo("binary-content"); } @Test void 파일_정보를_조회하면_이름과_크기와_MIME타입을_돌려준다() { server.stubFor(get(urlEqualTo("/api/v4/files/f1/info")) .willReturn(okJson("{\"name\":\"보고서.pdf\",\"size\":1234,\"mime_type\":\"application/pdf\"}"))); FileRef info = client.fileInfo("f1"); assertThat(info.id()).isEqualTo("f1"); assertThat(info.name()).isEqualTo("보고서.pdf"); assertThat(info.size()).isEqualTo(1234L); assertThat(info.mimeType()).isEqualTo("application/pdf"); } @Test void 파일을_업로드하면_file_id를_돌려준다() { server.stubFor(post(urlEqualTo("/api/v4/files")) .willReturn(okJson(""" {"file_infos":[{"id":"f-new","name":"공지자료.pdf"}]} """))); String fileId = client.uploadFile("chan001mj", "공지자료.pdf", "hello".getBytes(), "application/pdf"); assertThat(fileId).isEqualTo("f-new"); server.verify(postRequestedFor(urlEqualTo("/api/v4/files")) .withRequestBodyPart(aMultipart().withName("channel_id").withBody(equalTo("chan001mj")).build()) .withRequestBodyPart(aMultipart().withName("files").withBody(equalTo("hello")).build())); } @Test void 게시글을_생성하면_post_id를_돌려준다() { server.stubFor(post(urlEqualTo("/api/v4/posts")) .willReturn(okJson("{\"id\":\"post-new\"}"))); String postId = client.createPost("chan001mj", "안녕하세요", List.of("f1", "f2")); assertThat(postId).isEqualTo("post-new"); server.verify(postRequestedFor(urlEqualTo("/api/v4/posts")) .withRequestBody(matchingJsonPath("$.channel_id", equalTo("chan001mj"))) .withRequestBody(matchingJsonPath("$.message", equalTo("안녕하세요"))) .withRequestBody(matchingJsonPath("$.file_ids[0]", equalTo("f1"))) .withRequestBody(matchingJsonPath("$.file_ids[1]", equalTo("f2")))); } @Test void 게시글을_고정한다() { server.stubFor(post(urlEqualTo("/api/v4/posts/post1/pin")) .willReturn(okJson("{\"status\":\"OK\"}"))); client.pinPost("post1"); server.verify(postRequestedFor(urlEqualTo("/api/v4/posts/post1/pin"))); } @Test void 게시글_고정을_해제한다() { server.stubFor(post(urlEqualTo("/api/v4/posts/post1/unpin")) .willReturn(okJson("{\"status\":\"OK\"}"))); client.unpinPost("post1"); server.verify(postRequestedFor(urlEqualTo("/api/v4/posts/post1/unpin"))); } @Test void 고정된_게시글_목록을_최신순으로_돌려준다() { server.stubFor(get(urlEqualTo("/api/v4/channels/chan001mj/pinned")) .willReturn(okJson(""" { "order": ["p2", "p1"], "posts": { "p1": {"id":"p1","user_id":"u1","message":"먼저 고정","create_at":100,"type":""}, "p2": {"id":"p2","user_id":"u1","message":"나중 고정","create_at":200,"type":""} } } """))); server.stubFor(get(urlPathEqualTo("/api/v4/users/u1")) .willReturn(okJson("{\"username\":\"user1\"}"))); List pinned = client.getPinnedPosts("chan001mj"); // Mattermost가 이미 newest-first로 내려주므로 뒤집지 않고 그대로다. assertThat(pinned).extracting(PostView::id).containsExactly("p2", "p1"); } @Test void 채널_헤더를_변경한다() { server.stubFor(put(urlEqualTo("/api/v4/channels/chan001mj/patch")) .willReturn(okJson("{\"id\":\"chan001mj\"}"))); client.updateChannelHeader("chan001mj", "📢 공지: 안녕하세요 — [자세히](http://x/pl/p1)"); server.verify(putRequestedFor(urlEqualTo("/api/v4/channels/chan001mj/patch")) .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); } }