ITN Dev 07-22
feat: 채팅 메시지 전송·파일 업로드·공지(핀+헤더) 백엔드 추가
@28107e3ff32401321587118546be4f19eb1ac63d
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -1,6 +1,7 @@
 package kr.itn.itnhub.config;
 
 import kr.itn.itnhub.feed.ChannelNotReadyException;
+import kr.itn.itnhub.feed.InvalidMessageException;
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import org.springframework.http.HttpStatus;
@@ -35,6 +36,9 @@
  * 문정원/법률검토 채널이 아직 생성되지 않았거나 channel 파라미터가 잘못됐을 때 던진다 -
  * 기관은 있고(404 아님) 요청 자체도 잘못되지 않았으니(400 아님) "아직 처리할 수 없는
  * 상태"라는 의미로 409를 쓴다.</p>
+ *
+ * <p>{@link InvalidMessageException}은 채팅 탭에서 메시지 본문과 첨부파일이 둘 다
+ * 없는 채로 전송을 시도할 때 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
  *
  * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
  * 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리
@@ -76,4 +80,9 @@
     public ResponseEntity<ApiError> handleChannelNotReady(ChannelNotReadyException e) {
         return ResponseEntity.status(HttpStatus.CONFLICT).body(new ApiError(e.getMessage()));
     }
+
+    @ExceptionHandler(InvalidMessageException.class)
+    public ResponseEntity<ApiError> handleInvalidMessage(InvalidMessageException e) {
+        return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
+    }
 }
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
@@ -9,5 +9,6 @@
         String token,
         String teamId,
         @DefaultValue("문정원") String channelNameMj,
-        @DefaultValue("법률검토") String channelNameLaw) {
+        @DefaultValue("법률검토") String channelNameLaw,
+        @DefaultValue("itn-hub") String teamName) {
 }
src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
@@ -2,15 +2,22 @@
 
 import kr.itn.itnhub.mattermost.MattermostClient;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
 
