feat(frontend): 설정(비밀번호 · 부재중 응답) · 메일함 관리 · 스팸 신고 · 원문 내려받기 화면

- 설정 메뉴를 계정 · 메일 묶음으로, 비밀번호 바꾸기(칸 아래 오류 · 30초 한도)와 부재중 자동 응답(기간 · 미리 보기 · 나가기 확인),
  메일함 패널의 부재중 안내(1분마다 다시 계산)
- 메일함 패널의 내 메일함 묶음 · ⋯ 메뉴(보이는 영역 안에 열기) · 대화상자(만들기 · 이름 바꾸기 · 지우기), 목록 머리 비우기
  — 확인한 그 메일함만, 휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다
- 오래 걸리는 처리의 진행 알림("…하는 중 · 600 / 1,234통" → 결과), 세션이 바뀌면 멈추고 늦게 온 응답은 쓰지 않는다
- 읽기 화면 · 여러 통 도구 줄의 스팸 신고 / 스팸 아님 + 되돌리기, ⋯ › 원문 내려받기 (.eml)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 06:47:12 +09:00
parent 05fc3909dd
commit af1e581be1
86 changed files with 7114 additions and 302 deletions
+4
View File
@@ -2,6 +2,7 @@
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
import AppShell from '@/components/shell/AppShell.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
@@ -9,6 +10,7 @@ import { useClock } from '@/composables/useClock'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
@@ -19,6 +21,8 @@ void mailStore.ensureMailboxes()
void mailStore.ensureQuota()
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureSignature()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
useMailStatePolling({
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.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 AccountPasswordPage from './AccountPasswordPage.vue'
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CURRENT = 'old-pass-1234'
const NEXT = 'Ara!2026-mail'
describe('AccountPasswordPage', () => {
let pinia: Pinia
let requests: MockRequest[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null)) {
requests = installMockApi((request) => (request.url === '/auth/password' ? reply(request) : ok(null)))
const router = createTestRouter()
await router.push('/settings/account/password')
await router.isReady()
const wrapper = mount(AccountPasswordPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
await flushPromises()
return wrapper
}
async function fill(wrapper: VueWrapper, values: { current?: string; next?: string; confirm?: string }) {
if (values.current !== undefined) await wrapper.get('#password-current').setValue(values.current)
if (values.next !== undefined) await wrapper.get('#password-next').setValue(values.next)
if (values.confirm !== undefined) await wrapper.get('#password-confirm').setValue(values.confirm)
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
const passwordRequests = () => requests.filter((request) => request.url === '/auth/password')
const errorOf = (wrapper: VueWrapper, id: string) => wrapper.find(`#${id}-error`)
it('칸 셋 · 안내 · 안내 상자 — 앞의 두 칸에 보기 단추(aria-pressed), 확인 칸은 새 비밀번호를 따른다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('비밀번호')
expect(wrapper.findAll('label').map((label) => label.text())).toEqual(['지금 비밀번호', '새 비밀번호', '새 비밀번호 확인'])
expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password')
expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password')
expect(wrapper.get('#password-next-hint').text()).toBe(
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
)
expect(wrapper.get('[data-password-note]').text()).toContain('메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.')
const toggles = wrapper.findAll('.password-field__toggle')
expect(toggles.map((toggle) => toggle.attributes('aria-label'))).toEqual(['지금 비밀번호 보기', '새 비밀번호 보기'])
await toggles[1]?.trigger('click')
expect(toggles[1]?.attributes('aria-pressed')).toBe('true')
// 이름은 그대로다 — 켜짐은 aria-pressed 로만
expect(toggles[1]?.attributes('aria-label')).toBe('새 비밀번호 보기')
expect(wrapper.get('#password-next').attributes('type')).toBe('text')
expect(wrapper.get('#password-confirm').attributes('type')).toBe('text')
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
})
it('보내기 전에 규칙을 본다 — 8자 미만이면 안내 글이 빨간 오류, 확인 칸이 다르면 "새 비밀번호가 서로 다릅니다." · 보내지 않는다', async () => {
const wrapper = await mountPage()
await fill(wrapper, { current: CURRENT, next: 'short', confirm: 'shorter' })
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
expect(errorOf(wrapper, 'password-confirm').text()).toBe('새 비밀번호가 서로 다릅니다.')
expect(document.activeElement?.id).toBe('password-next')
// 고치면 그 칸의 오류를 내린다
await fill(wrapper, { next: NEXT })
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('false')
expect(errorOf(wrapper, 'password-confirm').exists()).toBe(false)
})
it('지금 비밀번호와 같으면 보내지 않는다 · 지금 비밀번호를 비우면 그 칸에 사전 문구', async () => {
const wrapper = await mountPage()
await fill(wrapper, { current: NEXT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
await fill(wrapper, { current: '' })
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(document.activeElement?.id).toBe('password-current')
})
it('바꾸면 PUT /auth/password { currentPassword, newPassword } — "비밀번호를 바꿨습니다…" 를 보이고 세 칸을 비운다', async () => {
const wrapper = await mountPage()
await wrapper.findAll('.password-field__toggle')[0]?.trigger('click')
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests().map((request) => [request.method, request.data])).toEqual([
['PUT', { currentPassword: CURRENT, newPassword: NEXT }],
])
// 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초
expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000)
expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.')
for (const id of ['password-current', 'password-next', 'password-confirm']) {
expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('')
}
// 보기도 끈다
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
expect(useAuthStore().isLoggedIn).toBe(true)
// 다시 치면 내린다
await fill(wrapper, { current: 'x' })
expect(wrapper.get('#password-status').text()).toBe('')
})
it('지금 비밀번호가 틀리면(400 AUTH_005) 알림 없이 지금 비밀번호 칸 아래에 사전 문구 — 세션은 그대로', async () => {
const wrapper = await mountPage(() => fail(400, 'AUTH_005'))
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
expect(ERROR_CODE_MESSAGES.AUTH_005).toBe('지금 비밀번호가 올바르지 않습니다.')
expect(wrapper.get('#password-current').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('password-current')
expect(useToastStore().toasts).toEqual([])
expect(useAuthStore().isLoggedIn).toBe(true)
// 쓴 것은 남긴다 — 지금 비밀번호만 고쳐 다시 보낼 수 있게
expect((wrapper.get('#password-next').element as HTMLInputElement).value).toBe(NEXT)
await fill(wrapper, { current: CURRENT })
expect(errorOf(wrapper, 'password-current').exists()).toBe(false)
})
it('엔진 규칙에 어긋나면(400 VAL_001) 새 비밀번호 칸 아래에 사전 문구 · 그 밖의 실패(MAIL_001)는 인터셉터 알림', async () => {
let reply: MockResult = fail(400, 'VAL_001')
const wrapper = await mountPage(() => reply)
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(errorOf(wrapper, 'password-next').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
reply = fail(503, 'MAIL_001')
await submit(wrapper)
expect(errorOf(wrapper, 'password-next').exists()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
it('기다리는 사이 화면을 떠났는데 지금 비밀번호가 틀렸으면(AUTH_005) 볼 칸이 없으니 알림으로', async () => {
let finish: (result: MockResult) => void = () => undefined
const wrapper = await mountPage(
() =>
new Promise<MockResult>((resolve) => {
finish = resolve
}),
)
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests()).toHaveLength(1)
wrapper.unmount()
finish(fail(400, 'AUTH_005'))
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', ERROR_CODE_MESSAGES.AUTH_005],
])
expect(useAuthStore().isLoggedIn).toBe(true)
})
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호") — /settings/account/password · 설정의 첫 화면
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣는 것
import AppIcon from '@/components/AppIcon.vue'
import PasswordChangeForm from '@/components/settings/PasswordChangeForm.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { PASSWORD_TEXT } from '@/shared/constants/account'
</script>
<template>
<SettingsPageFrame
title-id="password-page-title"
:title="PASSWORD_TEXT.title"
:description="PASSWORD_TEXT.description"
>
<PasswordChangeForm />
<div
class="password-note"
data-password-note
>
<AppIcon
name="info"
class="password-note__icon"
/>
<div class="password-note__text">
<span>{{ PASSWORD_TEXT.noteSessions }}</span>
<span>{{ PASSWORD_TEXT.noteImap }}</span>
</div>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.password-note {
display: flex;
align-items: flex-start;
gap: var(--space-10);
max-width: var(--note-max-width);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.password-note__icon {
margin-top: var(--space-2);
color: var(--color-brand-strong);
}
.password-note__text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
</style>
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailVacation } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.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, routeViewProvide } from '@/test-utils/router'
import MailVacationPage from './MailVacationPage.vue'
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 설정한 적 없는 계정 — 꺼짐 · 빈 값 */
const NEVER: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
const SAVED: MailVacation = {
isEnabled: true,
fromDate: startOfDayIso('2026-10-30'),
toDate: endOfDayIso('2026-11-03'),
subject: '부재중 안내 — 11월 4일 복귀',
textBody: '안녕하세요, 김아라입니다.\n급한 일은 인프라팀으로 연락해 주세요.',
}
interface Server {
vacation?: MailVacation | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MailVacationPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
requests = installMockApi((request) => {
if (request.method === 'GET' && request.url === '/mail-vacation') {
const vacation = server.vacation ?? NEVER
return typeof vacation === 'string' || 'status' in vacation ? vacation : ok(vacation)
}
if (request.method === 'PUT' && request.url === '/mail-vacation') {
return server.save ? server.save(request) : ok(request.data)
}
return ok(null)
})
const router = createTestRouter()
await router.push('/settings/mail/vacation')
await router.isReady()
const wrapper = mount(MailVacationPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
const value = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
async function save(wrapper: VueWrapper) {
await wrapper.get('#vacation-save').trigger('click')
await flushPromises()
}
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸, 끈 동안은 아래 칸이 흐리다', async () => {
const { wrapper } = await mountPage()
expect(wrapper.get('h1').text()).toBe('부재중 자동 응답')
expect((wrapper.get('#vacation-enabled').element as HTMLInputElement).checked).toBe(false)
expect((wrapper.get('#vacation-period-until-off').element as HTMLInputElement).checked).toBe(true)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('200')
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('4000')
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
})
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
expect(wrapper.get('#vacation-period-hint').text()).toBe(
'시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
const preview = wrapper.get('[data-vacation-preview]')
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
)
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
let reply: MailVacation | MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountPage({
get vacation() {
return reply
},
})
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
reply = SAVED
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(wrapper.find('#vacation-enabled').exists()).toBe(true)
})
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
const { wrapper } = await mountPage()
await wrapper.get('#vacation-enabled').setValue(true)
await wrapper.get('#vacation-period-range').setValue(true)
// 날짜가 비었으면 시작을 오늘로 채운다
expect(value(wrapper, '#vacation-from')).toBe(formatDate(new Date()))
await wrapper.get('#vacation-from').setValue('2026-10-30')
await wrapper.get('#vacation-to').setValue('2026-11-03')
await wrapper.get('#vacation-subject').setValue('부재중 안내')
await wrapper.get('#vacation-body').setValue('휴가입니다.')
await save(wrapper)
expect(puts()).toEqual([
{
isEnabled: true,
fromDate: new Date(2026, 9, 30).toISOString(),
toDate: new Date(2026, 10, 4).toISOString(),
subject: '부재중 안내',
textBody: '휴가입니다.',
},
])
expect(wrapper.get('#vacation-save-status').text()).toBe(
'저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.',
)
expect(useMailVacationStore().vacation?.isEnabled).toBe(true)
// 다시 바꾸면 내린다
await wrapper.get('#vacation-subject').setValue('부재중 안내 (수정)')
expect(wrapper.get('#vacation-save-status').text()).toBe('')
})
it('켜 둘 때는 제목 · 내용 · 기간을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다', async () => {
const { wrapper } = await mountPage()
await wrapper.get('#vacation-enabled').setValue(true)
await wrapper.get('#vacation-period-range').setValue(true)
await wrapper.get('#vacation-from').setValue('2026-11-05')
await wrapper.get('#vacation-to').setValue('2026-11-03')
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-period-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-subject').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('vacation-from')
// 고친 칸의 문구만 내린다
await wrapper.get('#vacation-subject').setValue('부재중 안내')
expect(wrapper.find('#vacation-subject-error').exists()).toBe(false)
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
})
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-enabled').setValue(false)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
// 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다
const { wrapper: fresh } = await mountPage()
await save(fresh)
expect(puts().at(-1)).toEqual(NEVER)
})
it('끌 때까지 계속 — 두 날짜 모두 null · "끌 때까지 자동으로 답합니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-period-until-off').setValue(true)
expect(wrapper.find('#vacation-from').exists()).toBe(false)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, fromDate: null, toDate: null }])
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
})
it('서버가 거절하면(VAL_001) 알림 없이 저장 단추 옆에 사전 문구', async () => {
const { wrapper } = await mountPage({ vacation: SAVED, save: () => fail(400, 'VAL_001') })
await save(wrapper)
expect(wrapper.get('#vacation-save-status').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
})
it('저장하는 사이 화면을 떠났는데 서버가 거절하면(VAL_001) 볼 곳이 없으니 알림으로 — 떠날 때는 묻지 않는다', async () => {
let finish: (result: MockResult) => void = () => undefined
const { wrapper, router } = await mountPage({
vacation: SAVED,
save: () =>
new Promise<MockResult>((resolve) => {
finish = resolve
}),
})
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
await save(wrapper)
expect(wrapper.get('#vacation-save').attributes('aria-busy')).toBe('true')
await router.push('/mail/inbox')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
wrapper.unmount()
finish(fail(400, 'VAL_001'))
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', ERROR_CODE_MESSAGES.VAL_001],
])
})
describe('나가기 확인 (서명 설정과 같다)', () => {
const dialogOpen = () => document.getElementById('vacation-leave-dialog')?.hasAttribute('open') ?? false
it('바꾸고 떠나면 묻는다 — [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
const { wrapper, router } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
const stay = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(true)
expect(wrapper.get('#vacation-leave-title').text()).toBe('저장하지 않은 자동 응답이 있습니다')
expect(wrapper.get('#vacation-leave-message').text()).toBe('이 화면을 나가면 바꾼 내용이 사라집니다.')
expect(document.activeElement?.id).toBe('vacation-leave-stay')
await wrapper.get('#vacation-leave-stay').trigger('click')
await stay
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/vacation')
const leave = router.push('/mail/inbox')
await flushPromises()
await wrapper.get('#vacation-leave-confirm').trigger('click')
await leave
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('되돌렸거나 저장했으면 묻지 않는다', async () => {
const { wrapper, router } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
await wrapper.get('#vacation-subject').setValue(SAVED.subject)
await wrapper.get('#vacation-body').setValue('바꾼 내용')
await save(wrapper)
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
})
})
@@ -0,0 +1,51 @@
<script setup lang="ts">
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import VacationForm from '@/components/settings/VacationForm.vue'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
const vacationStore = useMailVacationStore()
function reload() {
void vacationStore.load()
}
// 처음 그리기 전에 — 지난번에 받아 둔 값으로 칸을 잠깐 그렸다 지우지 않게
reload()
</script>
<template>
<SettingsPageFrame
title-id="vacation-page-title"
:title="VACATION_TEXT.title"
:description="VACATION_TEXT.description"
>
<MailLoadError
v-if="vacationStore.status === 'error' && vacationStore.error"
:error="vacationStore.error"
leave-label=""
@retry="reload"
/>
<VacationForm
v-else-if="vacationStore.status === 'ready' && vacationStore.vacation"
:vacation="vacationStore.vacation"
/>
<div
v-else
class="vacation-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* 받는 동안의 자리 — 사용 · 기간 · 제목 · 내용 칸만큼 */
.vacation-page__placeholder {
min-height: 22rem;
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
</style>
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { routes } from '@/router/routes'
import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
describe('SettingsLayout', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
document.body.innerHTML = ''
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(SettingsLayout, {
global: { plugins: [pinia, router], stubs: { RouterView: { render: () => h('div') } } },
})
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일'])
const lists = menu.findAll('ul')
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
[['비밀번호', '/settings/account/password']],
[
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
],
])
})
it('지금 화면의 항목만 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/vacation')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]')
expect(current.map((link) => link.text())).toEqual(['부재중 자동 응답'])
expect(current[0]?.classes()).toContain('is-active')
})
it('/settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 — 앱의 라우트 표 그대로', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings')
expect(router.currentRoute.value.name).toBe('settings-account-password')
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
})
})
+43 -24
View File
@@ -1,8 +1,14 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음("메일") 아래 항목("서명")으로 늘어난다. 지금은 메일 서명만 있다.
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { SETTINGS_MENU } from '@/shared/constants/settings-menu'
function groupLabelId(groupId: string): string {
return `settings-menu-${groupId}`
}
</script>
<template>
@@ -11,30 +17,38 @@ import AppShell from '@/components/shell/AppShell.vue'
class="settings-menu"
aria-label="설정 메뉴"
>
<p
id="settings-menu-mail"
class="settings-menu__group"
<template
v-for="group in SETTINGS_MENU"
:key="group.id"
>
메일
</p>
<ul
class="settings-menu__list"
aria-labelledby="settings-menu-mail"
>
<li>
<RouterLink
:to="{ name: 'settings-mail-signature' }"
class="settings-menu__link"
active-class="is-active"
<p
:id="groupLabelId(group.id)"
class="settings-menu__group"
>
{{ group.label }}
</p>
<ul
class="settings-menu__list"
:aria-labelledby="groupLabelId(group.id)"
>
<li
v-for="item in group.items"
:key="item.routeName"
>
<AppIcon
name="edit"
:size="16"
/>
<span>서명</span>
</RouterLink>
</li>
</ul>
<RouterLink
:to="{ name: item.routeName }"
class="settings-menu__link"
active-class="is-active"
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</template>
</nav>
<RouterView />
</AppShell>
@@ -59,6 +73,11 @@ import AppShell from '@/components/shell/AppShell.vue'
color: var(--color-text-muted);
}
/* 둘째 묶음부터 위를 띄운다 (디자인 원본 SettingsAccount — 묶음 사이 14) */
.settings-menu__list + .settings-menu__group {
margin-top: var(--space-14);
}
.settings-menu__list {
display: flex;
flex-direction: column;