feat: 채팅 탭 입력창과 공지 배너 추가, 전송·공지 엔드포인트 테스트 보강
@0e134740e3784d2bcdd1f0892286fbda83066265
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -138,6 +138,52 @@ |
| 138 | 138 |
return `/api/files/${fileId}`
|
| 139 | 139 |
} |
| 140 | 140 |
|
| 141 |
+export interface SendResult {
|
|
| 142 |
+ post: PostView |
|
| 143 |
+ /** 게시글은 전송됐지만 공지 뒤처리(핀/헤더)가 일부 실패한 경우의 경고. 성공이면 null */ |
|
| 144 |
+ noticeWarning: string | null |
|
| 145 |
+} |
|
| 146 |
+ |
|
| 147 |
+export function sendMessage( |
|
| 148 |
+ orgId: number, |
|
| 149 |
+ channel: ChannelKindKey, |
|
| 150 |
+ body: { message: string; notice: boolean; files: File[] },
|
|
| 151 |
+): Promise<SendResult> {
|
|
| 152 |
+ const form = new FormData() |
|
| 153 |
+ form.append('channel', channel)
|
|
| 154 |
+ form.append('message', body.message)
|
|
| 155 |
+ form.append('notice', String(body.notice))
|
|
| 156 |
+ for (const file of body.files) {
|
|
| 157 |
+ form.append('files', file)
|
|
| 158 |
+ } |
|
| 159 |
+ return request<SendResult>(`/api/orgs/${orgId}/messages`, { method: 'POST', body: form })
|
|
| 160 |
+} |
|
| 161 |
+ |
|
| 162 |
+/** 공지가 없으면 서버가 204를 주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ |
|
| 163 |
+export async function getNotice(orgId: number, channel: ChannelKindKey): Promise<PostView | null> {
|
|
| 164 |
+ const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, {
|
|
| 165 |
+ credentials: 'same-origin', |
|
| 166 |
+ }) |
|
| 167 |
+ if (response.status === 204) {
|
|
| 168 |
+ return null |
|
| 169 |
+ } |
|
| 170 |
+ if (!response.ok) {
|
|
| 171 |
+ throw new ApiError(response.status, `요청 실패 (${response.status})`)
|
|
| 172 |
+ } |
|
| 173 |
+ return response.json() as Promise<PostView> |
|
| 174 |
+} |
|
| 175 |
+ |
|
| 176 |
+export async function clearNotice(orgId: number, channel: ChannelKindKey): Promise<void> {
|
|
| 177 |
+ const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, {
|
|
| 178 |
+ method: 'DELETE', |
|
| 179 |
+ credentials: 'same-origin', |
|
| 180 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 181 |
+ }) |
|
| 182 |
+ if (!response.ok) {
|
|
| 183 |
+ throw new ApiError(response.status, `공지 해제 실패 (${response.status})`)
|
|
| 184 |
+ } |
|
| 185 |
+} |
|
| 186 |
+ |
|
| 141 | 187 |
export function getMemos(orgId: number): Promise<WorkMemo[]> {
|
| 142 | 188 |
return request<WorkMemo[]>(`/api/orgs/${orgId}/memos`)
|
| 143 | 189 |
} |
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
... | ... | @@ -1,15 +1,21 @@ |
| 1 |
-import { act, render, screen, waitFor } from '@testing-library/react'
|
|
| 1 |
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ChannelPosts from './ChannelPosts' |
| 4 | 4 |
import type { Org, PostView } from '../api/client'
|
| 5 | 5 |
|
| 6 | 6 |
const mocks = vi.hoisted(() => ({
|
| 7 | 7 |
getPosts: vi.fn(), |
| 8 |
+ getNotice: vi.fn(), |
|
| 9 |
+ sendMessage: vi.fn(), |
|
| 10 |
+ clearNotice: vi.fn(), |
|
| 8 | 11 |
})) |
| 9 | 12 |
|
| 10 | 13 |
vi.mock('../api/client', async (importOriginal) => ({
|
| 11 | 14 |
...(await importOriginal<typeof import('../api/client')>()),
|
| 12 | 15 |
getPosts: mocks.getPosts, |
| 16 |
+ getNotice: mocks.getNotice, |
|
| 17 |
+ sendMessage: mocks.sendMessage, |
|
| 18 |
+ clearNotice: mocks.clearNotice, |
|
| 13 | 19 |
})) |
| 14 | 20 |
|
| 15 | 21 |
function org(overrides: Partial<Org> = {}): Org {
|
... | ... | @@ -43,6 +49,9 @@ |
| 43 | 49 |
|
| 44 | 50 |
beforeEach(() => {
|
| 45 | 51 |
mocks.getPosts.mockReset() |
| 52 |
+ mocks.getNotice.mockReset().mockResolvedValue(null) |
|
| 53 |
+ mocks.sendMessage.mockReset().mockResolvedValue({ post: post(), noticeWarning: null })
|
|
| 54 |
+ mocks.clearNotice.mockReset().mockResolvedValue(undefined) |
|
| 46 | 55 |
vi.useRealTimers() |
| 47 | 56 |
}) |
| 48 | 57 |
|
... | ... | @@ -129,4 +138,108 @@ |
| 129 | 138 |
expect(link).toHaveAttribute('href', '/api/files/f1')
|
| 130 | 139 |
}) |
| 131 | 140 |
}) |
| 141 |
+ |
|
| 142 |
+ it('보내기는 내용이 없으면 비활성이고, 전송하면 입력창을 비운다', async () => {
|
|
| 143 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 144 |
+ |
|
| 145 |
+ render(<ChannelPosts org={org()} />)
|
|
| 146 |
+ |
|
| 147 |
+ const sendButton = screen.getByRole('button', { name: '보내기' })
|
|
| 148 |
+ expect(sendButton).toBeDisabled() |
|
| 149 |
+ |
|
| 150 |
+ fireEvent.change(screen.getByLabelText('메시지 입력'), { target: { value: '안녕하세요' } })
|
|
| 151 |
+ expect(sendButton).toBeEnabled() |
|
| 152 |
+ |
|
| 153 |
+ fireEvent.click(sendButton) |
|
| 154 |
+ |
|
| 155 |
+ await waitFor(() => |
|
| 156 |
+ expect(mocks.sendMessage).toHaveBeenCalledWith(1, 'mj', {
|
|
| 157 |
+ message: '안녕하세요', |
|
| 158 |
+ notice: false, |
|
| 159 |
+ files: [], |
|
| 160 |
+ }), |
|
| 161 |
+ ) |
|
| 162 |
+ await waitFor(() => expect(screen.getByLabelText('메시지 입력')).toHaveValue(''))
|
|
| 163 |
+ }) |
|
| 164 |
+ |
|
| 165 |
+ it('Enter는 전송하고 Shift_Enter는 전송하지 않는다', async () => {
|
|
| 166 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 167 |
+ |
|
| 168 |
+ render(<ChannelPosts org={org()} />)
|
|
| 169 |
+ |
|
| 170 |
+ const textarea = screen.getByLabelText('메시지 입력')
|
|
| 171 |
+ fireEvent.change(textarea, { target: { value: '안녕' } })
|
|
| 172 |
+ |
|
| 173 |
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
|
| 174 |
+ expect(mocks.sendMessage).not.toHaveBeenCalled() |
|
| 175 |
+ |
|
| 176 |
+ fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
| 177 |
+ await waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledTimes(1)) |
|
| 178 |
+ }) |
|
| 179 |
+ |
|
| 180 |
+ it('공지로 등록을 체크하면 안내가 보이고 notice_true로 전송된다', async () => {
|
|
| 181 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 182 |
+ |
|
| 183 |
+ render(<ChannelPosts org={org()} />)
|
|
| 184 |
+ |
|
| 185 |
+ fireEvent.click(screen.getByLabelText('공지로 등록'))
|
|
| 186 |
+ expect(screen.getByText(/이전 공지는 해제됩니다/)).toBeTruthy() |
|
| 187 |
+ |
|
| 188 |
+ fireEvent.change(screen.getByLabelText('메시지 입력'), { target: { value: '중요 안내' } })
|
|
| 189 |
+ fireEvent.click(screen.getByRole('button', { name: '보내기' }))
|
|
| 190 |
+ |
|
| 191 |
+ await waitFor(() => |
|
| 192 |
+ expect(mocks.sendMessage).toHaveBeenCalledWith(1, 'mj', {
|
|
| 193 |
+ message: '중요 안내', |
|
| 194 |
+ notice: true, |
|
| 195 |
+ files: [], |
|
| 196 |
+ }), |
|
| 197 |
+ ) |
|
| 198 |
+ }) |
|
| 199 |
+ |
|
| 200 |
+ it('파일을 선택하면 칩이 보이고 제거할 수 있다', async () => {
|
|
| 201 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 202 |
+ |
|
| 203 |
+ render(<ChannelPosts org={org()} />)
|
|
| 204 |
+ |
|
| 205 |
+ const input = screen.getByLabelText('첨부파일 선택')
|
|
| 206 |
+ const file = new File(['x'], '계약서.pdf', { type: 'application/pdf' })
|
|
| 207 |
+ fireEvent.change(input, { target: { files: [file] } })
|
|
| 208 |
+ |
|
| 209 |
+ expect(screen.getByText('계약서.pdf')).toBeTruthy()
|
|
| 210 |
+ |
|
| 211 |
+ fireEvent.click(screen.getByRole('button', { name: '계약서.pdf 제거' }))
|
|
| 212 |
+ expect(screen.queryByText('계약서.pdf')).toBeNull()
|
|
| 213 |
+ }) |
|
| 214 |
+ |
|
| 215 |
+ it('공지가 있으면 배너로 보이고 확인 후 해제할 수 있다', async () => {
|
|
| 216 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 217 |
+ mocks.getNotice.mockResolvedValue(post({ id: 'n1', message: '중요 공지입니다' }))
|
|
| 218 |
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
|
| 219 |
+ |
|
| 220 |
+ render(<ChannelPosts org={org()} />)
|
|
| 221 |
+ |
|
| 222 |
+ await waitFor(() => expect(screen.getByText('📢 공지')).toBeTruthy())
|
|
| 223 |
+ expect(screen.getByText('중요 공지입니다')).toBeTruthy()
|
|
| 224 |
+ |
|
| 225 |
+ fireEvent.click(screen.getByRole('button', { name: '공지 해제' }))
|
|
| 226 |
+ |
|
| 227 |
+ await waitFor(() => expect(mocks.clearNotice).toHaveBeenCalledWith(1, 'mj')) |
|
| 228 |
+ confirmSpy.mockRestore() |
|
| 229 |
+ }) |
|
| 230 |
+ |
|
| 231 |
+ it('공지 뒤처리 경고가 오면 표시한다', async () => {
|
|
| 232 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 233 |
+ mocks.sendMessage.mockResolvedValue({
|
|
| 234 |
+ post: post(), |
|
| 235 |
+ noticeWarning: '공지 등록 처리 중 일부가 실패했습니다: 헤더 갱신 실패', |
|
| 236 |
+ }) |
|
| 237 |
+ |
|
| 238 |
+ render(<ChannelPosts org={org()} />)
|
|
| 239 |
+ |
|
| 240 |
+ fireEvent.change(screen.getByLabelText('메시지 입력'), { target: { value: '공지' } })
|
|
| 241 |
+ fireEvent.click(screen.getByRole('button', { name: '보내기' }))
|
|
| 242 |
+ |
|
| 243 |
+ await waitFor(() => expect(screen.getByText(/일부가 실패했습니다/)).toBeTruthy()) |
|
| 244 |
+ }) |
|
| 132 | 245 |
}) |
--- frontend/src/components/ChannelPosts.tsx
+++ frontend/src/components/ChannelPosts.tsx
... | ... | @@ -1,9 +1,27 @@ |
| 1 | 1 |
import { useEffect, useRef, useState } from 'react'
|
| 2 |
-import { getPosts, fileDownloadUrl, type ChannelKindKey, type FileRef, type Org, type PostView } from '../api/client'
|
|
| 2 |
+import {
|
|
| 3 |
+ clearNotice, |
|
| 4 |
+ fileDownloadUrl, |
|
| 5 |
+ getNotice, |
|
| 6 |
+ getPosts, |
|
| 7 |
+ sendMessage, |
|
| 8 |
+ type ChannelKindKey, |
|
| 9 |
+ type FileRef, |
|
| 10 |
+ type Org, |
|
| 11 |
+ type PostView, |
|
| 12 |
+} from '../api/client' |
|
| 3 | 13 |
|
| 4 | 14 |
const POLL_INTERVAL_MS = 3000 |
| 5 | 15 |
|
| 6 | 16 |
const timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
| 17 |
+ |
|
| 18 |
+const dateTimeFormatter = new Intl.DateTimeFormat('ko-KR', {
|
|
| 19 |
+ year: 'numeric', |
|
| 20 |
+ month: '2-digit', |
|
| 21 |
+ day: '2-digit', |
|
| 22 |
+ hour: '2-digit', |
|
| 23 |
+ minute: '2-digit', |
|
| 24 |
+}) |
|
| 7 | 25 |
|
| 8 | 26 |
function humanizeSize(bytes: number): string {
|
| 9 | 27 |
if (bytes < 1024) {
|
... | ... | @@ -104,14 +122,66 @@ |
| 104 | 122 |
) |
| 105 | 123 |
} |
| 106 | 124 |
|
| 125 |
+/** 공지 배너: 현재 채널의 단일 활성 공지를 목록 위에 항상 고정해 보여준다. */ |
|
| 126 |
+function NoticeBanner({
|
|
| 127 |
+ notice, |
|
| 128 |
+ busy, |
|
| 129 |
+ onClear, |
|
| 130 |
+}: {
|
|
| 131 |
+ notice: PostView |
|
| 132 |
+ busy: boolean |
|
| 133 |
+ onClear: () => void |
|
| 134 |
+}) {
|
|
| 135 |
+ return ( |
|
| 136 |
+ <div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3"> |
|
| 137 |
+ <div className="flex items-baseline gap-2"> |
|
| 138 |
+ <span className="text-sm font-bold text-amber-700">📢 공지</span> |
|
| 139 |
+ <span className="text-xs text-gray-500"> |
|
| 140 |
+ {notice.user} · {dateTimeFormatter.format(new Date(notice.createAt))}
|
|
| 141 |
+ </span> |
|
| 142 |
+ <span className="flex-1" /> |
|
| 143 |
+ <button |
|
| 144 |
+ type="button" |
|
| 145 |
+ disabled={busy}
|
|
| 146 |
+ onClick={onClear}
|
|
| 147 |
+ 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" |
|
| 148 |
+ > |
|
| 149 |
+ 공지 해제 |
|
| 150 |
+ </button> |
|
| 151 |
+ </div> |
|
| 152 |
+ {notice.message && (
|
|
| 153 |
+ <p className="mt-1.5 whitespace-pre-wrap text-sm text-gray-800">{notice.message}</p>
|
|
| 154 |
+ )} |
|
| 155 |
+ {notice.files.length > 0 && (
|
|
| 156 |
+ <div className="mt-1.5 flex flex-wrap gap-1.5"> |
|
| 157 |
+ {notice.files.map((f) => (
|
|
| 158 |
+ <AttachmentChip key={f.id} file={f} />
|
|
| 159 |
+ ))} |
|
| 160 |
+ </div> |
|
| 161 |
+ )} |
|
| 162 |
+ </div> |
|
| 163 |
+ ) |
|
| 164 |
+} |
|
| 165 |
+ |
|
| 107 | 166 |
/** 기관관리 상세의 채팅 탭. 선택한 채널의 최근 게시글을 3초 간격으로 폴링해서 보여준다. */ |
| 108 | 167 |
export default function ChannelPosts({ org }: { org: Org }) {
|
| 109 | 168 |
const bothMissing = !org.channelIdMj && !org.channelIdLaw |
| 110 | 169 |
const [channel, setChannel] = useState<ChannelKindKey>(org.channelIdMj ? 'mj' : 'law') |
| 111 | 170 |
const [posts, setPosts] = useState<PostView[]>([]) |
| 171 |
+ const [notice, setNotice] = useState<PostView | null>(null) |
|
| 112 | 172 |
const [error, setError] = useState<string | null>(null) |
| 173 |
+ // 전송 성공 직후 3초 폴링을 기다리지 않고 즉시 다시 읽기 위한 트리거 |
|
| 174 |
+ const [refreshTick, setRefreshTick] = useState(0) |
|
| 175 |
+ |
|
| 176 |
+ const [draft, setDraft] = useState('')
|
|
| 177 |
+ const [attachments, setAttachments] = useState<File[]>([]) |
|
| 178 |
+ const [asNotice, setAsNotice] = useState(false) |
|
| 179 |
+ const [sending, setSending] = useState(false) |
|
| 180 |
+ const [sendError, setSendError] = useState<string | null>(null) |
|
| 181 |
+ const [noticeWarning, setNoticeWarning] = useState<string | null>(null) |
|
| 113 | 182 |
|
| 114 | 183 |
const containerRef = useRef<HTMLDivElement>(null) |
| 184 |
+ const fileInputRef = useRef<HTMLInputElement>(null) |
|
| 115 | 185 |
const prevCountRef = useRef(0) |
| 116 | 186 |
|
| 117 | 187 |
useEffect(() => {
|
... | ... | @@ -133,6 +203,15 @@ |
| 133 | 203 |
setError('연결이 원활하지 않습니다. 재시도 중…')
|
| 134 | 204 |
} |
| 135 | 205 |
} |
| 206 |
+ // 공지 조회 실패는 목록을 깨뜨리지 않는다 - 마지막 값을 유지한다 |
|
| 207 |
+ try {
|
|
| 208 |
+ const current = await getNotice(org.id, channel) |
|
| 209 |
+ if (!cancelled) {
|
|
| 210 |
+ setNotice(current) |
|
| 211 |
+ } |
|
| 212 |
+ } catch {
|
|
| 213 |
+ /* keep last notice */ |
|
| 214 |
+ } |
|
| 136 | 215 |
} |
| 137 | 216 |
|
| 138 | 217 |
void load() |
... | ... | @@ -142,7 +221,50 @@ |
| 142 | 221 |
clearInterval(timer) |
| 143 | 222 |
} |
| 144 | 223 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 145 |
- }, [channel, org.id]) |
|
| 224 |
+ }, [channel, org.id, refreshTick]) |
|
| 225 |
+ |
|
| 226 |
+ const canSend = !sending && (draft.trim().length > 0 || attachments.length > 0) |
|
| 227 |
+ |
|
| 228 |
+ async function submit() {
|
|
| 229 |
+ if (!canSend) {
|
|
| 230 |
+ return |
|
| 231 |
+ } |
|
| 232 |
+ setSending(true) |
|
| 233 |
+ setSendError(null) |
|
| 234 |
+ setNoticeWarning(null) |
|
| 235 |
+ try {
|
|
| 236 |
+ const result = await sendMessage(org.id, channel, {
|
|
| 237 |
+ message: draft, |
|
| 238 |
+ notice: asNotice, |
|
| 239 |
+ files: attachments, |
|
| 240 |
+ }) |
|
| 241 |
+ setDraft('')
|
|
| 242 |
+ setAttachments([]) |
|
| 243 |
+ setAsNotice(false) |
|
| 244 |
+ setNoticeWarning(result.noticeWarning) |
|
| 245 |
+ setRefreshTick((t) => t + 1) |
|
| 246 |
+ } catch (e) {
|
|
| 247 |
+ setSendError((e as Error).message) |
|
| 248 |
+ } finally {
|
|
| 249 |
+ setSending(false) |
|
| 250 |
+ } |
|
| 251 |
+ } |
|
| 252 |
+ |
|
| 253 |
+ async function handleClearNotice() {
|
|
| 254 |
+ if (!window.confirm('공지를 해제할까요? (원본 글은 채팅 기록에 남습니다)')) {
|
|
| 255 |
+ return |
|
| 256 |
+ } |
|
| 257 |
+ setSending(true) |
|
| 258 |
+ try {
|
|
| 259 |
+ await clearNotice(org.id, channel) |
|
| 260 |
+ setNotice(null) |
|
| 261 |
+ setRefreshTick((t) => t + 1) |
|
| 262 |
+ } catch (e) {
|
|
| 263 |
+ setSendError((e as Error).message) |
|
| 264 |
+ } finally {
|
|
| 265 |
+ setSending(false) |
|
| 266 |
+ } |
|
| 267 |
+ } |
|
| 146 | 268 |
|
| 147 | 269 |
useEffect(() => {
|
| 148 | 270 |
if (posts.length > prevCountRef.current && containerRef.current) {
|
... | ... | @@ -189,13 +311,18 @@ |
| 189 | 311 |
{error && (
|
| 190 | 312 |
<p className="mt-3 rounded-md bg-amber-50 px-3 py-1.5 text-xs text-amber-700">{error}</p>
|
| 191 | 313 |
)} |
| 314 |
+ |
|
| 315 |
+ {notice && (
|
|
| 316 |
+ <NoticeBanner notice={notice} busy={sending} onClear={() => void handleClearNotice()} />
|
|
| 317 |
+ )} |
|
| 318 |
+ |
|
| 192 | 319 |
<div |
| 193 | 320 |
ref={containerRef}
|
| 194 | 321 |
className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white p-4" |
| 195 | 322 |
> |
| 196 | 323 |
{posts.length === 0 ? (
|
| 197 | 324 |
<p className="py-6 text-center text-sm text-gray-400"> |
| 198 |
- 아직 게시글이 없습니다. Mattermost에서 메시지를 보내면 여기 나타납니다. |
|
| 325 |
+ 아직 게시글이 없습니다. 아래 입력창이나 Mattermost에서 메시지를 보내면 여기 나타납니다. |
|
| 199 | 326 |
</p> |
| 200 | 327 |
) : ( |
| 201 | 328 |
posts.map((post, i) => ( |
... | ... | @@ -203,6 +330,112 @@ |
| 203 | 330 |
)) |
| 204 | 331 |
)} |
| 205 | 332 |
</div> |
| 333 |
+ |
|
| 334 |
+ {/* 입력창 */}
|
|
| 335 |
+ <div |
|
| 336 |
+ className={`mt-3 rounded-lg border bg-white p-3 ${
|
|
| 337 |
+ asNotice ? 'border-amber-300' : 'border-gray-200' |
|
| 338 |
+ }`} |
|
| 339 |
+ > |
|
| 340 |
+ {attachments.length > 0 && (
|
|
| 341 |
+ <div className="mb-2 flex flex-wrap gap-1.5"> |
|
| 342 |
+ {attachments.map((file, i) => (
|
|
| 343 |
+ <span |
|
| 344 |
+ key={`${file.name}-${i}`}
|
|
| 345 |
+ 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" |
|
| 346 |
+ > |
|
| 347 |
+ {file.name}
|
|
| 348 |
+ <button |
|
| 349 |
+ type="button" |
|
| 350 |
+ aria-label={`${file.name} 제거`}
|
|
| 351 |
+ onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
|
| 352 |
+ className="text-gray-400 hover:text-gray-600" |
|
| 353 |
+ > |
|
| 354 |
+ × |
|
| 355 |
+ </button> |
|
| 356 |
+ </span> |
|
| 357 |
+ ))} |
|
| 358 |
+ </div> |
|
| 359 |
+ )} |
|
| 360 |
+ |
|
| 361 |
+ <textarea |
|
| 362 |
+ aria-label="메시지 입력" |
|
| 363 |
+ placeholder="메시지를 입력하세요 (Enter 전송, Shift+Enter 줄바꿈)" |
|
| 364 |
+ rows={2}
|
|
| 365 |
+ value={draft}
|
|
| 366 |
+ onChange={(e) => setDraft(e.target.value)}
|
|
| 367 |
+ onKeyDown={(e) => {
|
|
| 368 |
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
| 369 |
+ e.preventDefault() |
|
| 370 |
+ void submit() |
|
| 371 |
+ } |
|
| 372 |
+ }} |
|
| 373 |
+ 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" |
|
| 374 |
+ /> |
|
| 375 |
+ |
|
| 376 |
+ <div className="mt-2 flex items-center gap-3"> |
|
| 377 |
+ <input |
|
| 378 |
+ ref={fileInputRef}
|
|
| 379 |
+ type="file" |
|
| 380 |
+ multiple |
|
| 381 |
+ aria-label="첨부파일 선택" |
|
| 382 |
+ className="hidden" |
|
| 383 |
+ onChange={(e) => {
|
|
| 384 |
+ const picked = Array.from(e.target.files ?? []) |
|
| 385 |
+ if (picked.length > 0) {
|
|
| 386 |
+ setAttachments((prev) => [...prev, ...picked]) |
|
| 387 |
+ } |
|
| 388 |
+ e.target.value = '' |
|
| 389 |
+ }} |
|
| 390 |
+ /> |
|
| 391 |
+ <button |
|
| 392 |
+ type="button" |
|
| 393 |
+ onClick={() => fileInputRef.current?.click()}
|
|
| 394 |
+ 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" |
|
| 395 |
+ > |
|
| 396 |
+ <svg |
|
| 397 |
+ className="h-3.5 w-3.5" |
|
| 398 |
+ viewBox="0 0 24 24" |
|
| 399 |
+ fill="none" |
|
| 400 |
+ stroke="currentColor" |
|
| 401 |
+ strokeWidth="2" |
|
| 402 |
+ strokeLinecap="round" |
|
| 403 |
+ > |
|
| 404 |
+ <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" /> |
|
| 405 |
+ </svg> |
|
| 406 |
+ 파일 첨부 |
|
| 407 |
+ </button> |
|
| 408 |
+ |
|
| 409 |
+ <label className="flex cursor-pointer items-center gap-1.5 text-xs text-gray-600"> |
|
| 410 |
+ <input |
|
| 411 |
+ type="checkbox" |
|
| 412 |
+ checked={asNotice}
|
|
| 413 |
+ onChange={(e) => setAsNotice(e.target.checked)}
|
|
| 414 |
+ className="h-3.5 w-3.5 accent-amber-500" |
|
| 415 |
+ /> |
|
| 416 |
+ 공지로 등록 |
|
| 417 |
+ </label> |
|
| 418 |
+ |
|
| 419 |
+ <span className="flex-1" /> |
|
| 420 |
+ |
|
| 421 |
+ <button |
|
| 422 |
+ type="button" |
|
| 423 |
+ disabled={!canSend}
|
|
| 424 |
+ onClick={() => void submit()}
|
|
| 425 |
+ className="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-medium text-white disabled:bg-gray-300" |
|
| 426 |
+ > |
|
| 427 |
+ 보내기 |
|
| 428 |
+ </button> |
|
| 429 |
+ </div> |
|
| 430 |
+ |
|
| 431 |
+ {asNotice && (
|
|
| 432 |
+ <p className="mt-2 text-xs text-amber-700"> |
|
| 433 |
+ 등록하면 채널 멤버 전체에게 알림이 가고, 이전 공지는 해제됩니다. |
|
| 434 |
+ </p> |
|
| 435 |
+ )} |
|
| 436 |
+ {noticeWarning && <p className="mt-2 text-xs text-amber-700">{noticeWarning}</p>}
|
|
| 437 |
+ {sendError && <p className="mt-2 text-xs text-red-600">{sendError}</p>}
|
|
| 438 |
+ </div> |
|
| 206 | 439 |
</> |
| 207 | 440 |
)} |
| 208 | 441 |
</div> |
--- src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
+++ src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
... | ... | @@ -16,9 +16,17 @@ |
| 16 | 16 |
|
| 17 | 17 |
import java.util.List; |
| 18 | 18 |
|
| 19 |
+import static org.assertj.core.api.Assertions.assertThat; |
|
| 20 |
+import static org.mockito.ArgumentMatchers.any; |
|
| 21 |
+import static org.mockito.ArgumentMatchers.anyString; |
|
| 19 | 22 |
import static org.mockito.ArgumentMatchers.eq; |
| 23 |
+import static org.mockito.Mockito.never; |
|
| 24 |
+import static org.mockito.Mockito.verify; |
|
| 20 | 25 |
import static org.mockito.Mockito.when; |
| 26 |
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; |
|
| 27 |
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
|
| 21 | 28 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
| 29 |
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; |
|
| 22 | 30 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; |
| 23 | 31 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
| 24 | 32 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
... | ... | @@ -121,6 +129,129 @@ |
| 121 | 129 |
} |
| 122 | 130 |
|
| 123 | 131 |
@Test |
| 132 |
+ void 일반_메시지_전송은_게시글만_만들고_공지_처리를_하지_않는다() throws Exception {
|
|
| 133 |
+ when(mattermost.createPost(eq("chan-mj"), eq("안녕하세요"), eq(List.of())))
|
|
| 134 |
+ .thenReturn("post-1");
|
|
| 135 |
+ when(mattermost.getRecentPosts(eq("chan-mj"), eq(1))).thenReturn(List.of(
|
|
| 136 |
+ new PostView("post-1", "관리자", "안녕하세요", 100L, false, List.of())));
|
|
| 137 |
+ |
|
| 138 |
+ mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
|
|
| 139 |
+ .param("channel", "mj")
|
|
| 140 |
+ .param("message", "안녕하세요")
|
|
| 141 |
+ .with(csrf())) |
|
| 142 |
+ .andExpect(status().isOk()) |
|
| 143 |
+ .andExpect(jsonPath("$.post.id").value("post-1"))
|
|
| 144 |
+ .andExpect(jsonPath("$.noticeWarning").isEmpty());
|
|
| 145 |
+ |
|
| 146 |
+ verify(mattermost, never()).pinPost(anyString()); |
|
| 147 |
+ verify(mattermost, never()).unpinPost(anyString()); |
|
| 148 |
+ verify(mattermost, never()).updateChannelHeader(anyString(), anyString()); |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 151 |
+ @Test |
|
| 152 |
+ void 공지_전송은_기존핀을_해제하고_새핀과_헤더를_설정한다() throws Exception {
|
|
| 153 |
+ when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of(
|
|
| 154 |
+ new PostView("old-pin", "관리자", "이전 공지", 50L, false, List.of())));
|
|
| 155 |
+ when(mattermost.createPost(eq("chan-mj"), eq("새 공지"), eq(List.of())))
|
|
| 156 |
+ .thenReturn("post-2");
|
|
| 157 |
+ when(mattermost.getRecentPosts(eq("chan-mj"), eq(1))).thenReturn(List.of(
|
|
| 158 |
+ new PostView("post-2", "관리자", "새 공지", 200L, false, List.of())));
|
|
| 159 |
+ |
|
| 160 |
+ mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
|
|
| 161 |
+ .param("channel", "mj")
|
|
| 162 |
+ .param("message", "새 공지")
|
|
| 163 |
+ .param("notice", "true")
|
|
| 164 |
+ .with(csrf())) |
|
| 165 |
+ .andExpect(status().isOk()) |
|
| 166 |
+ .andExpect(jsonPath("$.post.id").value("post-2"))
|
|
| 167 |
+ .andExpect(jsonPath("$.noticeWarning").isEmpty());
|
|
| 168 |
+ |
|
| 169 |
+ verify(mattermost).unpinPost(eq("old-pin"));
|
|
| 170 |
+ verify(mattermost).pinPost(eq("post-2"));
|
|
| 171 |
+ |
|
| 172 |
+ org.mockito.ArgumentCaptor<String> headerCaptor = |
|
| 173 |
+ org.mockito.ArgumentCaptor.forClass(String.class); |
|
| 174 |
+ verify(mattermost).updateChannelHeader(eq("chan-mj"), headerCaptor.capture());
|
|
| 175 |
+ assertThat(headerCaptor.getValue()) |
|
| 176 |
+ .startsWith("📢 공지: 새 공지")
|
|
| 177 |
+ .contains("/itn-hub/pl/post-2");
|
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ @Test |
|
| 181 |
+ void 파일첨부_전송은_업로드한_파일id를_게시글에_싣는다() throws Exception {
|
|
| 182 |
+ when(mattermost.uploadFile(eq("chan-mj"), eq("계약서.pdf"), any(), eq("application/pdf")))
|
|
| 183 |
+ .thenReturn("file-1");
|
|
| 184 |
+ when(mattermost.createPost(eq("chan-mj"), eq(""), eq(List.of("file-1"))))
|
|
| 185 |
+ .thenReturn("post-3");
|
|
| 186 |
+ when(mattermost.getRecentPosts(eq("chan-mj"), eq(1))).thenReturn(List.of(
|
|
| 187 |
+ new PostView("post-3", "관리자", "", 300L, false,
|
|
| 188 |
+ List.of(new FileRef("file-1", "계약서.pdf", 4L, "application/pdf")))));
|
|
| 189 |
+ |
|
| 190 |
+ mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
|
|
| 191 |
+ .file(new org.springframework.mock.web.MockMultipartFile( |
|
| 192 |
+ "files", "계약서.pdf", "application/pdf", "data".getBytes())) |
|
| 193 |
+ .param("channel", "mj")
|
|
| 194 |
+ .with(csrf())) |
|
| 195 |
+ .andExpect(status().isOk()) |
|
| 196 |
+ .andExpect(jsonPath("$.post.id").value("post-3"));
|
|
| 197 |
+ |
|
| 198 |
+ verify(mattermost).createPost(eq("chan-mj"), eq(""), eq(List.of("file-1")));
|
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ @Test |
|
| 202 |
+ void 내용도_파일도_없으면_400이다() throws Exception {
|
|
| 203 |
+ mvc.perform(multipart("/api/orgs/{id}/messages", orgId)
|
|
| 204 |
+ .param("channel", "mj")
|
|
| 205 |
+ .with(csrf())) |
|
| 206 |
+ .andExpect(status().isBadRequest()) |
|
| 207 |
+ .andExpect(jsonPath("$.message")
|
|
| 208 |
+ .value("메시지 내용이나 첨부파일 중 하나는 있어야 합니다."));
|
|
| 209 |
+ } |
|
| 210 |
+ |
|
| 211 |
+ @Test |
|
| 212 |
+ void 메시지_전송시_존재하지_않는_기관이면_404다() throws Exception {
|
|
| 213 |
+ mvc.perform(multipart("/api/orgs/{id}/messages", orgId + 999999L)
|
|
| 214 |
+ .param("channel", "mj")
|
|
| 215 |
+ .param("message", "안녕")
|
|
| 216 |
+ .with(csrf())) |
|
| 217 |
+ .andExpect(status().isNotFound()); |
|
| 218 |
+ } |
|
| 219 |
+ |
|
| 220 |
+ @Test |
|
| 221 |
+ void 공지_조회는_고정글이_있으면_돌려준다() throws Exception {
|
|
| 222 |
+ when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of(
|
|
| 223 |
+ new PostView("n1", "관리자", "공지문", 100L, false, List.of())));
|
|
| 224 |
+ |
|
| 225 |
+ mvc.perform(get("/api/orgs/{id}/notice", orgId).param("channel", "mj"))
|
|
| 226 |
+ .andExpect(status().isOk()) |
|
| 227 |
+ .andExpect(jsonPath("$.message").value("공지문"));
|
|
| 228 |
+ } |
|
| 229 |
+ |
|
| 230 |
+ @Test |
|
| 231 |
+ void 공지_조회는_고정글이_없으면_204다() throws Exception {
|
|
| 232 |
+ when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of());
|
|
| 233 |
+ |
|
| 234 |
+ mvc.perform(get("/api/orgs/{id}/notice", orgId).param("channel", "mj"))
|
|
| 235 |
+ .andExpect(status().isNoContent()); |
|
| 236 |
+ } |
|
| 237 |
+ |
|
| 238 |
+ @Test |
|
| 239 |
+ void 공지_해제는_핀을_전부_풀고_헤더를_비운다() throws Exception {
|
|
| 240 |
+ when(mattermost.getPinnedPosts(eq("chan-mj"))).thenReturn(List.of(
|
|
| 241 |
+ new PostView("n1", "관리자", "공지1", 100L, false, List.of()),
|
|
| 242 |
+ new PostView("n2", "관리자", "공지2", 200L, false, List.of())));
|
|
| 243 |
+ |
|
| 244 |
+ mvc.perform(delete("/api/orgs/{id}/notice", orgId)
|
|
| 245 |
+ .param("channel", "mj")
|
|
| 246 |
+ .with(csrf())) |
|
| 247 |
+ .andExpect(status().isNoContent()); |
|
| 248 |
+ |
|
| 249 |
+ verify(mattermost).unpinPost(eq("n1"));
|
|
| 250 |
+ verify(mattermost).unpinPost(eq("n2"));
|
|
| 251 |
+ verify(mattermost).updateChannelHeader(eq("chan-mj"), eq(""));
|
|
| 252 |
+ } |
|
| 253 |
+ |
|
| 254 |
+ @Test |
|
| 124 | 255 |
void 파일_다운로드는_한글파일명을_UTF8로_인코딩한_Content_Disposition을_설정한다() throws Exception {
|
| 125 | 256 |
when(mattermost.fileInfo(eq("f1")))
|
| 126 | 257 |
.thenReturn(new FileRef("f1", "보고서.pdf", 1234L, "application/pdf"));
|
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?