+import java.io.IOException;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -39,6 +46,54 @@
     }
 
     /**
+     * 메시지를 보낸다. {@code message}와 {@code files} 둘 다 비어 있으면 400을 돌려준다.
+     * {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다 - 자세한 내용은
+     * {@link ChannelFeedService#sendMessage}를 참고.
+     */
+    @PostMapping("/api/orgs/{id}/messages")
+    public SendResult sendMessage(
+            @PathVariable Long id,
+            @RequestParam String channel,
+            @RequestParam(required = false, defaultValue = "") String message,
+            @RequestParam(required = false, defaultValue = "false") boolean notice,
+            @RequestParam(required = false) List<MultipartFile> files) {
+
+        List<MultipartFile> submitted = files == null ? List.of() : files;
+        boolean anyFile = submitted.stream().anyMatch(f -> !f.isEmpty());
+        if (message.isBlank() && !anyFile) {
+            throw new InvalidMessageException("메시지 내용이나 첨부파일 중 하나는 있어야 합니다.");
+        }
+
+        List<UploadedFile> uploaded = new ArrayList<>();
+        for (MultipartFile file : submitted) {
+            if (file.isEmpty()) {
+                continue;
+            }
+            try {
+                uploaded.add(new UploadedFile(file.getOriginalFilename(), file.getBytes(), file.getContentType()));
+            } catch (IOException e) {
+                throw new InvalidMessageException("첨부파일을 읽지 못했습니다: " + file.getOriginalFilename());
+            }
+        }
+
+        return feedService.sendMessage(id, channel, message, uploaded, notice);
+    }
+
+    /** 채널의 현재 공지(가장 최근에 고정된 게시글). 없으면 204를 돌려준다. */
+    @GetMapping("/api/orgs/{id}/notice")
+    public ResponseEntity<PostView> notice(@PathVariable Long id, @RequestParam String channel) {
+        PostView notice = feedService.currentNotice(id, channel);
+        return notice == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(notice);
+    }
+
+    /** 공지 해제: 고정을 전부 풀고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/notice")
+    public void clearNotice(@PathVariable Long id, @RequestParam String channel) {
+        feedService.clearNotice(id, channel);
+    }
+
+    /**
      * 한글 파일명이 대부분이라 {@code Content-Disposition}은 반드시 RFC 5987의
      * {@code filename*=UTF-8''...} 형식으로 인코딩해야 한다. 일반 {@code filename="..."}
      * 만 쓰면 브라우저마다 한글이 깨진다.
src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
@@ -1,18 +1,27 @@
 package kr.itn.itnhub.feed;
 
+import kr.itn.itnhub.config.MattermostProperties;
 import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
 import kr.itn.itnhub.org.Organization;
 import kr.itn.itnhub.org.OrganizationMapper;
 import kr.itn.itnhub.org.OrgNotFoundException;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
- * 기관관리 상세 화면의 게시물/자료 탭이 쓰는 조회 전용 서비스.
- * Mattermost를 매번 실시간으로 읽어오는 뷰이므로 {@code @Transactional}을 걸지 않는다 -
- * DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을 트랜잭션
- * 안에 묶어둘 이유가 없다.
+ * 기관관리 상세 화면의 채팅 탭이 쓰는 서비스. 조회뿐 아니라 메시지 전송·공지 등록/해제까지
+ * 담당한다. Mattermost를 매번 실시간으로 읽고 쓰는 통로이므로 {@code @Transactional}을
+ * 걸지 않는다 - DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을
+ * 트랜잭션 안에 묶어둘 이유가 없다.
+ *
+ * <p>공지 정책(정책 A - 단일 활성 공지)은 {@link #sendMessage}에서 처리한다: 게시글을
+ * 올린 뒤, 기존에 고정돼 있던 게시글을 전부 해제하고, 새 게시글을 고정하고, 채널 헤더를
+ * 공지 요약 + 원문 permalink로 바꾼다. 게시글 전송 자체가 아니라 이 뒤처리(고정/헤더) 중
+ * 하나라도 실패하면 - 게시글은 이미 채널 멤버에게 전달된 뒤이므로 - 실패로 보고하지 않고
+ * {@link SendResult#noticeWarning()}에 경고만 담아 돌려준다.</p>
  */
 @Service
 public class ChannelFeedService {
@@ -20,12 +29,18 @@
     /** 게시물 탭 한 번에 보여줄 최근 게시글 수. */
     private static final int RECENT_POSTS_PAGE_SIZE = 60;
 
+    /** 공지 헤더에 넣을 요약문 최대 길이(첫 줄 기준). */
+    private static final int NOTICE_SUMMARY_MAX_LEN = 150;
+
     private final OrganizationMapper orgMapper;
     private final MattermostClient mattermost;
+    private final MattermostProperties mattermostProps;
 
-    public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost) {
+    public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost,
+                               MattermostProperties mattermostProps) {
         this.orgMapper = orgMapper;
         this.mattermost = mattermost;
+        this.mattermostProps = mattermostProps;
     }
 
     public List<PostView> posts(Long orgId, String channel) {
@@ -38,6 +53,89 @@
         return mattermost.collectChannelFiles(channelId);
     }
 
