ITN Dev 07-22
feat: 채팅 탭 입력창과 공지 배너 추가, 전송·공지 엔드포인트 테스트 보강
@0e134740e3784d2bcdd1f0892286fbda83066265
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -138,6 +138,52 @@
   return `/api/files/${fileId}`
 }
 
+export interface SendResult {
+  post: PostView
+  /** 게시글은 전송됐지만 공지 뒤처리(핀/헤더)가 일부 실패한 경우의 경고. 성공이면 null */
+  noticeWarning: string | null
+}
+
+export function sendMessage(
+  orgId: number,
+  channel: ChannelKindKey,
+  body: { message: string; notice: boolean; files: File[] },
+): Promise<SendResult> {
+  const form = new FormData()
+  form.append('channel', channel)
+  form.append('message', body.message)
+  form.append('notice', String(body.notice))
+  for (const file of body.files) {
+    form.append('files', file)
+  }
+  return request<SendResult>(`/api/orgs/${orgId}/messages`, { method: 'POST', body: form })
+}
+
+/** 공지가 없으면 서버가 204를 주므로 request()의 json() 경로를 타지 않고 직접 부른다. */
+export async function getNotice(orgId: number, channel: ChannelKindKey): Promise<PostView | null> {
+  const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, {
+    credentials: 'same-origin',
+  })
+  if (response.status === 204) {
+    return null
+  }
+  if (!response.ok) {
+    throw new ApiError(response.status, `요청 실패 (${response.status})`)
+  }
+  return response.json() as Promise<PostView>
+}
+
+export async function clearNotice(orgId: number, channel: ChannelKindKey): Promise<void> {
+  const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, {
+    method: 'DELETE',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    throw new ApiError(response.status, `공지 해제 실패 (${response.status})`)
+  }
+}
+
 export function getMemos(orgId: number): Promise<WorkMemo[]> {
   return request<WorkMemo[]>(`/api/orgs/${orgId}/memos`)
 }
frontend/src/components/ChannelPosts.test.tsx
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
@@ -1,15 +1,21 @@
-import { act, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ChannelPosts from './ChannelPosts'
 import type { Org, PostView } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
   getPosts: vi.fn(),
+  getNotice: vi.fn(),
+  sendMessage: vi.fn(),
+  clearNotice: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
   ...(await importOriginal<typeof import('../api/client')>()),
   getPosts: mocks.getPosts,
+  getNotice: mocks.getNotice,
+  sendMessage: mocks.sendMessage,
+  clearNotice: mocks.clearNotice,
 }))
 
 function org(overrides: Partial<Org> = {}): Org {
@@ -43,6 +49,9 @@
 
 beforeEach(() => {
   mocks.getPosts.mockReset()
+  mocks.getNotice.mockReset().mockResolvedValue(null)
+  mocks.sendMessage.mockReset().mockResolvedValue({ post: post(), noticeWarning: null })
+  mocks.clearNotice.mockReset().mockResolvedValue(undefined)
   vi.useRealTimers()
 })
 
@@ -129,4 +138,108 @@
       expect(link).toHaveAttribute('href', '/api/files/f1')
     })
   })
