From af1e581be1bb4136ef0c67885db766849c58441e Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 28 Oct 2026 06:47:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=84=A4=EC=A0=95(=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=C2=B7=20=EB=B6=80=EC=9E=AC?= =?UTF-8?q?=EC=A4=91=20=EC=9D=91=EB=8B=B5)=20=C2=B7=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=ED=95=A8=20=EA=B4=80=EB=A6=AC=20=C2=B7=20=EC=8A=A4=ED=8C=B8=20?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EB=82=B4?= =?UTF-8?q?=EB=A0=A4=EB=B0=9B=EA=B8=B0=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 메뉴를 계정 · 메일 묶음으로, 비밀번호 바꾸기(칸 아래 오류 · 30초 한도)와 부재중 자동 응답(기간 · 미리 보기 · 나가기 확인), 메일함 패널의 부재중 안내(1분마다 다시 계산) - 메일함 패널의 내 메일함 묶음 · ⋯ 메뉴(보이는 영역 안에 열기) · 대화상자(만들기 · 이름 바꾸기 · 지우기), 목록 머리 비우기 — 확인한 그 메일함만, 휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다 - 오래 걸리는 처리의 진행 알림("…하는 중 · 600 / 1,234통" → 결과), 세션이 바뀌면 멈추고 늦게 온 응답은 쓰지 않는다 - 읽기 화면 · 여러 통 도구 줄의 스팸 신고 / 스팸 아님 + 되돌리기, ⋯ › 원문 내려받기 (.eml) Co-Authored-By: Claude Opus 5.5 --- .../src/components/feedback/ModalDialog.vue | 41 +- .../src/components/feedback/ToastHost.vue | 44 +- .../src/components/mail/MailBulkButton.vue | 14 +- .../src/components/mail/MailListHeader.vue | 53 +- .../mail/MailSelectionToolbar.spec.ts | 60 +- .../components/mail/MailSelectionToolbar.vue | 47 +- .../mail/MailVacationNotice.spec.ts | 78 ++ .../components/mail/MailVacationNotice.vue | 101 +++ .../components/mail/MailboxDeleteDialog.vue | 90 ++ .../src/components/mail/MailboxDialogs.vue | 156 ++++ .../mail/MailboxEmptyButton.spec.ts | 189 +++++ .../components/mail/MailboxEmptyButton.vue | 138 ++++ .../components/mail/MailboxEmptyDialog.vue | 65 ++ .../src/components/mail/MailboxMoreButton.vue | 189 +++++ .../src/components/mail/MailboxNameDialog.vue | 182 ++++ .../src/components/mail/MailboxPanel.spec.ts | 777 ++++++++++++++++++ frontend/src/components/mail/MailboxPanel.vue | 166 ++-- .../src/components/mail/MailboxPanelRow.vue | 186 +++++ frontend/src/components/mail/MailboxTree.vue | 120 +++ .../components/mail/MessageDeleteButton.vue | 15 +- .../src/components/mail/MessageMoreMenu.vue | 120 +++ .../src/components/mail/MessageReader.spec.ts | 147 ++++ .../src/components/mail/MessageReader.vue | 10 +- .../src/components/mail/MessageSpamButton.vue | 54 ++ .../src/components/mail/editor/EditorMenu.vue | 16 +- .../src/components/mail/editor/editor-menu.ts | 2 + .../src/components/mail/mailbox-dialog.ts | 9 + .../settings/PasswordChangeForm.vue | 258 ++++++ .../src/components/settings/PasswordField.vue | 158 ++++ .../components/settings/SettingsPageFrame.vue | 77 ++ .../src/components/settings/VacationForm.vue | 296 +++++++ .../settings/VacationMessageFields.vue | 143 ++++ .../settings/VacationPeriodFields.vue | 196 +++++ .../components/settings/VacationPreview.vue | 128 +++ frontend/src/components/shell/AppRail.spec.ts | 2 +- frontend/src/composables/useApi.spec.ts | 3 + frontend/src/composables/useAuth.ts | 19 +- .../src/composables/useBulkMailActions.ts | 38 +- frontend/src/composables/useMail.ts | 83 ++ .../src/composables/useMailMoveFeedback.ts | 25 +- frontend/src/composables/useMailboxActions.ts | 155 ++++ frontend/src/composables/useMessageRemoval.ts | 50 +- frontend/src/composables/useToast.ts | 39 +- frontend/src/pages/mail/MailLayout.vue | 4 + .../settings/AccountPasswordPage.spec.ts | 198 +++++ .../pages/settings/AccountPasswordPage.vue | 55 ++ .../pages/settings/MailVacationPage.spec.ts | 288 +++++++ .../src/pages/settings/MailVacationPage.vue | 51 ++ .../src/pages/settings/SettingsLayout.spec.ts | 70 ++ .../src/pages/settings/SettingsLayout.vue | 67 +- frontend/src/router/index.ts | 68 +- frontend/src/router/routes.ts | 83 ++ frontend/src/shared/constants/account.ts | 31 + frontend/src/shared/constants/icons.ts | 24 + .../src/shared/constants/mail-vacation.ts | 55 ++ frontend/src/shared/constants/mail.ts | 21 + .../src/shared/constants/mailbox-manage.ts | 85 ++ .../src/shared/constants/settings-menu.ts | 40 + frontend/src/shared/types/auth.ts | 10 + frontend/src/shared/types/mail.ts | 57 ++ frontend/src/shared/utils/korean.spec.ts | 16 +- frontend/src/shared/utils/korean.ts | 10 + frontend/src/shared/utils/mail-bulk.spec.ts | 8 + frontend/src/shared/utils/mail-bulk.ts | 8 +- .../src/shared/utils/mail-vacation.spec.ts | 159 ++++ frontend/src/shared/utils/mail-vacation.ts | 181 ++++ .../shared/utils/mailbox-operation.spec.ts | 43 + .../src/shared/utils/mailbox-operation.ts | 63 ++ frontend/src/shared/utils/mailbox.spec.ts | 63 +- frontend/src/shared/utils/mailbox.ts | 71 ++ .../src/shared/utils/password-change.spec.ts | 34 + frontend/src/shared/utils/password-change.ts | 37 + .../src/shared/utils/popup-placement.spec.ts | 22 + frontend/src/shared/utils/popup-placement.ts | 22 + .../src/stores/mail-vacation.store.spec.ts | 98 +++ frontend/src/stores/mail-vacation.store.ts | 75 ++ frontend/src/stores/mail.store.ts | 16 +- frontend/src/stores/mail/bulk.ts | 28 +- frontend/src/stores/mail/mailbox-admin.ts | 224 +++++ frontend/src/stores/mail/mailboxes.ts | 21 + frontend/src/stores/mail/types.ts | 7 + frontend/src/stores/toast.store.spec.ts | 71 +- frontend/src/stores/toast.store.ts | 98 ++- frontend/src/styles/base.css | 72 ++ frontend/src/styles/tokens.css | 9 + frontend/src/test-utils/router.ts | 14 +- 86 files changed, 7114 insertions(+), 302 deletions(-) create mode 100644 frontend/src/components/mail/MailVacationNotice.spec.ts create mode 100644 frontend/src/components/mail/MailVacationNotice.vue create mode 100644 frontend/src/components/mail/MailboxDeleteDialog.vue create mode 100644 frontend/src/components/mail/MailboxDialogs.vue create mode 100644 frontend/src/components/mail/MailboxEmptyButton.spec.ts create mode 100644 frontend/src/components/mail/MailboxEmptyButton.vue create mode 100644 frontend/src/components/mail/MailboxEmptyDialog.vue create mode 100644 frontend/src/components/mail/MailboxMoreButton.vue create mode 100644 frontend/src/components/mail/MailboxNameDialog.vue create mode 100644 frontend/src/components/mail/MailboxPanel.spec.ts create mode 100644 frontend/src/components/mail/MailboxPanelRow.vue create mode 100644 frontend/src/components/mail/MailboxTree.vue create mode 100644 frontend/src/components/mail/MessageMoreMenu.vue create mode 100644 frontend/src/components/mail/MessageSpamButton.vue create mode 100644 frontend/src/components/mail/mailbox-dialog.ts create mode 100644 frontend/src/components/settings/PasswordChangeForm.vue create mode 100644 frontend/src/components/settings/PasswordField.vue create mode 100644 frontend/src/components/settings/SettingsPageFrame.vue create mode 100644 frontend/src/components/settings/VacationForm.vue create mode 100644 frontend/src/components/settings/VacationMessageFields.vue create mode 100644 frontend/src/components/settings/VacationPeriodFields.vue create mode 100644 frontend/src/components/settings/VacationPreview.vue create mode 100644 frontend/src/composables/useMailboxActions.ts create mode 100644 frontend/src/pages/settings/AccountPasswordPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountPasswordPage.vue create mode 100644 frontend/src/pages/settings/MailVacationPage.spec.ts create mode 100644 frontend/src/pages/settings/MailVacationPage.vue create mode 100644 frontend/src/pages/settings/SettingsLayout.spec.ts create mode 100644 frontend/src/router/routes.ts create mode 100644 frontend/src/shared/constants/account.ts create mode 100644 frontend/src/shared/constants/mail-vacation.ts create mode 100644 frontend/src/shared/constants/mailbox-manage.ts create mode 100644 frontend/src/shared/constants/settings-menu.ts create mode 100644 frontend/src/shared/utils/mail-vacation.spec.ts create mode 100644 frontend/src/shared/utils/mail-vacation.ts create mode 100644 frontend/src/shared/utils/mailbox-operation.spec.ts create mode 100644 frontend/src/shared/utils/mailbox-operation.ts create mode 100644 frontend/src/shared/utils/password-change.spec.ts create mode 100644 frontend/src/shared/utils/password-change.ts create mode 100644 frontend/src/shared/utils/popup-placement.spec.ts create mode 100644 frontend/src/shared/utils/popup-placement.ts create mode 100644 frontend/src/stores/mail-vacation.store.spec.ts create mode 100644 frontend/src/stores/mail-vacation.store.ts create mode 100644 frontend/src/stores/mail/mailbox-admin.ts diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue index 7cf14e1..3359b46 100644 --- a/frontend/src/components/feedback/ModalDialog.vue +++ b/frontend/src/components/feedback/ModalDialog.vue @@ -1,19 +1,24 @@ @@ -70,19 +85,33 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe } .list-header__title { + min-width: 0; margin: 0; + overflow: hidden; font-size: var(--font-size-title-sm); font-weight: 700; letter-spacing: var(--letter-spacing-tight); + text-overflow: ellipsis; + white-space: nowrap; } +/* 좁으면 이름보다 먼저 줄어든다 */ .list-header__count { + flex: 0 100 auto; + min-width: 0; + overflow: hidden; font-size: var(--font-size-small); color: var(--color-text-muted); font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; } -.list-header__switch { +.list-header__end { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--space-8); margin-left: auto; } diff --git a/frontend/src/components/mail/MailSelectionToolbar.spec.ts b/frontend/src/components/mail/MailSelectionToolbar.spec.ts index aa02b91..4cdfc21 100644 --- a/frontend/src/components/mail/MailSelectionToolbar.spec.ts +++ b/frontend/src/components/mail/MailSelectionToolbar.spec.ts @@ -109,7 +109,7 @@ describe('여러 통 처리 도구 줄', () => { const bulkPatches = () => requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data) - it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => { + it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 스팸 신고 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => { const { wrapper, store } = await mountView() store.toggleCheck('m1') store.toggleCheck('m2') @@ -117,7 +117,7 @@ describe('여러 통 처리 도구 줄', () => { expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨') const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label')) - expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제']) + expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '스팸 신고', '삭제', '선택 해제']) expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)') expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list') expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시') @@ -376,4 +376,60 @@ describe('여러 통 처리 도구 줄', () => { release(ok({ updatedIds: ['m1'], failedIds: [] })) await flushPromises() }) + + describe('스팸 신고 · 스팸 아님 (4단계 — 디자인 원본 MailActions4)', () => { + it('스팸 신고는 이동 ▾ 과 삭제 사이 — PATCH { ids, mailboxId: 스팸메일함, isJunk: true }, 알림 + [되돌리기](원래 메일함 · isJunk: false)', async () => { + const { wrapper, store } = await mountView() + store.toggleCheck('m1') + store.toggleCheck('m2') + await flushPromises() + const tools = wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool')) + expect(tools.slice(3, 6)).toEqual(['move', 'spam', 'delete']) + + await wrapper.get('[data-tool="spam"]').trigger('click') + await flushPromises() + + expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], mailboxId: 'junk', isJunk: true }]) + expect(store.messages.map((message) => message.id)).toEqual(['m3']) + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('메일 2통을 스팸메일함으로 옮겼습니다.') + expect(toast?.action?.label).toBe('되돌리기') + + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false }) + expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.') + }) + + it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => { + const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })] + const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked }) + store.toggleCheck('j1') + await flushPromises() + const notSpam = wrapper.get('[data-tool="spam"]') + expect(notSpam.attributes('aria-label')).toBe('스팸 아님') + expect(notSpam.classes()).toContain('bulk-button--accent') + + await notSpam.trigger('click') + await flushPromises() + + expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }]) + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).') + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true }) + }) + + it('스팸메일함을 모르면 스팸 단추를 그리지 않는다 — 로빙 tabindex 도 건너뛴다', async () => { + const { wrapper, store } = await mountView() + store.mailboxes = MAILBOXES.filter((mailbox) => mailbox.role !== 'junk') + store.toggleCheck('m1') + await flushPromises() + + expect(wrapper.find('[data-tool="spam"]').exists()).toBe(false) + await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowRight' }) + expect(document.activeElement).toBe(wrapper.get('[data-tool="delete"]').element) + }) + }) }) diff --git a/frontend/src/components/mail/MailSelectionToolbar.vue b/frontend/src/components/mail/MailSelectionToolbar.vue index 83f1265..9c8e011 100644 --- a/frontend/src/components/mail/MailSelectionToolbar.vue +++ b/frontend/src/components/mail/MailSelectionToolbar.vue @@ -1,7 +1,8 @@ + + + + diff --git a/frontend/src/components/mail/MailboxDeleteDialog.vue b/frontend/src/components/mail/MailboxDeleteDialog.vue new file mode 100644 index 0000000..d738082 --- /dev/null +++ b/frontend/src/components/mail/MailboxDeleteDialog.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/components/mail/MailboxDialogs.vue b/frontend/src/components/mail/MailboxDialogs.vue new file mode 100644 index 0000000..8ad4680 --- /dev/null +++ b/frontend/src/components/mail/MailboxDialogs.vue @@ -0,0 +1,156 @@ + + + diff --git a/frontend/src/components/mail/MailboxEmptyButton.spec.ts b/frontend/src/components/mail/MailboxEmptyButton.spec.ts new file mode 100644 index 0000000..a84aeaf --- /dev/null +++ b/frontend/src/components/mail/MailboxEmptyButton.spec.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import type { Mailbox, MessageSummary } from '@/shared/types/mail' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { useToastStore } from '@/stores/toast.store' +import { + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import MessageList from './MessageList.vue' +import MessageTable from './MessageTable.vue' + +// 휴지통 · 스팸메일함 목록 머리의 「비우기」 (디자인 원본 MailboxManage · 계약 2절 "메일함 관리") + +function box(id: string, role: Mailbox['role'], name: string, totalCount: number): Mailbox { + return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 } +} + +function summary(id: string, mailboxId: string): MessageSummary { + return { + id, + threadId: `t-${id}`, + mailboxIds: [mailboxId], + from: { name: '호스팅 안내', email: 'ad@example.test' }, + to: [], + subject: `제목 ${id}`, + preview: '', + receivedAt: '2026-10-21T01:00:00Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + } +} + +describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => { + let pinia: Pinia + let requests: MockRequest[] + let listed: MessageSummary[] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) + }) + + afterEach(() => { + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + async function mountList( + mailbox: Mailbox, + view: 'split' | 'list' = 'list', + handler?: (request: MockRequest) => MockResult | Promise | undefined, + ) { + listed = [summary('t1', mailbox.id), summary('t2', mailbox.id)] + requests = installMockApi((request) => { + const custom = handler?.(request) + if (custom) return custom + if (request.method === 'DELETE') { + listed = [] + return ok({ processed: 2, remaining: 0 }) + } + if (request.url === '/messages') return ok({ items: listed, pageInfo: { hasNext: false, nextCursor: null, total: listed.length } }) + if (request.url === '/mailboxes') return ok({ items: useMailStore().mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 1 } }) + return ok(null) + }) + const store = useMailStore() + store.mailboxes = [box('inbox', 'inbox', '받은편지함', 3), mailbox] + store.currentMailboxId = mailbox.id + store.messages = listed + store.pageInfo = { hasNext: false, nextCursor: null, total: listed.length } + store.listStatus = 'ready' + const router = createTestRouter() + await router.push(`/mail/${mailbox.id}`) + await router.isReady() + const wrapper = mount(view === 'split' ? MessageList : MessageTable, { + global: { plugins: [pinia, router] }, + attachTo: document.body, + }) + await flushPromises() + return { wrapper, store } + } + + it('휴지통 — 빨간 글자 「휴지통 비우기」, 확인(처음 초점 「취소」) 뒤 비우고 목록을 다시 받는다 · 되돌리기 없는 알림', async () => { + const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2)) + const empty = wrapper.get('[data-mailbox-empty]') + expect(empty.text()).toBe('휴지통 비우기') + + await empty.trigger('click') + await flushPromises() + expect(document.getElementById('list-empty-dialog')?.hasAttribute('open')).toBe(true) + expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?') + expect(document.activeElement?.id).toBe('list-empty-cancel') + + await wrapper.get('#list-empty-confirm').trigger('click') + await flushPromises() + + expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([ + '/mailboxes/trash/messages', + ]) + expect(store.messages).toEqual([]) + expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([ + ['휴지통을 비웠습니다 — 메일 2통.', null], + ]) + // 비었으니 단추도 사라진다 + expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false) + }) + + it('스팸메일함 · 분할 보기 — 「스팸메일함 비우기」는 아이콘만(이름은 aria-label · 툴팁)', async () => { + const { wrapper } = await mountList(box('junk', 'junk', '스팸메일함', 2), 'split') + const empty = wrapper.get('[data-mailbox-empty]') + + expect(empty.classes()).toContain('empty-button--split') + expect(empty.attributes('aria-label')).toBe('스팸메일함 비우기') + expect(empty.attributes('title')).toBe('스팸메일함 비우기') + }) + + it('빈 휴지통 · 다른 메일함에는 없다', async () => { + const emptyTrash = await mountList(box('trash', 'trash', '휴지통', 0)) + expect(emptyTrash.wrapper.find('[data-mailbox-empty]').exists()).toBe(false) + emptyTrash.wrapper.unmount() + + const inbox = await mountList(box('archive', 'archive', '보관함', 2)) + expect(inbox.wrapper.find('[data-mailbox-empty]').exists()).toBe(false) + }) + + const dialogOpen = () => document.getElementById('list-empty-dialog')?.hasAttribute('open') ?? false + const deletes = () => requests.filter((request) => request.method === 'DELETE').map((request) => request.url) + + it('확인창은 연 때의 메일함을 붙잡는다 — 연 채 보는 메일함이 바뀌면(뒤로 가기 등) 닫고, 연 적 없는 메일함을 비우지 않는다', async () => { + const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2)) + store.mailboxes = [...store.mailboxes, box('junk', 'junk', '스팸메일함', 5)] + await wrapper.get('[data-mailbox-empty]').trigger('click') + await flushPromises() + expect(dialogOpen()).toBe(true) + expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?') + + // 확인창을 연 채 뒤로 가기로 스팸메일함을 보게 되었다 + store.currentMailboxId = 'junk' + await flushPromises() + expect(dialogOpen()).toBe(false) + expect(deletes()).toEqual([]) + + // 다시 열면 지금 메일함의 창 — 확인하면 그 메일함만 비운다 + await wrapper.get('[data-mailbox-empty]').trigger('click') + await flushPromises() + expect(wrapper.get('#list-empty-title').text()).toBe('스팸메일함의 메일 5통을 모두 영구 삭제할까요?') + await wrapper.get('#list-empty-confirm').trigger('click') + await flushPromises() + expect(deletes()).toEqual(['/mailboxes/junk/messages']) + }) + + it('메일함을 지우는 동안 「휴지통 비우기」는 누를 수 없다 — 꺼도 초점을 잃지 않게 aria-disabled', async () => { + let finish: (result: MockResult) => void = () => undefined + const deleting = new Promise((resolve) => { + finish = resolve + }) + const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2), 'list', (request) => + request.url === '/mailboxes/c' ? deleting : undefined, + ) + const custom = box('c', null, '거래처', 12) + store.mailboxes = [...store.mailboxes, custom] + const running = store.runMailboxOperation('delete', custom, () => undefined) + await flushPromises() + + const empty = wrapper.get('[data-mailbox-empty]') + expect(empty.attributes('aria-disabled')).toBe('true') + expect(empty.attributes('disabled')).toBeUndefined() + await empty.trigger('click') + await flushPromises() + expect(dialogOpen()).toBe(false) + + finish(ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 })) + await running + await flushPromises() + expect(wrapper.get('[data-mailbox-empty]').attributes('aria-disabled')).toBeUndefined() + }) +}) diff --git a/frontend/src/components/mail/MailboxEmptyButton.vue b/frontend/src/components/mail/MailboxEmptyButton.vue new file mode 100644 index 0000000..90a3366 --- /dev/null +++ b/frontend/src/components/mail/MailboxEmptyButton.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/frontend/src/components/mail/MailboxEmptyDialog.vue b/frontend/src/components/mail/MailboxEmptyDialog.vue new file mode 100644 index 0000000..3bde697 --- /dev/null +++ b/frontend/src/components/mail/MailboxEmptyDialog.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/src/components/mail/MailboxMoreButton.vue b/frontend/src/components/mail/MailboxMoreButton.vue new file mode 100644 index 0000000..11d8565 --- /dev/null +++ b/frontend/src/components/mail/MailboxMoreButton.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/components/mail/MailboxNameDialog.vue b/frontend/src/components/mail/MailboxNameDialog.vue new file mode 100644 index 0000000..db98cbc --- /dev/null +++ b/frontend/src/components/mail/MailboxNameDialog.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/components/mail/MailboxPanel.spec.ts b/frontend/src/components/mail/MailboxPanel.spec.ts new file mode 100644 index 0000000..5e5e165 --- /dev/null +++ b/frontend/src/components/mail/MailboxPanel.spec.ts @@ -0,0 +1,777 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { nextTick } from 'vue' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { MailVacation, Mailbox } from '@/shared/types/mail' +import { endOfDayIso } from '@/shared/utils/mail-vacation' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { useMailVacationStore } from '@/stores/mail-vacation.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import MailboxPanel from './MailboxPanel.vue' + +// 메일함 패널의 메일함 관리 (디자인 원본 MailboxManage · MailActions4 · 계약 2절 "메일함 관리" · "부재중 자동 응답") + +function box(id: string, role: Mailbox['role'], name: string, partial: Partial = {}): Mailbox { + return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial } +} + +const MAILBOXES: Mailbox[] = [ + box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }), + box('sent', 'sent', '보낸편지함', { totalCount: 64 }), + box('drafts', 'drafts', '임시보관함', { totalCount: 2 }), + box('junk', 'junk', '스팸메일함', { unreadCount: 1, totalCount: 5 }), + box('trash', 'trash', '휴지통', { totalCount: 21 }), + box('p', null, '프로젝트', { unreadCount: 2, totalCount: 18 }), + box('pq', null, '견적', { parentId: 'p', totalCount: 6 }), + box('c', null, '거래처', { unreadCount: 1, totalCount: 12 }), +] + +/** 응답을 직접 풀어 주는 요청 — 진행 알림을 그 사이에 본다 */ +interface Deferred { + promise: Promise + resolve: (result: MockResult) => void +} + +function deferred(): Deferred { + let resolve: (result: MockResult) => void = () => undefined + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +type Reply = MockResult | Deferred + +describe('MailboxPanel — 메일함 관리', () => { + let pinia: Pinia + let requests: MockRequest[] + /** "METHOD url" 마다 차례로 줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */ + let replies: Map + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) + replies = new Map() + }) + + afterEach(() => { + vi.restoreAllMocks() + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + function reply(key: string, ...results: Reply[]) { + replies.set(key, results) + } + + async function mountPanel(path = '/mail/inbox') { + requests = installMockApi((request) => { + const queue = replies.get(`${request.method} ${request.url}`) + if (queue && queue.length > 0) { + const next = queue.length > 1 ? queue.shift() : queue[0] + if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next + } + if (request.url === '/mailboxes') { + return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 8 } }) + } + if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }) + return ok(null) + }) + const store = useMailStore() + store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox })) + store.mailboxesStatus = 'ready' + const router = createTestRouter() + await router.push(path) + await router.isReady() + const match = /^\/mail\/([^/]+)/.exec(path) + if (match?.[1]) store.currentMailboxId = match[1] + const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } }) + await flushPromises() + return { wrapper, router, store } + } + + const row = (wrapper: VueWrapper, name: string) => { + const found = wrapper.findAll('.mailbox-row').find((item) => item.get('.mailbox-row__name').text() === name) + if (!found) throw new Error(`${name} 줄이 없다`) + return found + } + + async function openMenu(wrapper: VueWrapper, name: string) { + await row(wrapper, name).get('[data-mailbox-more]').trigger('click') + await flushPromises() + return wrapper.findAll('[role="menu"] [role^="menuitem"]') + } + + async function choose(wrapper: VueWrapper, name: string, label: string) { + const items = await openMenu(wrapper, name) + const item = items.find((candidate) => candidate.text() === label) + if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`) + await item.trigger('click') + await flushPromises() + } + + const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false + const toastTexts = () => useToastStore().toasts.map((toast) => toast.message) + const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url) + + it('기본 메일함 아래 "내 메일함" 묶음 — 하위 메일함은 들여 쓰고, 끝에 [+ 새 메일함]', async () => { + const { wrapper } = await mountPanel() + + const lists = wrapper.findAll('.mailbox-tree__list') + expect(lists[0]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual([ + '받은편지함', + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + ]) + expect(wrapper.get('.mailbox-tree__group').text()).toBe('내 메일함') + expect(lists[1]?.attributes('aria-labelledby')).toBe(wrapper.get('.mailbox-tree__group').attributes('id')) + expect(lists[1]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual(['거래처', '프로젝트', '견적']) + expect(row(wrapper, '견적').get('a').classes()).toContain('mailbox-row__link--depth-1') + expect(wrapper.get('[data-mailbox-new]').text()).toBe('새 메일함') + // 안 읽은 수는 평범한 숫자 — ⋯ 앞에 그대로 + expect(row(wrapper, '받은편지함').get('.mailbox-row__count').text()).toContain('3') + }) + + it('⋯ 메뉴 — 메일함마다 할 수 있는 일만 · 할 일이 없는 메일함에는 ⋯ 가 없다', async () => { + const { wrapper } = await mountPanel() + const labels = async (name: string) => { + const items = await openMenu(wrapper, name) + const result = items.map((item) => [item.text(), item.classes().includes('menu__item--danger')]) + await row(wrapper, name).get('[data-mailbox-more]').trigger('click') + await flushPromises() + return result + } + + expect(await labels('받은편지함')).toEqual([ + ['모두 읽음으로 표시', false], + ['하위 메일함 만들기', false], + ]) + expect(await labels('스팸메일함')).toEqual([ + ['모두 읽음으로 표시', false], + ['스팸메일함 비우기', true], + ]) + expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]]) + expect(await labels('프로젝트')).toEqual([ + ['모두 읽음으로 표시', false], + ['하위 메일함 만들기', false], + ['이름 바꾸기', false], + ['지우기', true], + ]) + expect(await labels('견적')).toEqual([ + ['하위 메일함 만들기', false], + ['이름 바꾸기', false], + ['지우기', true], + ]) + expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false) + expect(row(wrapper, '임시보관함').find('[data-mailbox-more]').exists()).toBe(false) + }) + + it('⋯ 단추 — 이름 · aria-expanded, 메뉴가 열린 동안 줄이 is-open(⋯ 가 보인다), Esc 는 메뉴만 닫고 단추로', async () => { + const { wrapper } = await mountPanel() + const more = row(wrapper, '거래처').get('[data-mailbox-more]') + expect(more.attributes('aria-label')).toBe('거래처 메일함 메뉴') + expect(more.attributes('aria-haspopup')).toBe('menu') + + await more.trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + const menu = wrapper.get('[role="menu"]') + expect(menu.attributes('aria-label')).toBe('거래처 메일함') + expect(more.attributes('aria-expanded')).toBe('true') + expect(row(wrapper, '거래처').classes()).toContain('is-open') + expect(document.activeElement?.textContent?.trim()).toBe('모두 읽음으로 표시') + + await menu.trigger('keydown', { key: 'Escape' }) + await flushPromises() + expect(wrapper.find('[role="menu"]').exists()).toBe(false) + expect(row(wrapper, '거래처').classes()).not.toContain('is-open') + expect(document.activeElement).toBe(more.element) + }) + + describe('새 메일함 · 하위 메일함 · 이름 바꾸기', () => { + it('[+ 새 메일함] — 이름 칸(64자)에 초점 · 안내, 만들면 POST /mailboxes { name } → 패널에 바로 나타나고 짧은 알림', async () => { + const { wrapper } = await mountPanel() + reply('POST /mailboxes', ok(box('n1', null, '협력사'), 201)) + + await wrapper.get('[data-mailbox-new]').trigger('click') + await flushPromises() + expect(dialogOpen('mailbox-name-dialog')).toBe(true) + expect(wrapper.get('#mailbox-name-title').text()).toBe('새 메일함') + const input = wrapper.get('#mailbox-name-input') + expect(input.attributes('maxlength')).toBe('64') + expect(document.activeElement).toBe(input.element) + expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.') + // 이름을 비웠으면 누를 수 없다 + expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined() + + await input.setValue(' 협력사 ') + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '협력사' }]) + expect(dialogOpen('mailbox-name-dialog')).toBe(false) + expect(wrapper.findAll('.mailbox-row__name').map((name) => name.text())).toContain('협력사') + expect(toastTexts()).toEqual(['‘협력사’ 메일함을 만들었습니다.']) + }) + + it('하위 메일함 만들기 — "‘프로젝트’ 안에 새 메일함", parentId 를 함께 보낸다', async () => { + const { wrapper } = await mountPanel() + reply('POST /mailboxes', ok(box('n2', null, '2027', { parentId: 'p' }), 201)) + + await choose(wrapper, '프로젝트', '하위 메일함 만들기') + expect(wrapper.get('#mailbox-name-title').text()).toBe('‘프로젝트’ 안에 새 메일함') + await wrapper.get('#mailbox-name-input').setValue('2027') + await wrapper.get('#mailbox-name-confirm').trigger('click') + await flushPromises() + + expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '2027', parentId: 'p' }]) + }) + + it('같은 이름(409 MAIL_009) — 알림 없이 칸 아래에 빨간 사전 문구 · 대화상자는 그대로, 고치면 안내로 돌아간다', async () => { + const { wrapper } = await mountPanel() + reply('POST /mailboxes', fail(409, 'MAIL_009')) + + await wrapper.get('[data-mailbox-new]').trigger('click') + await flushPromises() + await wrapper.get('#mailbox-name-input').setValue('거래처') + await wrapper.get('#mailbox-name-confirm').trigger('click') + await flushPromises() + + const help = wrapper.get('#mailbox-name-help') + expect(help.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009) + expect(ERROR_CODE_MESSAGES.MAIL_009).toBe('같은 이름의 메일함이 이미 있습니다.') + expect(help.classes()).toContain('form-message--error') + expect(wrapper.get('#mailbox-name-input').attributes('aria-invalid')).toBe('true') + expect(dialogOpen('mailbox-name-dialog')).toBe(true) + expect(useToastStore().toasts).toEqual([]) + + await wrapper.get('#mailbox-name-input').setValue('거래처 2') + expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.') + }) + + it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => { + const { wrapper } = await mountPanel() + await wrapper.get('[data-mailbox-new]').trigger('click') + await flushPromises() + await wrapper.get('#mailbox-name-input').setValue('2026/견적') + await wrapper.get('#mailbox-name-confirm').trigger('click') + await flushPromises() + + expect(sent('POST', '/mailboxes')).toEqual([]) + expect(wrapper.get('#mailbox-name-help').text()).toBe(ERROR_CODE_MESSAGES.VAL_001) + }) + + it('이름 바꾸기 — 지금 이름으로 채운다. 그대로면 보내지 않고 닫고, 바꾸면 PATCH { name } + "메일함 이름을 바꿨습니다."', async () => { + const { wrapper } = await mountPanel() + reply('PATCH /mailboxes/c', ok(box('c', null, '거래처 (2026)', { totalCount: 12 }))) + + await choose(wrapper, '거래처', '이름 바꾸기') + expect(wrapper.get('#mailbox-name-title').text()).toBe('메일함 이름 바꾸기') + const input = wrapper.get('#mailbox-name-input') + expect(input.element.value).toBe('거래처') + await wrapper.get('#mailbox-name-confirm').trigger('click') + await flushPromises() + expect(dialogOpen('mailbox-name-dialog')).toBe(false) + expect(sent('PATCH', '/mailboxes/c')).toEqual([]) + + await choose(wrapper, '거래처', '이름 바꾸기') + await wrapper.get('#mailbox-name-input').setValue('거래처 (2026)') + await wrapper.get('#mailbox-name-confirm').trigger('click') + await flushPromises() + expect(sent('PATCH', '/mailboxes/c').map((request) => request.data)).toEqual([{ name: '거래처 (2026)' }]) + expect(row(wrapper, '거래처 (2026)').exists()).toBe(true) + expect(toastTexts()).toEqual(['메일함 이름을 바꿨습니다.']) + }) + }) + + describe('지우기 — 안의 메일은 휴지통으로', () => { + it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => { + const { wrapper } = await mountPanel() + await choose(wrapper, '프로젝트', '지우기') + + expect(dialogOpen('mailbox-delete-dialog')).toBe(true) + expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog') + expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다') + expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.') + expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false) + expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인') + + await wrapper.get('#mailbox-delete-cancel').trigger('click') + await flushPromises() + expect(dialogOpen('mailbox-delete-dialog')).toBe(false) + expect(requests.some((request) => request.method === 'DELETE')).toBe(false) + }) + + it('확인(처음 초점 「취소」) 뒤 DELETE 를 deleted 가 될 때까지 다시 보낸다 — 진행 알림이 결과로 바뀌고, 보던 메일함이면 받은편지함으로', async () => { + const second = deferred() + reply( + 'DELETE /mailboxes/c', + ok({ deleted: false, processed: 500, movedToTrash: 500, remaining: 734 }), + second, + ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }), + ) + const { wrapper, router, store } = await mountPanel('/mail/c') + + await choose(wrapper, '거래처', '지우기') + expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함을 지울까요?') + expect(wrapper.get('#mailbox-delete-message').text()).toBe( + '안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.', + ) + expect(document.activeElement?.id).toBe('mailbox-delete-cancel') + + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + expect(dialogOpen('mailbox-delete-dialog')).toBe(false) + expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ + ['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'], + ]) + // 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다 + const items = await openMenu(wrapper, '거래처') + expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true) + + second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 })) + await flushPromises() + + expect(sent('DELETE', '/mailboxes/c')).toHaveLength(3) + expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ + ['success', '‘거래처’ 메일함을 지웠습니다. 안의 메일 1,234통은 휴지통에 있습니다.'], + ]) + expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false) + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('빈 메일함은 "빈 메일함입니다." — 지운 뒤 "‘…’ 메일함을 지웠습니다." · 다른 메일함을 보고 있었으면 그대로', async () => { + const { wrapper, router, store } = await mountPanel('/mail/inbox') + store.mailboxes = store.mailboxes.map((mailbox) => (mailbox.id === 'c' ? { ...mailbox, totalCount: 0 } : mailbox)) + reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 })) + await flushPromises() + + await choose(wrapper, '거래처', '지우기') + expect(wrapper.get('#mailbox-delete-message').text()).toBe('빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + + expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.']) + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + }) + + describe('오래 걸리는 처리 — 모두 읽음 · 비우기', () => { + it('모두 읽음 — remaining 이 0 이 될 때까지 PATCH /mailboxes/{id}/messages { isRead: true } · 안 읽은 수가 바로 줄고 결과 알림', async () => { + const second = deferred() + reply( + 'PATCH /mailboxes/inbox/messages', + ok({ processed: 500, remaining: 734 }), + second, + ) + const { wrapper, store } = await mountPanel() + + await choose(wrapper, '받은편지함', '모두 읽음으로 표시') + expect(sent('PATCH', '/mailboxes/inbox/messages')[0]?.data).toEqual({ isRead: true }) + expect(toastTexts()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 500 / 1,234통']) + expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734) + // 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다 + const items = await openMenu(wrapper, '받은편지함') + expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([ + ['모두 읽음으로 표시', true], + ['하위 메일함 만들기', false], + ]) + await row(wrapper, '받은편지함').get('[data-mailbox-more]').trigger('click') + + second.resolve(ok({ processed: 734, remaining: 0 })) + await flushPromises() + expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2) + expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ + ['success', '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.'], + ]) + expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false) + }) + + it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => { + reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 })) + const { wrapper } = await mountPanel() + + await choose(wrapper, '휴지통', '휴지통 비우기') + expect(dialogOpen('mailbox-empty-dialog')).toBe(true) + expect(wrapper.get('#mailbox-empty-title').text()).toBe('휴지통의 메일 21통을 모두 영구 삭제할까요?') + expect(wrapper.get('#mailbox-empty-message').text()).toBe('지운 메일은 되돌릴 수 없습니다.') + expect(document.activeElement?.id).toBe('mailbox-empty-cancel') + + await wrapper.get('#mailbox-empty-cancel').trigger('click') + await flushPromises() + expect(sent('DELETE', '/mailboxes/trash/messages')).toEqual([]) + + await choose(wrapper, '휴지통', '휴지통 비우기') + await wrapper.get('#mailbox-empty-confirm').trigger('click') + await flushPromises() + expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2) + expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.']) + // 되돌릴 수 없어 [되돌리기]가 없다 + expect(useToastStore().toasts[0]?.action).toBeNull() + }) + + it('도중에 실패하면 거기까지 처리한 수를 알린다 (까닭은 인터셉터 알림) · 처음부터 실패하면 진행 알림만 닫는다', async () => { + reply('PATCH /mailboxes/inbox/messages', ok({ processed: 500, remaining: 734 }), fail(503, 'MAIL_001')) + const { wrapper } = await mountPanel() + + await choose(wrapper, '받은편지함', '모두 읽음으로 표시') + // 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림 + expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ + ['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'], + ['error', ERROR_CODE_MESSAGES.MAIL_001], + ]) + + useToastStore().clear() + reply('DELETE /mailboxes/junk/messages', fail(503, 'SYS_001')) + await choose(wrapper, '스팸메일함', '스팸메일함 비우기') + await wrapper.get('#mailbox-empty-confirm').trigger('click') + await flushPromises() + expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error']) + expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001]) + }) + + it('서버가 나아가지 못하면(남은 수만 돌려줌) 되풀이를 멈추고 사전 문구로 알린다', async () => { + reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 1 })) + const { wrapper } = await mountPanel() + + await choose(wrapper, '거래처', '모두 읽음으로 표시') + + expect(sent('PATCH', '/mailboxes/c/messages')).toHaveLength(3) + expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.BIZ_001]) + }) + }) + + it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => { + const vacation: MailVacation = { + isEnabled: true, + fromDate: null, + toDate: endOfDayIso('2099-11-03'), + subject: '부재중 안내', + textBody: '휴가입니다.', + } + const { wrapper } = await mountPanel() + useMailVacationStore().vacation = vacation + useMailStore().quota = { usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 } + await flushPromises() + + const notice = wrapper.get('[data-vacation-notice]') + expect(notice.text()).toContain('부재중 자동 응답 켜짐') + expect(notice.text()).toContain('2099년 11월 3일까지 · 설정') + expect(notice.attributes('href')).toBe('/settings/mail/vacation') + const footer = wrapper.get('.mailbox-panel__footer').element + expect(footer.firstElementChild).toBe(notice.element) + expect(footer.lastElementChild?.classList.contains('quota')).toBe(true) + + useMailVacationStore().vacation = { ...vacation, isEnabled: false } + await flushPromises() + expect(wrapper.find('[data-vacation-notice]').exists()).toBe(false) + }) + + describe('지우기 · 비우기 — 리뷰 보완', () => { + it('다른 메일함에도 든 메일은 이 메일함에서만 빠진다 — 진행 · 정체는 processed 로, 끝 알림의 휴지통 수는 movedToTrash 합계로', async () => { + reply( + 'DELETE /mailboxes/c', + ok({ deleted: false, processed: 500, movedToTrash: 300, remaining: 300 }), + ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 200 }), + ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 100 }), + ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 0 }), + ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }), + ) + const { wrapper } = await mountPanel() + + await choose(wrapper, '거래처', '지우기') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + + // 휴지통으로 옮긴 것이 없는 차례가 이어져도 나아가고 있다 — 멈추지 않는다 + expect(sent('DELETE', '/mailboxes/c')).toHaveLength(5) + expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 300통은 휴지통에 있습니다.']) + }) + + it('이미 지워진 메일함(404 RES_001)은 끝난 것으로 — 오류 알림 없이 "‘거래처’ 메일함을 지웠습니다." · 메일함 목록을 다시 맞춘다', async () => { + reply('DELETE /mailboxes/c', fail(404, 'RES_001')) + const { wrapper, store } = await mountPanel() + + await choose(wrapper, '거래처', '지우기') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + + expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ + ['success', '‘거래처’ 메일함을 지웠습니다.'], + ]) + expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false) + expect(sent('GET', '/mailboxes').length).toBeGreaterThan(0) + }) + + it('응답이 끊긴 뒤 다시 보내 404 를 받아도 지운 것으로 — 처음 끊김은 인터셉터 알림만', async () => { + reply('DELETE /mailboxes/c', 'network', fail(404, 'RES_001')) + const { wrapper } = await mountPanel() + + await choose(wrapper, '거래처', '지우기') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001]) + + useToastStore().clear() + await choose(wrapper, '거래처', '지우기') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.']) + }) + + it('다른 탭이 먼저 했으면 — "거래처에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다."', async () => { + reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 0 })) + reply('DELETE /mailboxes/trash/messages', ok({ processed: 0, remaining: 0 })) + const { wrapper } = await mountPanel() + + await choose(wrapper, '거래처', '모두 읽음으로 표시') + expect(toastTexts()).toEqual(['거래처에 안 읽은 메일이 없습니다.']) + + useToastStore().clear() + await choose(wrapper, '휴지통', '휴지통 비우기') + await wrapper.get('#mailbox-empty-confirm').trigger('click') + await flushPromises() + expect(toastTexts()).toEqual(['휴지통이 이미 비어 있습니다.']) + }) + + it('다른 탭이 같은 메일함을 처리하는 중이면(429 REQ_001) 인터셉터 알림만 — 진행 알림은 닫는다', async () => { + reply('PATCH /mailboxes/inbox/messages', fail(429, 'REQ_001')) + const { wrapper } = await mountPanel() + + await choose(wrapper, '받은편지함', '모두 읽음으로 표시') + + expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([ + ['error', ERROR_CODE_MESSAGES.REQ_001], + ]) + }) + + it('비운 뒤 누른 ⋯ 가 사라지면 초점을 메일함 패널의 지금 메일함으로', async () => { + reply('DELETE /mailboxes/trash/messages', ok({ processed: 21, remaining: 0 })) + const { wrapper } = await mountPanel('/mail/inbox') + + await choose(wrapper, '휴지통', '휴지통 비우기') + await wrapper.get('#mailbox-empty-confirm').trigger('click') + await flushPromises() + + expect(row(wrapper, '휴지통').find('[data-mailbox-more]').exists()).toBe(false) + expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element) + }) + + it('지운 메일함의 ⋯ 가 사라져도 초점을 메일함 패널의 지금 메일함으로', async () => { + reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 })) + const { wrapper } = await mountPanel('/mail/inbox') + + await choose(wrapper, '거래처', '지우기') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + + expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element) + }) + }) + + describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => { + const itemState = (items: DOMWrapper[]) => + items.map((item) => [item.text(), item.attributes('disabled') !== undefined]) + + it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => { + const second = deferred() + reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), second) + const { wrapper } = await mountPanel() + + await choose(wrapper, '휴지통', '휴지통 비우기') + await wrapper.get('#mailbox-empty-confirm').trigger('click') + await flushPromises() + expect(itemState(await openMenu(wrapper, '거래처'))).toEqual([ + ['모두 읽음으로 표시', false], + ['하위 메일함 만들기', false], + ['이름 바꾸기', false], + ['지우기', true], + ]) + await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click') + + second.resolve(ok({ processed: 1, remaining: 0 })) + await flushPromises() + expect(itemState(await openMenu(wrapper, '거래처')).at(-1)).toEqual(['지우기', false]) + }) + + it('지우기 대화상자를 연 뒤 휴지통 비우기가 시작되면 [지우기]를 누를 수 없다 — 누르는 순간에도 다시 본다', async () => { + const emptying = deferred() + reply('DELETE /mailboxes/trash/messages', emptying) + const { wrapper, store } = await mountPanel() + + await choose(wrapper, '거래처', '지우기') + const trash = store.mailboxes.find((mailbox) => mailbox.id === 'trash') + const target = store.mailboxes.find((mailbox) => mailbox.id === 'c') + if (!trash || !target) throw new Error('메일함이 없다') + const running = store.runMailboxOperation('empty', trash, () => undefined) + await flushPromises() + + const confirm = wrapper.get('#mailbox-delete-confirm') + expect(confirm.attributes('disabled')).toBeDefined() + await confirm.trigger('click') + await flushPromises() + expect(sent('DELETE', '/mailboxes/c')).toEqual([]) + expect(dialogOpen('mailbox-delete-dialog')).toBe(true) + // 스토어도 시작하지 않는다 + expect((await store.runMailboxOperation('delete', target, () => undefined)).status).toBe('canceled') + + emptying.resolve(ok({ processed: 21, remaining: 0 })) + await running + await flushPromises() + expect(wrapper.get('#mailbox-delete-confirm').attributes('disabled')).toBeUndefined() + }) + + it('메일함을 지우는 동안은 「휴지통 비우기」가 꺼진다 — 「스팸메일함 비우기」는 그대로', async () => { + const second = deferred() + reply('DELETE /mailboxes/c', ok({ deleted: false, processed: 6, movedToTrash: 6, remaining: 6 }), second) + const { wrapper } = await mountPanel() + + await choose(wrapper, '거래처', '지우기') + await wrapper.get('#mailbox-delete-confirm').trigger('click') + await flushPromises() + + expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', true]]) + await row(wrapper, '휴지통').get('[data-mailbox-more]').trigger('click') + expect(itemState(await openMenu(wrapper, '스팸메일함'))).toEqual([ + ['모두 읽음으로 표시', false], + ['스팸메일함 비우기', false], + ]) + await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click') + + second.resolve(ok({ deleted: true, processed: 6, movedToTrash: 6, remaining: 0 })) + await flushPromises() + expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', false]]) + }) + }) + + describe('세션이 바뀌면 (stores/mail/mailbox-admin.ts)', () => { + const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } + const inbox = () => MAILBOXES[0] as Mailbox + + it('처리 중 로그아웃하면 다음 요청을 보내지 않고 진행 알림이 닫힌다', async () => { + const first = deferred() + reply('PATCH /mailboxes/inbox/messages', first, ok({ processed: 734, remaining: 0 })) + const { wrapper } = await mountPanel() + + await choose(wrapper, '받은편지함', '모두 읽음으로 표시') + expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['progress']) + useAuthStore().clearAuth() + await nextTick() + first.resolve(ok({ processed: 500, remaining: 734 })) + await flushPromises() + + expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1) + expect(useToastStore().toasts).toEqual([]) + }) + + it('「모두 읽음」을 두 번 눌러도 요청은 한 줄 — 도는 동안 항목이 꺼지고 스토어도 다시 시작하지 않는다', async () => { + const first = deferred() + reply('PATCH /mailboxes/inbox/messages', first) + const { wrapper, store } = await mountPanel() + + await choose(wrapper, '받은편지함', '모두 읽음으로 표시') + const items = await openMenu(wrapper, '받은편지함') + await items[0]?.trigger('click') + await flushPromises() + expect((await store.runMailboxOperation('read', inbox(), () => undefined)).status).toBe('canceled') + + expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1) + first.resolve(ok({ processed: 3, remaining: 0 })) + await flushPromises() + expect(toastTexts()).toEqual(['받은편지함의 메일 3통을 읽음으로 표시했습니다.']) + }) + + it('새 세션에서 같은 처리를 다시 시작할 수 있다 — 옛 처리가 끝나도 새 처리의 표시를 지우지 않는다', async () => { + const first = deferred() + const second = deferred() + reply('PATCH /mailboxes/inbox/messages', first, second) + const { store } = await mountPanel() + + const old = store.runMailboxOperation('read', inbox(), () => undefined) + await flushPromises() + // 로그아웃 → 다시 로그인 (사이에 로그인 화면이 있다) + useAuthStore().clearAuth() + await nextTick() + useAuthStore().startSession(USER) + await nextTick() + store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox })) + + const fresh = store.runMailboxOperation('read', inbox(), () => undefined) + await flushPromises() + expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true) + + first.resolve(ok({ processed: 1, remaining: 2 })) + expect((await old).status).toBe('canceled') + expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true) + expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2) + + second.resolve(ok({ processed: 3, remaining: 0 })) + expect((await fresh).status).toBe('done') + expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(false) + }) + + it('만들기 · 이름 바꾸기의 늦은 응답은 쓰지 않는다 — 다음 사용자의 메일함을 덮지 않고 알리지도 않는다', async () => { + const created = deferred() + const renamed = deferred() + reply('POST /mailboxes', created) + reply('PATCH /mailboxes/c', renamed) + const { store } = await mountPanel() + + const creating = store.createMailbox('협력사', null) + const renaming = store.renameMailbox('c', '거래처 (옛 사용자)') + await flushPromises() + useAuthStore().clearAuth() + await nextTick() + useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }) + await nextTick() + // 다음 사용자의 메일함 — id 가 겹칠 수 있다 + store.mailboxes = [box('c', null, '도윤의 거래처')] + + created.resolve(ok(box('n1', null, '협력사'), 201)) + renamed.resolve(ok(box('c', null, '거래처 (옛 사용자)'))) + expect(await creating).toBeNull() + expect(await renaming).toBeNull() + expect(store.mailboxes.map((mailbox) => mailbox.name)).toEqual(['도윤의 거래처']) + expect(useToastStore().toasts).toEqual([]) + }) + }) + + it('⋯ 메뉴는 메일함 패널의 보이는 영역 아래가 모자라면 위로 연다', async () => { + const { wrapper } = await mountPanel() + const panel = wrapper.get('[data-mailbox-panel]').element as HTMLElement + panel.style.overflowY = 'auto' + let buttonTop = 540 + const rectOf = (top: number, bottom: number) => + ({ top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) }) as DOMRect + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this === panel) return rectOf(0, 600) + if (this.matches('[data-mailbox-more]')) return rectOf(buttonTop, buttonTop + 40) + if (this.matches('[role="menu"]')) return rectOf(buttonTop + 44, buttonTop + 44 + 254) + return rectOf(0, 0) + }) + + await openMenu(wrapper, '거래처') + expect(wrapper.get('[role="menu"]').classes()).toContain('mailbox-more__menu--up') + await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click') + + buttonTop = 100 + await openMenu(wrapper, '거래처') + expect(wrapper.get('[role="menu"]').classes()).not.toContain('mailbox-more__menu--up') + }) +}) diff --git a/frontend/src/components/mail/MailboxPanel.vue b/frontend/src/components/mail/MailboxPanel.vue index 13acbeb..ded1036 100644 --- a/frontend/src/components/mail/MailboxPanel.vue +++ b/frontend/src/components/mail/MailboxPanel.vue @@ -1,33 +1,63 @@ diff --git a/frontend/src/components/mail/MailboxTree.vue b/frontend/src/components/mail/MailboxTree.vue new file mode 100644 index 0000000..5782021 --- /dev/null +++ b/frontend/src/components/mail/MailboxTree.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/mail/MessageDeleteButton.vue b/frontend/src/components/mail/MessageDeleteButton.vue index be1524c..daa364e 100644 --- a/frontend/src/components/mail/MessageDeleteButton.vue +++ b/frontend/src/components/mail/MessageDeleteButton.vue @@ -3,19 +3,16 @@ // - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval) // · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기] // · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다." -// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다). +// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved). // 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다 // - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다) import { computed, ref } from 'vue' -import { useRouter } from 'vue-router' import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue' import MailToolButton from '@/components/mail/MailToolButton.vue' -import { useMailNavigation } from '@/composables/useMailNavigation' import { useMessageRemoval } from '@/composables/useMessageRemoval' import { MAIL_BULK_TEXT } from '@/shared/constants/mail' import type { MessageSummary } from '@/shared/types/mail' -import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source' import { useMailStore } from '@/stores/mail.store' interface Props { @@ -26,10 +23,8 @@ interface Props { const props = defineProps() -const router = useRouter() const mailStore = useMailStore() const removal = useMessageRemoval() -const { backToSourceList } = useMailNavigation() const working = ref(false) const confirmOpen = ref(false) @@ -43,13 +38,7 @@ async function run(kind: 'trash' | 'purge') { working.value = true try { const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target) - if (!outcome.removed || !source) return - if (props.layout === 'list') { - // 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead) - await backToSourceList(source) - return - } - await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source)) + await removal.leaveRemoved(outcome, source, props.layout) } finally { working.value = false } diff --git a/frontend/src/components/mail/MessageMoreMenu.vue b/frontend/src/components/mail/MessageMoreMenu.vue new file mode 100644 index 0000000..b36d2db --- /dev/null +++ b/frontend/src/components/mail/MessageMoreMenu.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts index 13b14f9..bfc6466 100644 --- a/frontend/src/components/mail/MessageReader.spec.ts +++ b/frontend/src/components/mail/MessageReader.spec.ts @@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' import { useToastStore } from '@/stores/toast.store' import { + fail, flushPromises, installMockApi, ok, @@ -457,3 +458,149 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를 expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제') }) }) + +describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자인 원본 MailActions4)', () => { + let pinia: Pinia + let requests: MockRequest[] + /** 내려받기를 시작하는 임시 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */ + let downloads: (string | null)[] + + const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 } + const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' } + const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' } + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) + downloads = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + downloads.push(this.getAttribute('href')) + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + async function mountWith( + options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {}, + ) { + const box = options.box ?? 'inbox' + const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH] + requests = installMockApi((request) => { + if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) + if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) + if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } }) + if (request.url === '/messages' && request.method === 'GET') { + return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }) + } + if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] }) + return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }) + }) + const store = useMailStore() + store.mailboxes = mailboxes + store.listSource = { kind: 'mailbox', mailboxId: box } + const open = { ...DETAIL, mailboxIds: [box] } + store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }] + store.pageInfo = { hasNext: false, nextCursor: null, total: 2 } + store.listStatus = 'ready' + store.selected = open + store.selectedId = open.id + store.selectedStatus = 'ready' + const router = createTestRouter() + await router.push(`/mail/${box}/m1`) + await router.isReady() + const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body }) + return { wrapper, router, store } + } + + const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data) + + it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => { + const { wrapper } = await mountWith() + const toolbar = wrapper.get('.reader__toolbar') + + expect(toolbar.findAll('.tool-button').map((button) => button.text())).toEqual([ + '답장', + '전체 답장', + '전달', + '스팸 신고', + '삭제', + ]) + const more = toolbar.get('[data-reader-more]') + expect(more.attributes('aria-label')).toBe('더 보기') + expect(more.attributes('aria-haspopup')).toBe('menu') + expect(toolbar.element.lastElementChild?.contains(more.element)).toBe(true) + }) + + it('스팸 신고 — 스팸메일함으로 옮기며 isJunk: true, 다음 메일을 연다 · [되돌리기]는 원래 메일함 · isJunk: false', async () => { + const { wrapper, router, store } = await mountWith() + + await wrapper.get('[data-reader-spam]').trigger('click') + await flushPromises() + + expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }]) + expect(store.messages.map((message) => message.id)).toEqual(['m2']) + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2') + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.') + + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false }) + }) + + it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => { + const { wrapper } = await mountWith({ box: 'junk' }) + const notSpam = wrapper.get('[data-reader-spam]') + expect(notSpam.text()).toBe('스팸 아님') + expect(notSpam.classes()).toContain('tool-button--accent') + + await notSpam.trigger('click') + await flushPromises() + + expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }]) + expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).') + }) + + it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => { + const { wrapper } = await mountWith({ mailboxes: [INBOX, TRASH] }) + + expect(wrapper.find('[data-reader-spam]').exists()).toBe(false) + }) + + it('⋯ → "원문 내려받기 (.eml)" — 이동하기 전에 세션을 확인하고(confirmSession) 원문 주소로 내려받는다', async () => { + const { wrapper } = await mountWith() + const more = wrapper.get('[data-reader-more]') + + await more.trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + const items = wrapper.findAll('[role="menu"] [role="menuitem"]') + expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)']) + expect(document.activeElement).toBe(items[0]?.element) + + await items[0]?.trigger('click') + await flushPromises() + + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me']) + expect(downloads).toEqual(['/api/messages/m1/source']) + expect(wrapper.find('[role="menu"]').exists()).toBe(false) + expect(document.activeElement).toBe(more.element) + }) + + it('세션을 확인하지 못하면(갱신도 401) 내려받지 않고 세션 만료로 다룬다', async () => { + const { wrapper } = await mountWith({ me: fail(401, 'AUTH_001'), refresh: fail(401, 'AUTH_001') }) + + await wrapper.get('[data-reader-more]').trigger('click') + await flushPromises() + await wrapper.get('[role="menuitem"]').trigger('click') + await flushPromises() + + expect(downloads).toEqual([]) + expect(useAuthStore().sessionExpired).toBe(true) + }) +}) diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index 93ad7cc..cf8d0ae 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -3,7 +3,8 @@ // 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다. // 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통. // 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색"). -// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. +// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문"). +// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu. // 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다. import { computed } from 'vue' @@ -15,6 +16,8 @@ import MailToolButton from '@/components/mail/MailToolButton.vue' import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue' import MessageAttachments from '@/components/mail/MessageAttachments.vue' import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue' +import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue' +import MessageSpamButton from '@/components/mail/MessageSpamButton.vue' import { useMailNavigation } from '@/composables/useMailNavigation' import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail' import { formatMailDateTime, initialOf } from '@/shared/utils/format' @@ -99,10 +102,15 @@ function leaveToList() { :to="{ name: 'mail-compose', query: { forward: message.id } }" /> + + diff --git a/frontend/src/components/mail/MessageSpamButton.vue b/frontend/src/components/mail/MessageSpamButton.vue new file mode 100644 index 0000000..6ad67f5 --- /dev/null +++ b/frontend/src/components/mail/MessageSpamButton.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/components/mail/editor/EditorMenu.vue b/frontend/src/components/mail/editor/EditorMenu.vue index ae4e464..b25a9a0 100644 --- a/frontend/src/components/mail/editor/EditorMenu.vue +++ b/frontend/src/components/mail/editor/EditorMenu.vue @@ -1,6 +1,7 @@ + + + + diff --git a/frontend/src/components/settings/PasswordField.vue b/frontend/src/components/settings/PasswordField.vue new file mode 100644 index 0000000..b6dc1ad --- /dev/null +++ b/frontend/src/components/settings/PasswordField.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/frontend/src/components/settings/SettingsPageFrame.vue b/frontend/src/components/settings/SettingsPageFrame.vue new file mode 100644 index 0000000..1343ca6 --- /dev/null +++ b/frontend/src/components/settings/SettingsPageFrame.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/settings/VacationForm.vue b/frontend/src/components/settings/VacationForm.vue new file mode 100644 index 0000000..6d1c2f6 --- /dev/null +++ b/frontend/src/components/settings/VacationForm.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/frontend/src/components/settings/VacationMessageFields.vue b/frontend/src/components/settings/VacationMessageFields.vue new file mode 100644 index 0000000..fc70292 --- /dev/null +++ b/frontend/src/components/settings/VacationMessageFields.vue @@ -0,0 +1,143 @@ + + +