+    /**
+     * 메시지를 보내고, {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다.
+     * 파일 업로드·게시글 생성이 실패하면 그대로 예외가 전파된다(전송 자체가 실패한 것).
+     */
+    public SendResult sendMessage(Long orgId, String channel, String message,
+                                   List<UploadedFile> files, boolean notice) {
+        String channelId = resolveChannelId(orgId, channel);
+
+        List<String> fileIds = new ArrayList<>();
+        for (UploadedFile file : files) {
+            fileIds.add(mattermost.uploadFile(channelId, file.filename(), file.data(), file.contentType()));
+        }
+
+        String postId = mattermost.createPost(channelId, message, fileIds);
+        PostView post = fetchPost(channelId, postId, message);
+
+        if (!notice) {
+            return new SendResult(post, null);
+        }
+
+        try {
+            for (PostView pinned : mattermost.getPinnedPosts(channelId)) {
+                mattermost.unpinPost(pinned.id());
+            }
+            mattermost.pinPost(postId);
+            mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId));
+            return new SendResult(post, null);
+        } catch (MattermostException e) {
+            return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage());
+        }
+    }
+
+    /** 채널의 "현재 공지" = 가장 최근에 고정된, 시스템 메시지가 아닌 게시글. 없으면 null. */
+    public PostView currentNotice(Long orgId, String channel) {
+        String channelId = resolveChannelId(orgId, channel);
+        return mattermost.getPinnedPosts(channelId).stream()
+                .filter(p -> !p.system())
+                .findFirst()
+                .orElse(null);
+    }
+
+    /** 공지 해제: 고정된 게시글을 전부 해제하고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */
+    public void clearNotice(Long orgId, String channel) {
+        String channelId = resolveChannelId(orgId, channel);
+        for (PostView pinned : mattermost.getPinnedPosts(channelId)) {
+            mattermost.unpinPost(pinned.id());
+        }
+        mattermost.updateChannelHeader(channelId, "");
+    }
+
+    /**
+     * 방금 만든 게시글을 다시 조회해 표시용 정보(작성자 표시명 등)까지 채워진 {@link PostView}로
+     * 돌려준다. 조회가 비어 있는 예외적인 경우를 대비해 최소한의 값으로 대체한다.
+     */
+    private PostView fetchPost(String channelId, String postId, String message) {
+        return mattermost.getRecentPosts(channelId, 1).stream()
+                .filter(p -> p.id().equals(postId))
+                .findFirst()
+                .orElseGet(() -> new PostView(postId, "", message == null ? "" : message,
+                        System.currentTimeMillis(), false, List.of()));
+    }
+
+    /**
+     * 공지 헤더 문자열: {@code 📢 공지: {요약} — [자세히]({permalink})}.
+     * 요약은 메시지 첫 줄을 150자로 자른 값이고, 메시지가 비어 있으면 첫 첨부파일 이름을 쓴다.
+     */
+    private String noticeHeader(String message, List<UploadedFile> files, String postId) {
+        String summary = noticeSummary(message, files);
+        String permalink = mattermostProps.baseUrl() + "/" + mattermostProps.teamName() + "/pl/" + postId;
+        return "📢 공지: " + summary + " — [자세히](" + permalink + ")";
+    }
+
+    private String noticeSummary(String message, List<UploadedFile> files) {
+        String firstLine = message == null ? "" : message.strip().split("\\R", 2)[0];
+        if (firstLine.isBlank() && !files.isEmpty()) {
+            firstLine = files.get(0).filename();
+        }
+        if (firstLine.length() > NOTICE_SUMMARY_MAX_LEN) {
+            firstLine = firstLine.substring(0, NOTICE_SUMMARY_MAX_LEN);
+        }
+        return firstLine;
+    }
+
     private String resolveChannelId(Long orgId, String channel) {
         Organization org = orgMapper.findById(orgId);
         if (org == null) {
 
src/main/java/kr/itn/itnhub/feed/InvalidMessageException.java (added)
+++ src/main/java/kr/itn/itnhub/feed/InvalidMessageException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.feed;
+
+/**
+ * 채널에 보낼 메시지에 본문도 없고 첨부파일도 없을 때 던진다. 흔한 사용자 실수(빈 채로
+ * 보내기 버튼을 누름)이므로 400과 함께 무엇이 잘못됐는지 알려준다.
+ */
+public class InvalidMessageException extends RuntimeException {
+    public InvalidMessageException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/feed/SendResult.java (added)
+++ src/main/java/kr/itn/itnhub/feed/SendResult.java
@@ -0,0 +1,10 @@
+package kr.itn.itnhub.feed;
+
+/**
+ * 메시지 전송 결과. {@code post}는 항상 채워진다 - 공지 등록 부가 처리(고정/헤더 변경)가
+ * 게시글이 이미 만들어진 뒤에 실패하더라도, 메시지 자체는 정상 전송된 것이므로 실패로
+ * 보이면 안 된다. 그 경우 {@code noticeWarning}에 무엇이 실패했는지 담아 알려준다.
+ * 완전히 성공하면 {@code noticeWarning}은 {@code null}이다.
+ */
+public record SendResult(PostView post, String noticeWarning) {
+}
 
src/main/java/kr/itn/itnhub/feed/UploadedFile.java (added)
+++ src/main/java/kr/itn/itnhub/feed/UploadedFile.java
@@ -0,0 +1,9 @@
+package kr.itn.itnhub.feed;
+
+/**
+ * 메시지에 첨부할 파일 하나의 원본 바이트. 서블릿 타입({@code MultipartFile})을 서비스
+ * 계층으로 들여오지 않기 위한 최소 전달 객체 - 컨트롤러가 {@code MultipartFile}을 읽어
+ * 이 레코드로 변환한 뒤 {@code ChannelFeedService}에 넘긴다.
+ */
+public record UploadedFile(String filename, byte[] data, String contentType) {
+}
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
@@ -24,4 +24,19 @@
     byte[] downloadFile(String fileId);
 
     FileRef fileInfo(String fileId);
+
+    /** 파일을 채널에 업로드하고 file id를 돌려준다. 아직 어떤 게시글에도 첨부되지 않은 상태다. */
+    String uploadFile(String channelId, String filename, byte[] data, String contentType);
+
+    /** 채널에 게시글을 올리고 post id를 돌려준다. {@code fileIds}는 비어 있어도 된다. */
+    String createPost(String channelId, String message, List<String> fileIds);
+
+    void pinPost(String postId);
+
+    void unpinPost(String postId);
+
+    /** 채널의 고정된 게시글을 최신순으로 돌려준다. */
+    List<PostView> getPinnedPosts(String channelId);
+
+    void updateChannelHeader(String channelId, String header);
 }
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
@@ -8,6 +8,7 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.MediaType;
 import org.springframework.stereotype.Component;
+import org.springframework.util.InvalidMimeTypeException;
 import org.springframework.web.client.RestClient;
 import org.springframework.web.client.RestClientException;
 
@@ -17,6 +18,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 
 @Component
@@ -259,6 +261,175 @@
         }
     }
 