+
+  it('보내기는 내용이 없으면 비활성이고, 전송하면 입력창을 비운다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+
+    render(<ChannelPosts org={org()} />)
+
+    const sendButton = screen.getByRole('button', { name: '보내기' })
+    expect(sendButton).toBeDisabled()
+
+    fireEvent.change(screen.getByLabelText('메시지 입력'), { target: { value: '안녕하세요' } })
+    expect(sendButton).toBeEnabled()
+
+    fireEvent.click(sendButton)
+
+    await waitFor(() =>
+      expect(mocks.sendMessage).toHaveBeenCalledWith(1, 'mj', {
+        message: '안녕하세요',
+        notice: false,
+        files: [],
+      }),
+    )
+    await waitFor(() => expect(screen.getByLabelText('메시지 입력')).toHaveValue(''))
+  })
+
+  it('Enter는 전송하고 Shift_Enter는 전송하지 않는다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+
+    render(<ChannelPosts org={org()} />)
+
+    const textarea = screen.getByLabelText('메시지 입력')
+    fireEvent.change(textarea, { target: { value: '안녕' } })
+
+    fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
+    expect(mocks.sendMessage).not.toHaveBeenCalled()
+
+    fireEvent.keyDown(textarea, { key: 'Enter' })
+    await waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledTimes(1))
+  })
+
+  it('공지로 등록을 체크하면 안내가 보이고 notice_true로 전송된다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+
+    render(<ChannelPosts org={org()} />)
+
+    fireEvent.click(screen.getByLabelText('공지로 등록'))
+    expect(screen.getByText(/이전 공지는 해제됩니다/)).toBeTruthy()
+
+    fireEvent.change(screen.getByLabelText('메시지 입력'), { target: { value: '중요 안내' } })
+    fireEvent.click(screen.getByRole('button', { name: '보내기' }))
+
+    await waitFor(() =>
+      expect(mocks.sendMessage).toHaveBeenCalledWith(1, 'mj', {
+        message: '중요 안내',
+        notice: true,
+        files: [],
+      }),
+    )
+  })
+
+  it('파일을 선택하면 칩이 보이고 제거할 수 있다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+
+    render(<ChannelPosts org={org()} />)
+
+    const input = screen.getByLabelText('첨부파일 선택')
+    const file = new File(['x'], '계약서.pdf', { type: 'application/pdf' })
+    fireEvent.change(input, { target: { files: [file] } })
+
+    expect(screen.getByText('계약서.pdf')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('button', { name: '계약서.pdf 제거' }))
+    expect(screen.queryByText('계약서.pdf')).toBeNull()
+  })
+
+  it('공지가 있으면 배너로 보이고 확인 후 해제할 수 있다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+    mocks.getNotice.mockResolvedValue(post({ id: 'n1', message: '중요 공지입니다' }))
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+    render(<ChannelPosts org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('📢 공지')).toBeTruthy())
+    expect(screen.getByText('중요 공지입니다')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('button', { name: '공지 해제' }))
+
+    await waitFor(() => expect(mocks.clearNotice).toHaveBeenCalledWith(1, 'mj'))
+    confirmSpy.mockRestore()
+  })
+
+  it('공지 뒤처리 경고가 오면 표시한다', async () => {
+    mocks.getPosts.mockResolvedValue([])
+    mocks.sendMessage.mockResolvedValue({
+      post: post(),
+      noticeWarning: '공지 등록 처리 중 일부가 실패했습니다: 헤더 갱신 실패',
+    })
+
+    render(<ChannelPosts org={org()} />)
+
+    fireEvent.change(screen.getByLabelText('메시지 입력'), { target: { value: '공지' } })
+    fireEvent.click(screen.getByRole('button', { name: '보내기' }))
+
+    await waitFor(() => expect(screen.getByText(/일부가 실패했습니다/)).toBeTruthy())
+  })
 })
frontend/src/components/ChannelPosts.tsx
--- frontend/src/components/ChannelPosts.tsx
+++ frontend/src/components/ChannelPosts.tsx
@@ -1,9 +1,27 @@
 import { useEffect, useRef, useState } from 'react'
-import { getPosts, fileDownloadUrl, type ChannelKindKey, type FileRef, type Org, type PostView } from '../api/client'
+import {
+  clearNotice,
+  fileDownloadUrl,
+  getNotice,
+  getPosts,
+  sendMessage,
+  type ChannelKindKey,
+  type FileRef,
+  type Org,
+  type PostView,
+} from '../api/client'
 
 const POLL_INTERVAL_MS = 3000
 
 const timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' })
+
+const dateTimeFormatter = new Intl.DateTimeFormat('ko-KR', {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+})
 
 function humanizeSize(bytes: number): string {
   if (bytes < 1024) {
@@ -104,14 +122,66 @@
   )
 }
 