+    private static final String MULTIPART_CRLF = "\r\n";
+
+    /**
+     * multipart/form-data 본문을 직접 바이트로 구성해서 보낸다. {@code MultipartBodyBuilder}는
+     * 편리하지만 이 프로젝트의 클래스패스에는 없는 {@code org.reactivestreams.Publisher}를
+     * 내부적으로 필요로 해(RestClient의 multipart 기록 경로가 파트마다 Publisher인지 검사한다)
+     * NoClassDefFoundError가 난다 - 새 의존성을 추가하는 대신 바이트를 직접 조립한다.
+     * 한글 파일명도 헤더 문자열 인코딩 제약(ISO-8859-1 등) 없이 UTF-8 바이트로 그대로 실린다.
+     */
+    @Override
+    public String uploadFile(String channelId, String filename, byte[] data, String contentType) {
+        String boundary = "----ItnHubBoundary" + UUID.randomUUID();
+
+        try {
+            byte[] body = buildMultipartBody(boundary, channelId, filename, data, contentType);
+
+            JsonNode responseBody = rest.post()
+                    .uri("/api/v4/files")
+                    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
+                    .body(body)
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            if (responseBody == null) {
+                throw new MattermostException("파일 업로드 응답이 없습니다: " + filename);
+            }
+            JsonNode fileInfos = responseBody.path("file_infos");
+            if (!fileInfos.isArray() || fileInfos.isEmpty()) {
+                throw new MattermostException("파일 업로드 응답에 file_infos가 없습니다: " + filename);
+            }
+            return idOf(fileInfos.get(0)).orElseThrow(() ->
+                    new MattermostException("파일 업로드 응답에 id가 없습니다: " + filename));
+        } catch (RestClientException e) {
+            throw new MattermostException("파일 업로드 실패: " + filename, e);
+        }
+    }
+
+    private byte[] buildMultipartBody(String boundary, String channelId, String filename,
+                                       byte[] data, String contentType) {
+        try {
+            java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+
+            writeAscii(out, "--" + boundary + MULTIPART_CRLF);
+            writeAscii(out, "Content-Disposition: form-data; name=\"channel_id\"" + MULTIPART_CRLF + MULTIPART_CRLF);
+            out.write(channelId.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            writeAscii(out, MULTIPART_CRLF);
+
+            writeAscii(out, "--" + boundary + MULTIPART_CRLF);
+            writeAscii(out, "Content-Disposition: form-data; name=\"files\"; filename=\""
+                    + filename + "\"" + MULTIPART_CRLF);
+            writeAscii(out, "Content-Type: " + resolveContentType(contentType) + MULTIPART_CRLF + MULTIPART_CRLF);
+            out.write(data);
+            writeAscii(out, MULTIPART_CRLF);
+
+            writeAscii(out, "--" + boundary + "--" + MULTIPART_CRLF);
+            return out.toByteArray();
+        } catch (java.io.IOException e) {
+            throw new MattermostException("파일 업로드 본문 구성 실패: " + filename, e);
+        }
+    }
+
+    private void writeAscii(java.io.ByteArrayOutputStream out, String text) throws java.io.IOException {
+        out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+    }
+
+    private String resolveContentType(String contentType) {
+        if (contentType == null || contentType.isBlank()) {
+            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
+        }
+        try {
+            return MediaType.parseMediaType(contentType).toString();
+        } catch (InvalidMimeTypeException e) {
+            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
+        }
+    }
+
+    @Override
+    public String createPost(String channelId, String message, List<String> fileIds) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("channel_id", channelId);
+        body.put("message", message == null ? "" : message);
+        if (fileIds != null && !fileIds.isEmpty()) {
+            body.put("file_ids", fileIds);
+        }
+
+        try {
+            JsonNode created = rest.post()
+                    .uri("/api/v4/posts")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(body)
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            return idOf(created).orElseThrow(() ->
+                    new MattermostException("게시글 생성 응답에 id가 없습니다: " + channelId));
+        } catch (RestClientException e) {
+            throw new MattermostException("게시글 생성 실패: " + channelId, e);
+        }
+    }
+
+    @Override
+    public void pinPost(String postId) {
+        try {
+            rest.post()
+                    .uri("/api/v4/posts/{postId}/pin", postId)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(Map.of())
+                    .retrieve()
+                    .toBodilessEntity();
+        } catch (RestClientException e) {
+            throw new MattermostException("게시글 고정 실패: " + postId, e);
+        }
+    }
+
+    @Override
+    public void unpinPost(String postId) {
+        try {
+            rest.post()
+                    .uri("/api/v4/posts/{postId}/unpin", postId)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(Map.of())
+                    .retrieve()
+                    .toBodilessEntity();
+        } catch (RestClientException e) {
+            throw new MattermostException("게시글 고정 해제 실패: " + postId, e);
+        }
+    }
+
+    @Override
+    public List<PostView> getPinnedPosts(String channelId) {
+        try {
+            JsonNode body = rest.get()
+                    .uri("/api/v4/channels/{channelId}/pinned", channelId)
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            if (body == null) {
+                return List.of();
+            }
+
+            List<PostView> result = new ArrayList<>();
+            JsonNode posts = body.path("posts");
+            // Mattermost가 이미 newest-first(order)로 내려주므로 뒤집지 않는다.
+            for (JsonNode idNode : body.path("order")) {
+                result.add(toPostView(posts.path(idNode.asText())));
+            }
+            return result;
+        } catch (RestClientException e) {
+            throw new MattermostException("고정 게시글 조회 실패: " + channelId, e);
+        }
+    }
+
+    @Override
+    public void updateChannelHeader(String channelId, String header) {
+        Map<String, String> body = new LinkedHashMap<>();
+        body.put("header", header == null ? "" : header);
+
+        try {
+            rest.put()
+                    .uri("/api/v4/channels/{channelId}/patch", channelId)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(body)
+                    .retrieve()
+                    .toBodilessEntity();
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 헤더 변경 실패: " + channelId, e);
+        }
+    }
+
     private PostView toPostView(JsonNode post) {
         String id = post.path("id").asText();
         String message = post.path("message").asText("");
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -33,6 +33,7 @@
   base-url: ${MATTERMOST_URL}
   token: ${MATTERMOST_TOKEN}
   team-id: ${MATTERMOST_TEAM_ID}
+  team-name: ${MATTERMOST_TEAM_NAME:itn-hub}
 
 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
@@ -34,7 +34,8 @@
                 "test-token",
                 "team123",
                 "문정원",
-                "법률검토");
+                "법률검토",
+                "itn-hub");
 
         // 이 HTTP/1.1 고정은 테스트 하네스(WireMock/Jetty)만의 제약이다: JDK 21 HttpClient의
         // 기본 h2c 업그레이드 협상이 WireMock 상대로 POST 요청에서 간헐적으로 RST_STREAM을
@@ -385,4 +386,87 @@
         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<PostView> 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)"))));
+    }
 }
Add a comment
List