+/** 공지 배너: 현재 채널의 단일 활성 공지를 목록 위에 항상 고정해 보여준다. */
+function NoticeBanner({
+  notice,
+  busy,
+  onClear,
+}: {
+  notice: PostView
+  busy: boolean
+  onClear: () => void
+}) {
+  return (
+    <div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
+      <div className="flex items-baseline gap-2">
+        <span className="text-sm font-bold text-amber-700">📢 공지</span>
+        <span className="text-xs text-gray-500">
+          {notice.user} · {dateTimeFormatter.format(new Date(notice.createAt))}
+        </span>
+        <span className="flex-1" />
+        <button
+          type="button"
+          disabled={busy}
+          onClick={onClear}
+          className="shrink-0 rounded-md border border-amber-300 px-2 py-0.5 text-xs text-amber-700 hover:bg-amber-100 disabled:opacity-40"
+        >
+          공지 해제
+        </button>
+      </div>
+      {notice.message && (
+        <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{notice.message}</p>
+      )}
+      {notice.files.length > 0 && (
+        <div className="mt-1.5 flex flex-wrap gap-1.5">
+          {notice.files.map((f) => (
+            <AttachmentChip key={f.id} file={f} />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
 /** 기관관리 상세의 채팅 탭. 선택한 채널의 최근 게시글을 3초 간격으로 폴링해서 보여준다. */
 export default function ChannelPosts({ org }: { org: Org }) {
   const bothMissing = !org.channelIdMj && !org.channelIdLaw
   const [channel, setChannel] = useState<ChannelKindKey>(org.channelIdMj ? 'mj' : 'law')
   const [posts, setPosts] = useState<PostView[]>([])
+  const [notice, setNotice] = useState<PostView | null>(null)
   const [error, setError] = useState<string | null>(null)
+  // 전송 성공 직후 3초 폴링을 기다리지 않고 즉시 다시 읽기 위한 트리거
+  const [refreshTick, setRefreshTick] = useState(0)
+
+  const [draft, setDraft] = useState('')
+  const [attachments, setAttachments] = useState<File[]>([])
+  const [asNotice, setAsNotice] = useState(false)
+  const [sending, setSending] = useState(false)
+  const [sendError, setSendError] = useState<string | null>(null)
+  const [noticeWarning, setNoticeWarning] = useState<string | null>(null)
 
   const containerRef = useRef<HTMLDivElement>(null)
+  const fileInputRef = useRef<HTMLInputElement>(null)
   const prevCountRef = useRef(0)
 
   useEffect(() => {
@@ -133,6 +203,15 @@
           setError('연결이 원활하지 않습니다. 재시도 중…')
         }
       }
+      // 공지 조회 실패는 목록을 깨뜨리지 않는다 - 마지막 값을 유지한다
+      try {
+        const current = await getNotice(org.id, channel)
+        if (!cancelled) {
+          setNotice(current)
+        }
+      } catch {
+        /* keep last notice */
+      }
     }
 
     void load()
@@ -142,7 +221,50 @@
       clearInterval(timer)
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [channel, org.id])
+  }, [channel, org.id, refreshTick])
+
+  const canSend = !sending && (draft.trim().length > 0 || attachments.length > 0)
+
+  async function submit() {
+    if (!canSend) {
+      return
+    }
+    setSending(true)
+    setSendError(null)
+    setNoticeWarning(null)
+    try {
+      const result = await sendMessage(org.id, channel, {
+        message: draft,
+        notice: asNotice,
+        files: attachments,
+      })
+      setDraft('')
+      setAttachments([])
+      setAsNotice(false)
+      setNoticeWarning(result.noticeWarning)
+      setRefreshTick((t) => t + 1)
+    } catch (e) {
+      setSendError((e as Error).message)
+    } finally {
+      setSending(false)
+    }
+  }
+
+  async function handleClearNotice() {
+    if (!window.confirm('공지를 해제할까요? (원본 글은 채팅 기록에 남습니다)')) {
+      return
+    }
+    setSending(true)
+    try {
+      await clearNotice(org.id, channel)
+      setNotice(null)
+      setRefreshTick((t) => t + 1)
+    } catch (e) {
+      setSendError((e as Error).message)
+    } finally {
+      setSending(false)
+    }
+  }
 
   useEffect(() => {
     if (posts.length > prevCountRef.current && containerRef.current) {
@@ -189,13 +311,18 @@
           {error && (
             <p className="mt-3 rounded-md bg-amber-50 px-3 py-1.5 text-xs text-amber-700">{error}</p>
           )}
+
+          {notice && (
+            <NoticeBanner notice={notice} busy={sending} onClear={() => void handleClearNotice()} />
+          )}
+
           <div
             ref={containerRef}
             className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white p-4"
           >
             {posts.length === 0 ? (
               <p className="py-6 text-center text-sm text-gray-400">
-                아직 게시글이 없습니다. Mattermost에서 메시지를 보내면 여기 나타납니다.
+                아직 게시글이 없습니다. 아래 입력창이나 Mattermost에서 메시지를 보내면 여기 나타납니다.
               </p>
             ) : (
               posts.map((post, i) => (
@@ -203,6 +330,112 @@
               ))
             )}
           </div>
+
+          {/* 입력창 */}
+          <div
+            className={`mt-3 rounded-lg border bg-white p-3 ${
+              asNotice ? 'border-amber-300' : 'border-gray-200'
+            }`}
+          >
+            {attachments.length > 0 && (
+              <div className="mb-2 flex flex-wrap gap-1.5">
+                {attachments.map((file, i) => (
+                  <span
+                    key={`${file.name}-${i}`}
+                    className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-700"
+                  >
+                    {file.name}
+                    <button
+                      type="button"
+                      aria-label={`${file.name} 제거`}
+                      onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
+                      className="text-gray-400 hover:text-gray-600"
+                    >
+                      ×
+                    </button>
+                  </span>
+                ))}
+              </div>
+            )}
+
+            <textarea
+              aria-label="메시지 입력"
+              placeholder="메시지를 입력하세요 (Enter 전송, Shift+Enter 줄바꿈)"
+              rows={2}
+              value={draft}
+              onChange={(e) => setDraft(e.target.value)}
+              onKeyDown={(e) => {
+                if (e.key === 'Enter' && !e.shiftKey) {
+                  e.preventDefault()
+                  void submit()
+                }
+              }}
+              className="w-full resize-none rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-400"
+            />
+
+            <div className="mt-2 flex items-center gap-3">
+              <input
+                ref={fileInputRef}
+                type="file"
+                multiple
+                aria-label="첨부파일 선택"
+                className="hidden"
+                onChange={(e) => {
+                  const picked = Array.from(e.target.files ?? [])
+                  if (picked.length > 0) {
+                    setAttachments((prev) => [...prev, ...picked])
+                  }
+                  e.target.value = ''
+                }}
+              />
+              <button
+                type="button"
+                onClick={() => fileInputRef.current?.click()}
+                className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50"
+              >
+                <svg
+                  className="h-3.5 w-3.5"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  strokeWidth="2"
+                  strokeLinecap="round"
+                >
+                  <path d="M21 12l-8.5 8.5a5 5 0 0 1-7-7L14 5a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L16 7" />
+                </svg>
+                파일 첨부
+              </button>
+
+              <label className="flex cursor-pointer items-center gap-1.5 text-xs text-gray-600">
+                <input
+                  type="checkbox"
+                  checked={asNotice}
+                  onChange={(e) => setAsNotice(e.target.checked)}
+                  className="h-3.5 w-3.5 accent-amber-500"
+                />
+                공지로 등록
+              </label>
+
+              <span className="flex-1" />
+
+              <button
+                type="button"
+                disabled={!canSend}
+                onClick={() => void submit()}
+                className="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-medium text-white disabled:bg-gray-300"
+              >
+                보내기
+              </button>
+            </div>
+
+            {asNotice && (
+              <p className="mt-2 text-xs text-amber-700">
+                등록하면 채널 멤버 전체에게 알림이 가고, 이전 공지는 해제됩니다.
+              </p>
+            )}
+            {noticeWarning && <p className="mt-2 text-xs text-amber-700">{noticeWarning}</p>}
+            {sendError && <p className="mt-2 text-xs text-red-600">{sendError}</p>}
+          </div>
         </>
       )}
     </div>
src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
--- src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
+++ src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
@@ -16,9 +16,17 @@
 
 import java.util.List;
 
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -121,6 +129,129 @@
     }
 
     @Test
+    void 일반_메시지_전송은_게시글만_만들고_공지_처리를_하지_않는다() throws Exception {
+        when(mattermost.createPost(eq("chan-mj"), eq("안녕하세요"), eq(List.of())))
+                .thenReturn("post-1");
+        when(mattermost.getRecentPosts(eq("chan-mj"), eq(1))).thenReturn(List.of(
+                new PostView("post-1", "관리자", "안녕하세요", 100L, false, List.of())));
+
+        mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
+                        .param("channel", "mj")
+                        .param("message", "안녕하세요")
+                        .with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.post.id").value("post-1"))
+                .andExpect(jsonPath("$.noticeWarning").isEmpty());
+
+        verify(mattermost, never()).pinPost(anyString());
+        verify(mattermost, never()).unpinPost(anyString());
+        verify(mattermost, never()).updateChannelHeader(anyString(), anyString());
+    }
+
+    @Test
+    void 공지_전송은_기존핀을_해제하고_새핀과_헤더를_설정한다() throws Exception {
+        when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of(
+                new PostView("old-pin", "관리자", "이전 공지", 50L, false, List.of())));
+        when(mattermost.createPost(eq("chan-mj"), eq("새 공지"), eq(List.of())))
+                .thenReturn("post-2");
+        when(mattermost.getRecentPosts(eq("chan-mj"), eq(1))).thenReturn(List.of(
+                new PostView("post-2", "관리자", "새 공지", 200L, false, List.of())));
+
+        mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
+                        .param("channel", "mj")
+                        .param("message", "새 공지")
+                        .param("notice", "true")
+                        .with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.post.id").value("post-2"))
+                .andExpect(jsonPath("$.noticeWarning").isEmpty());
+
+        verify(mattermost).unpinPost(eq("old-pin"));
+        verify(mattermost).pinPost(eq("post-2"));
+
+        org.mockito.ArgumentCaptor<String> headerCaptor =
+                org.mockito.ArgumentCaptor.forClass(String.class);
+        verify(mattermost).updateChannelHeader(eq("chan-mj"), headerCaptor.capture());
+        assertThat(headerCaptor.getValue())
+                .startsWith("📢 공지: 새 공지")
+                .contains("/itn-hub/pl/post-2");
+    }
+
+    @Test
+    void 파일첨부_전송은_업로드한_파일id를_게시글에_싣는다() throws Exception {
+        when(mattermost.uploadFile(eq("chan-mj"), eq("계약서.pdf"), any(), eq("application/pdf")))
+                .thenReturn("file-1");
+        when(mattermost.createPost(eq("chan-mj"), eq(""), eq(List.of("file-1"))))
+                .thenReturn("post-3");
+        when(mattermost.getRecentPosts(eq("chan-mj"), eq(1))).thenReturn(List.of(
+                new PostView("post-3", "관리자", "", 300L, false,
+                        List.of(new FileRef("file-1", "계약서.pdf", 4L, "application/pdf")))));
+
+        mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
+                        .file(new org.springframework.mock.web.MockMultipartFile(
+                                "files", "계약서.pdf", "application/pdf", "data".getBytes()))
+                        .param("channel", "mj")
+                        .with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.post.id").value("post-3"));
+
+        verify(mattermost).createPost(eq("chan-mj"), eq(""), eq(List.of("file-1")));
+    }
+
+    @Test
+    void 내용도_파일도_없으면_400이다() throws Exception {
+        mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
+                        .param("channel", "mj")
+                        .with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message")
+                        .value("메시지 내용이나 첨부파일 중 하나는 있어야 합니다."));
+    }
+
+    @Test
+    void 메시지_전송시_존재하지_않는_기관이면_404다() throws Exception {
+        mvc.perform(multipart("/api/orgs/{id}/messages", orgId + 999999L)
+                        .param("channel", "mj")
+                        .param("message", "안녕")
+                        .with(csrf()))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void 공지_조회는_고정글이_있으면_돌려준다() throws Exception {
+        when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of(
+                new PostView("n1", "관리자", "공지문", 100L, false, List.of())));
+
+        mvc.perform(get("/api/orgs/{id}/notice", orgId).param("channel", "mj"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.message").value("공지문"));
+    }
+
+    @Test
+    void 공지_조회는_고정글이_없으면_204다() throws Exception {
+        when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of());
+
+        mvc.perform(get("/api/orgs/{id}/notice", orgId).param("channel", "mj"))
+                .andExpect(status().isNoContent());
+    }
+
+    @Test
+    void 공지_해제는_핀을_전부_풀고_헤더를_비운다() throws Exception {
+        when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of(
+                new PostView("n1", "관리자", "공지1", 100L, false, List.of()),
+                new PostView("n2", "관리자", "공지2", 200L, false, List.of())));
+
+        mvc.perform(delete("/api/orgs/{id}/notice", orgId)
+                        .param("channel", "mj")
+                        .with(csrf()))
+                .andExpect(status().isNoContent());
+
+        verify(mattermost).unpinPost(eq("n1"));
+        verify(mattermost).unpinPost(eq("n2"));
+        verify(mattermost).updateChannelHeader(eq("chan-mj"), eq(""));
+    }
+
+    @Test
     void 파일_다운로드는_한글파일명을_UTF8로_인코딩한_Content_Disposition을_설정한다() throws Exception {
         when(mattermost.fileInfo(eq("f1")))
                 .thenReturn(new FileRef("f1", "보고서.pdf", 1234L, "application/pdf"));
Add a comment
List