generated from ttipo/project-base
feat(frontend): 주소록 가져오기 · 내보내기 · 여러 서명과 서명 바꾸기 · 부재중 서식 · 휴대폰 바꾸기
메일 16단계(개인 설정)의 화면이다(보드 Contacts16 · Signature16 · Settings16). - 주소록: 브라우저가 vCard · CSV · 엑셀(.xlsx)을 읽어 미리 보기(파일은 서버로 가지 않는다 — 새 의존성 없이 DecompressionStream · DOMParser), 네이버 · Google · Outlook 머리를 알아보고 칸 없는 정보는 메모 끝에, 묶음(100명 · 값 8,000 · 700KB)으로 넣고 ✕까지 남는 결과 알림의 [되돌리기], 내보내기 창. - 서명: 왼쪽 목록 · 오른쪽 편집 · 메일에 넣을 서명 둘, 쓰기 화면 [서명 ▾]로 서명 자리만 바꾸기 (앱과 같은 경계 — 앞 빈 줄을 지켜 임시 저장 · 이어 쓰기에서도 찾는다). - 부재중: 내용 편집기(서식이 있을 때만 서식 본문) · 회사 밖에서 온 메일 응답 셋 · 미리 보기 전환. - 2단계 인증: [휴대폰 바꾸기] 두 걸음 창(비밀은 창 안에만). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,780 @@
|
||||
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 { h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
|
||||
|
||||
import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { CONTACT_IMPORT_ERROR_TEXT, CONTACT_IMPORT_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
|
||||
import type { Contact, ContactGroup, ContactImportItem } from '@/shared/types/contacts'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useContactImportStore } from '@/stores/contact-import.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import { buildXlsx } from '@/test-utils/zip'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import ContactsLayout from './ContactsLayout.vue'
|
||||
import ContactsPage from './ContactsPage.vue'
|
||||
|
||||
// 주소록 가져오기 · 내보내기 (16단계 — 디자인 원본 Contacts16 · 계약 3절 "가져오기 · 내보내기")
|
||||
// 서버는 흉내다 — 확인(이메일이 주소록에 있으면 exists) · 넣기(uid 마다 하나, 같은 uid 는 다시 만들지 않는다) · 여러 명 지우기 · 그룹
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const Empty: FunctionalComponent = () => h('div')
|
||||
Empty.displayName = 'Empty'
|
||||
/** 손 시계의 지금 — 2026-11-10 (이 PC 의 날짜 — 내보낼 파일 이름) */
|
||||
const NOW = new Date(2026, 10, 10, 9, 30).getTime()
|
||||
|
||||
function contact(id: string, name: string, email: string | null, patch: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
emails: email ? [{ address: email, label: 'work' }] : [],
|
||||
phones: [],
|
||||
company: null,
|
||||
department: null,
|
||||
title: null,
|
||||
note: null,
|
||||
groupIds: [],
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
contacts: Contact[]
|
||||
groups: ContactGroup[]
|
||||
/** GET /contacts?limit=1 의 total — 한도 시험 */
|
||||
total?: number
|
||||
/** 확인이 주는 남은 자리 — 주지 않으면 3,000 − 연락처 수 */
|
||||
remaining?: number
|
||||
/** 넣기 요청마다 — 주면 그 결과를 쓴다(undefined 면 보통대로) */
|
||||
importReply?: (request: MockRequest, index: number) => MockResult | undefined
|
||||
/** 그룹을 만든 뒤 — 주면 그 결과를 쓴다(만들고 응답만 잃은 경우) */
|
||||
groupCreateReply?: (created: ContactGroup) => MockResult | undefined
|
||||
}
|
||||
|
||||
function pageOf<T>(items: T[], total: number = items.length): PaginatedResult<T> {
|
||||
return { items, pageInfo: { hasNext: false, nextCursor: null, total } }
|
||||
}
|
||||
|
||||
function groupsOf(state: ServerState): ContactGroup[] {
|
||||
return state.groups.map((group) => ({
|
||||
...group,
|
||||
memberCount: state.contacts.filter((item) => item.groupIds.includes(group.id)).length,
|
||||
}))
|
||||
}
|
||||
|
||||
function serve(state: ServerState, request: MockRequest, imports: { count: number }, uids: Map<string, string>): MockResult {
|
||||
const { method, url, params } = request
|
||||
const id = decodeURIComponent(url.split('/')[2] ?? '')
|
||||
if (url === '/auth/me') return ok({ user: USER, restriction: null, twoFactorDeadline: null })
|
||||
if (url === '/contact-groups' && method === 'GET') return ok(pageOf(groupsOf(state)))
|
||||
if (url === '/contact-groups' && method === 'POST') {
|
||||
const body = request.data as { name: string }
|
||||
if (state.groups.some((group) => group.name.toLowerCase() === body.name.toLowerCase())) return fail(409, 'CONTACT_001')
|
||||
const created: ContactGroup = { id: `ng${state.groups.length + 1}`, name: body.name, memberCount: 0 }
|
||||
state.groups.push(created)
|
||||
return state.groupCreateReply?.(created) ?? ok(created, 201)
|
||||
}
|
||||
if (url.startsWith('/contact-groups/') && method === 'DELETE') {
|
||||
const group = state.groups.find((item) => item.id === id)
|
||||
if (!group) return fail(404, 'RES_001')
|
||||
state.groups = state.groups.filter((item) => item.id !== id)
|
||||
return ok({ id, name: group.name, memberIds: [], restoreToken: null })
|
||||
}
|
||||
if (url === '/contact-import-checks') {
|
||||
const items = (request.data as { contacts: ContactImportItem[] }).contacts
|
||||
const known = new Set(state.contacts.flatMap((item) => item.emails.map((email) => email.address.toLowerCase())))
|
||||
return ok({
|
||||
items: items.map((item) => ({
|
||||
uid: item.uid,
|
||||
status: item.emails.some((email) => known.has(email.address.toLowerCase())) ? 'exists' : 'new',
|
||||
})),
|
||||
remaining: state.remaining ?? 3000 - state.contacts.length,
|
||||
})
|
||||
}
|
||||
if (url === '/contact-imports') {
|
||||
imports.count += 1
|
||||
const custom = state.importReply?.(request, imports.count)
|
||||
if (custom) return custom
|
||||
const items = (request.data as { contacts: ContactImportItem[] }).contacts
|
||||
const created = items.map((item) => {
|
||||
const existing = uids.get(item.uid)
|
||||
if (existing) return { uid: item.uid, id: existing, skippedGroupIds: [] }
|
||||
const newId = `c${state.contacts.length + 100}`
|
||||
uids.set(item.uid, newId)
|
||||
state.contacts.push({
|
||||
id: newId,
|
||||
name: item.name,
|
||||
emails: item.emails,
|
||||
phones: item.phones,
|
||||
company: item.company,
|
||||
department: item.department,
|
||||
title: item.title,
|
||||
note: item.note,
|
||||
groupIds: item.groupIds,
|
||||
})
|
||||
return { uid: item.uid, id: newId, skippedGroupIds: [] }
|
||||
})
|
||||
return ok({ items: created }, 201)
|
||||
}
|
||||
if (url === '/contact-deletions') {
|
||||
const ids = (request.data as { ids: string[] }).ids
|
||||
const deletedIds = ids.filter((item) => state.contacts.some((existing) => existing.id === item))
|
||||
state.contacts = state.contacts.filter((item) => !ids.includes(item.id))
|
||||
return ok({ deletedIds, notFoundIds: ids.filter((item) => !deletedIds.includes(item)) })
|
||||
}
|
||||
if (url === '/contacts' && method === 'GET') {
|
||||
if (params.limit === 1 && params.groupId === undefined && params.q === undefined) {
|
||||
return ok(pageOf([], state.total ?? state.contacts.length))
|
||||
}
|
||||
const items = state.contacts.filter((item) => params.groupId === undefined || item.groupIds.includes(String(params.groupId)))
|
||||
return ok(pageOf(items, state.total ?? items.length))
|
||||
}
|
||||
if (url === '/directory-entries') return ok(pageOf([]))
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
function testRouter(): Router {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Empty },
|
||||
{ path: '/login', name: 'login', component: Empty },
|
||||
{ path: '/mail', name: 'mail', component: Empty },
|
||||
{ path: '/mail/compose', name: 'mail-compose', component: Empty },
|
||||
{ path: '/settings', name: 'settings', component: Empty },
|
||||
{
|
||||
path: '/contacts',
|
||||
component: ContactsLayout,
|
||||
children: [
|
||||
{ path: '', name: 'contacts', component: ContactsPage },
|
||||
{ path: 'all/:contactId', name: 'contacts-contact', component: ContactsPage },
|
||||
{ path: 'groups/:groupId', name: 'contacts-group', component: ContactsPage },
|
||||
{ path: 'groups/:groupId/:contactId', name: 'contacts-group-contact', component: ContactsPage },
|
||||
{ path: 'directory/:kind(people|shared)', name: 'contacts-directory', component: ContactsPage },
|
||||
{ path: 'directory/:kind(people|shared)/:entryId', name: 'contacts-directory-entry', component: ContactsPage },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/** 네이버 꼴 CSV — 값마다 앞에 ' , UTF-8 BOM */
|
||||
function naverCsv(rows: string[][]): File {
|
||||
const header = ['성', '이름', '휴대폰번호', '이메일', '그룹명', '회사·소속명', '부서명', '직책', '회사주소', '메모']
|
||||
const lines = [header, ...rows].map((cells, index) => (index === 0 ? cells : cells.map((cell) => `'${cell}`)).join(','))
|
||||
return new File([`\uFEFF${lines.join('\n')}\n`], 'NAVER_Contacts.csv', { type: 'text/csv' })
|
||||
}
|
||||
|
||||
describe('주소록 가져오기 · 내보내기 (16단계)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let state: ServerState
|
||||
let imports: { count: number }
|
||||
let uids: Map<string, string>
|
||||
let downloads: string[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession(USER)
|
||||
imports = { count: 0 }
|
||||
uids = new Map()
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href') ?? '')
|
||||
})
|
||||
state = {
|
||||
contacts: [
|
||||
contact('c1', '박지훈', 'jihoon.park@example.com', { groupIds: ['g1'] }),
|
||||
contact('c2', '오세훈', 'sehun.oh@example.net'),
|
||||
],
|
||||
groups: [
|
||||
{ id: 'g1', name: '거래처', memberCount: 0 },
|
||||
{ id: 'g2', name: '세무', memberCount: 0 },
|
||||
{ id: 'g3', name: '협력사 담당', memberCount: 0 },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountApp(path = '/contacts') {
|
||||
requests = installMockApi((request) => serve(state, request, imports, uids))
|
||||
const router = testRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const clock = createManualClock(NOW)
|
||||
const app = mount(
|
||||
{ render: () => [h(RouterView), h(ToastHost)] },
|
||||
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
|
||||
)
|
||||
mounted.push(app)
|
||||
await flushPromises()
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
/** 조건이 될 때까지 기다린다 — 파일 읽기 · 압축 풀기 · 요청이 여러 번 양보한다 */
|
||||
async function until(condition: () => boolean, tries = 60) {
|
||||
for (let index = 0; index < tries && !condition(); index += 1) await flushPromises()
|
||||
if (!condition()) throw new Error('기다린 조건이 되지 않았다')
|
||||
}
|
||||
|
||||
async function click(target: Pick<DOMWrapper<Element>, 'element' | 'trigger'>) {
|
||||
;(target.element as HTMLElement).focus()
|
||||
await target.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function chooseFile(app: VueWrapper, file: File) {
|
||||
const input = app.get('[data-import-file]')
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change')
|
||||
}
|
||||
|
||||
const toasts = () => useToastStore().toasts
|
||||
const toastMessages = () => toasts().map((toast) => toast.message)
|
||||
const isOpen = (app: VueWrapper, selector: string) => app.find(`${selector}[open]`).exists()
|
||||
|
||||
async function openImport(app: VueWrapper) {
|
||||
await click(app.get('[data-contacts-import]'))
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(true)
|
||||
}
|
||||
|
||||
async function readPreview(app: VueWrapper, file: File) {
|
||||
await chooseFile(app, file)
|
||||
await until(() => app.find('[data-import-found]').exists())
|
||||
}
|
||||
|
||||
describe('「전체 연락처」 머리 · 빈 주소록', () => {
|
||||
it('전체 연락처 머리에만 [가져오기] · [내보내기] — 그룹 머리는 지금 그대로', async () => {
|
||||
const { app, router } = await mountApp()
|
||||
expect(app.get('[data-contacts-import]').text()).toBe('가져오기')
|
||||
expect(app.get('[data-contacts-export]').text()).toBe('내보내기')
|
||||
expect(app.get('[data-contacts-export]').attributes('aria-disabled')).toBeUndefined()
|
||||
|
||||
await router.push('/contacts/groups/g1')
|
||||
await flushPromises()
|
||||
expect(app.find('[data-contacts-import]').exists()).toBe(false)
|
||||
expect(app.find('[data-contacts-add-members]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('연락처가 하나도 없으면 [내보내기]를 끄고 까닭, 빈 칸 안내에 테두리 [가져오기]와 안내 한 줄', async () => {
|
||||
state.contacts = []
|
||||
const { app } = await mountApp()
|
||||
const exportButton = app.get('[data-contacts-export]')
|
||||
expect(exportButton.attributes('aria-disabled')).toBe('true')
|
||||
expect(exportButton.attributes('title')).toBe('내보낼 연락처가 없습니다.')
|
||||
expect(app.get(`#${exportButton.attributes('aria-describedby')}`).text()).toBe('내보낼 연락처가 없습니다.')
|
||||
await click(exportButton)
|
||||
expect(isOpen(app, '[data-contact-export]')).toBe(false)
|
||||
|
||||
const empty = app.get('[data-contacts-empty="first"]')
|
||||
expect(empty.get('[data-contacts-first-import]').text()).toBe('가져오기')
|
||||
expect(empty.findAll('.contacts-list__first-note').map((note) => note.text())).toEqual([
|
||||
'다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다.',
|
||||
'메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.',
|
||||
])
|
||||
await click(empty.get('[data-contacts-first-import]'))
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(true)
|
||||
})
|
||||
|
||||
it('연락처가 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로, 가져오는 동안은 바쁜 표시', async () => {
|
||||
state.total = 3000
|
||||
const { app } = await mountApp()
|
||||
const importButton = app.get('[data-contacts-import]')
|
||||
expect(importButton.attributes('aria-disabled')).toBe('true')
|
||||
expect(importButton.attributes('aria-describedby')).toBe('contacts-full-note')
|
||||
expect(importButton.attributes('title')).toBe(CONTACTS_PANEL_TEXT.fullNote)
|
||||
await click(importButton)
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(false)
|
||||
|
||||
state.total = 10
|
||||
useContactImportStore().busy = 'import'
|
||||
const busy = await mountApp()
|
||||
expect(busy.app.get('[data-contacts-import]').attributes('aria-busy')).toBe('true')
|
||||
await click(busy.app.get('[data-contacts-import]'))
|
||||
expect(isOpen(busy.app, '[data-contact-import]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('가져오기 창 ① 파일 고르기', () => {
|
||||
it('처음 — 글 · [파일 고르기](처음 초점) · 형식 안내 · [취소]만', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
const dialog = app.get('[data-contact-import]')
|
||||
expect(dialog.get('#contact-import-title').text()).toBe('연락처 가져오기')
|
||||
expect(dialog.get('#contact-import-message').text()).toBe(
|
||||
'네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다.',
|
||||
)
|
||||
expect(document.activeElement).toBe(dialog.get('[data-import-pick]').element)
|
||||
expect(dialog.get('.import-picker__formats').text()).toBe(
|
||||
'vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다.',
|
||||
)
|
||||
expect(dialog.get('[data-import-file]').attributes('accept')).toBe('.vcf,.csv,.xlsx')
|
||||
expect(dialog.find('[data-import-submit]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('읽는 동안의 상태 영역은 늘 있고 글만 바뀐다 — 영역이 글과 함께 생기면 화면 낭독기가 읽지 않기도 한다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
const status = app.get('[data-import-status]')
|
||||
expect(status.attributes('role')).toBe('status')
|
||||
expect(status.text()).toBe('')
|
||||
await chooseFile(app, naverCsv([['강', '민지', '', 'minji.kang@example.com', '', '', '', '', '', '']]))
|
||||
expect(app.get('[data-import-status]').element).toBe(status.element)
|
||||
expect(status.text()).toBe(CONTACT_IMPORT_TEXT.reading)
|
||||
expect(app.get('.import-picker__reading').attributes('aria-hidden')).toBe('true')
|
||||
await until(() => app.find('[data-import-found]').exists())
|
||||
})
|
||||
|
||||
it('읽다가 뜻밖에 멈추면 "읽는 중"에 머물지 않고 열지 못한 파일로 칸 아래에 알린다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
const file = new File(['이름,이메일'], '연락처.csv')
|
||||
Object.defineProperty(file, 'size', {
|
||||
get: () => {
|
||||
throw new Error('읽기 실패')
|
||||
},
|
||||
})
|
||||
await chooseFile(app, file)
|
||||
await until(() => app.find('[data-import-error]').exists())
|
||||
expect(app.get('[data-import-error]').text()).toBe(CONTACT_IMPORT_ERROR_TEXT.unreadable)
|
||||
expect(app.find('[data-import-pick]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('끌어 오는 동안 놓는 자리가 바뀌고, 여러 파일을 놓으면 칸 아래 글(알림 없음)', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
const zone = app.get('[data-import-zone]')
|
||||
const transfer = fakeDataTransfer([new File(['a'], 'a.csv'), new File(['b'], 'b.csv')])
|
||||
zone.element.dispatchEvent(dragEvent('dragenter', transfer))
|
||||
await flushPromises()
|
||||
expect(zone.classes()).toContain('is-dragging')
|
||||
expect(zone.text()).toBe('놓으면 이 파일에서 연락처를 찾습니다')
|
||||
|
||||
const drop = dragEvent('drop', transfer)
|
||||
zone.element.dispatchEvent(drop)
|
||||
await flushPromises()
|
||||
expect(drop.defaultPrevented).toBe(true)
|
||||
expect(app.get('[data-import-error]').text()).toBe('파일은 한 번에 하나만 넣을 수 있습니다.')
|
||||
expect(app.get('[data-import-error]').attributes('role')).toBe('alert')
|
||||
expect(zone.classes()).not.toContain('is-dragging')
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['list.xls', 'xls 내용', '옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요.'],
|
||||
['photo.png', '\u0000PNG', '넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요.'],
|
||||
['empty.csv', '날짜,금액\n2026-11-10,3000\n', '이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요.'],
|
||||
['empty.vcf', 'VERSION:3.0\n', '이 파일에서 연락처를 찾지 못했습니다.'],
|
||||
])('읽지 못하면 칸 아래 빨간 글 — %s', async (name, content, message) => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await chooseFile(app, new File([content], name))
|
||||
await until(() => app.find('[data-import-error]').exists())
|
||||
expect(app.get('[data-import-error]').text()).toBe(message)
|
||||
expect(document.activeElement).toBe(app.get('[data-import-pick]').element)
|
||||
expect(requests.some((request) => request.url === '/contact-import-checks')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('가져오기 창 ② 미리 보기 · 넣기 · 되돌리기', () => {
|
||||
const rows = [
|
||||
['강', '민지', '01041203387', 'minji.kang@example.com', '거래처', '한빛상사', '구매팀', '과장', '서울 중구 세종대로 110', '견적 문의는 월말 전에 보내기.'],
|
||||
['박', '지훈', '', 'jihoon.park@example.com', '', '한빛상사', '영업팀', '', '', ''],
|
||||
['김', '도현', '01055210934', 'dohyun.kim@example.net', '거래처', '그린클라우드', '', '', '', ''],
|
||||
['', '', '01090346621', '', '', '', '', '', '', ''],
|
||||
['문', '가은', '', 'gaeun.moon@example.net', '동호회', '', '', '', '', ''],
|
||||
['강', '민지', '', 'MINJI.KANG@example.com', '', '', '', '', '', ''],
|
||||
]
|
||||
|
||||
it('세 묶음 · 그룹 글 · 메모 견본 — 처음 초점은 [연락처 N명 넣기]', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv(rows))
|
||||
const dialog = app.get('[data-contact-import]')
|
||||
|
||||
expect(dialog.get('[data-import-file-name]').text()).toBe('NAVER_Contacts.csv')
|
||||
expect(dialog.get('[data-import-found]').text()).toBe('연락처 6명을 찾았습니다.')
|
||||
expect(dialog.get('[data-import-insert-count]').text()).toBe('3명')
|
||||
expect(dialog.findAll('[data-import-row]').map((row) => row.findAll('[role="cell"]').map((cell) => cell.text()))).toEqual([
|
||||
['강민지', 'minji.kang@example.com', '010-4120-3387', '한빛상사', '거래처'],
|
||||
['김도현', 'dohyun.kim@example.net', '010-5521-0934', '그린클라우드', '거래처'],
|
||||
['문가은', 'gaeun.moon@example.net', '', '', '동호회'],
|
||||
])
|
||||
expect(document.activeElement).toBe(dialog.get('[data-import-submit]').element)
|
||||
expect(dialog.get('[data-import-submit]').text()).toBe('연락처 3명 넣기')
|
||||
|
||||
const toggle = dialog.get('[data-import-duplicates-toggle]')
|
||||
expect(toggle.text()).toContain('이미 있는 연락처는 건너뜁니다')
|
||||
expect(toggle.text()).toContain('2명')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
await click(toggle)
|
||||
expect(toggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(
|
||||
dialog.findAll('[data-import-duplicates] li').map((item) => item.findAll('span').map((span) => span.text())),
|
||||
).toEqual([
|
||||
['3번째 줄', '박지훈jihoon.park@example.com', '박지훈', '주소록에 있음'],
|
||||
['7번째 줄', '강민지MINJI.KANG@example.com', '강민지', '2번째 줄과 같음'],
|
||||
])
|
||||
|
||||
await click(dialog.get('[data-import-problems-toggle]'))
|
||||
expect(dialog.get('[data-import-problems-toggle]').text()).toContain('넣지 못하는 줄')
|
||||
expect(dialog.get('[data-import-problems] li').text()).toContain('5번째 줄')
|
||||
expect(dialog.get('[data-import-problems] li').text()).toContain('010-9034-6621')
|
||||
expect(dialog.get('[data-import-problems] li').text()).toContain('이름과 이메일이 모두 없습니다.')
|
||||
|
||||
expect(dialog.get('[data-import-groups-text]').text()).toBe(
|
||||
'파일에 적힌 그룹 2개 가운데 거래처는 이미 있는 그룹에 넣고 동호회는 새로 만듭니다.',
|
||||
)
|
||||
expect(dialog.get('[data-import-info]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.get('[data-import-info-sample]').text()).toContain('예: 강민지의 메모')
|
||||
expect(dialog.get('[data-import-info-sample]').text()).toContain('회사 주소: 서울 중구 세종대로 110')
|
||||
expect(requests.filter((request) => request.url === '/contact-import-checks')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('[연락처 N명 넣기] — 새 그룹을 먼저 만들고 100명씩 넣는다, 결과는 ✕까지 남고 [되돌리기]가 넣은 연락처 · 새 그룹을 지운다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv(rows))
|
||||
const dialog = app.get('[data-contact-import]')
|
||||
|
||||
await dialog.get('[data-import-group-mode="new"]').setValue(true)
|
||||
await click(dialog.get('[data-import-submit]'))
|
||||
expect(dialog.get('[data-import-group-name]').attributes('aria-invalid')).toBe('true')
|
||||
expect(dialog.get('.import-groups .form-message--error').text()).toBe('그룹 이름을 넣어 주세요.')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-import-group-name]').element)
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(true)
|
||||
|
||||
await dialog.get('[data-import-group-name]').setValue('네이버 주소록')
|
||||
await click(dialog.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'success'))
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(false)
|
||||
|
||||
const groupCreates = requests.filter((request) => request.url === '/contact-groups' && request.method === 'POST')
|
||||
expect(groupCreates.map((request) => (request.data as { name: string }).name)).toEqual(['네이버 주소록', '동호회'])
|
||||
const importRequests = requests.filter((request) => request.url === '/contact-imports')
|
||||
expect(importRequests).toHaveLength(1)
|
||||
expect(requests.indexOf(importRequests[0]!)).toBeGreaterThan(requests.indexOf(groupCreates[1]!))
|
||||
const sent = (importRequests[0]?.data as { contacts: ContactImportItem[] }).contacts
|
||||
expect(sent.map((item) => [item.name, item.groupIds])).toEqual([
|
||||
['강민지', ['g1', 'ng4']],
|
||||
['김도현', ['g1', 'ng4']],
|
||||
['문가은', ['ng5', 'ng4']],
|
||||
])
|
||||
expect(sent[0]?.uid).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
expect(sent[0]?.note).toBe('견적 문의는 월말 전에 보내기.\n\n가져온 정보\n회사 주소: 서울 중구 세종대로 110')
|
||||
expect(sent[0]?.phones).toEqual([{ number: '010-4120-3387', label: 'mobile' }])
|
||||
|
||||
const [result] = toasts()
|
||||
expect(result?.message).toBe('연락처 3명을 넣었습니다. 이미 있던 2명은 건너뛰었습니다.')
|
||||
expect(result?.sticky).toBe(true)
|
||||
expect(app.get('[data-toast-close]').attributes('aria-label')).toBe('알림 닫기')
|
||||
// 끝나면 연락처 수 · 그룹 · 보는 목록을 다시 받는다
|
||||
await until(() => app.get('[data-contacts-count]').text() === '5명')
|
||||
|
||||
await click(app.get('.toast__action'))
|
||||
await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 3명')))
|
||||
expect(toastMessages()).toEqual(['가져온 연락처 3명과 새로 만든 그룹 2개를 지웠습니다.'])
|
||||
const deletions = requests.filter((request) => request.url === '/contact-deletions')
|
||||
expect(deletions.map((request) => (request.data as { ids: string[] }).ids)).toEqual([['c102', 'c103', 'c104']])
|
||||
expect(
|
||||
requests.filter((request) => request.method === 'DELETE').map((request) => request.url),
|
||||
).toEqual(['/contact-groups/ng4', '/contact-groups/ng5'])
|
||||
expect(state.contacts.map((item) => item.id)).toEqual(['c1', 'c2'])
|
||||
})
|
||||
|
||||
it('주소록이 차면 남은 자리만큼 — 한도 안내 · 단추의 수, 결과에 넣지 못한 수', async () => {
|
||||
state.remaining = 1
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv(rows))
|
||||
const dialog = app.get('[data-contact-import]')
|
||||
expect(dialog.get('[data-import-limit]').text()).toBe(
|
||||
'연락처는 3,000개까지 둘 수 있어 앞에서부터 1명만 넣습니다. 나머지 2명은 넣지 않습니다.',
|
||||
)
|
||||
expect(dialog.get('[data-import-submit]').text()).toBe('연락처 1명 넣기')
|
||||
|
||||
await click(dialog.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'success'))
|
||||
expect(toastMessages()).toEqual([
|
||||
'연락처 1명을 넣었습니다. 이미 있던 2명은 건너뛰었습니다. 연락처는 3,000개까지 둘 수 있어 나머지 2명은 넣지 못했습니다.',
|
||||
])
|
||||
})
|
||||
|
||||
it('남은 자리가 하나도 없으면(그사이 주소록이 참) 한도 안내는 패널의 3,000개 안내 · [연락처 넣기]는 꺼진다', async () => {
|
||||
state.remaining = 0
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv(rows))
|
||||
expect(app.get('[data-import-limit]').text()).toBe(CONTACTS_PANEL_TEXT.fullNote)
|
||||
expect(app.get('[data-import-submit]').attributes('aria-disabled')).toBe('true')
|
||||
expect(app.find('[data-import-insert-count]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('넣을 연락처가 없으면 [연락처 넣기]를 끄고(aria-disabled) 까닭을 단추 옆에', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv([rows[1]!]))
|
||||
const submit = app.get('[data-import-submit]')
|
||||
expect(submit.text()).toBe('연락처 넣기')
|
||||
expect(submit.attributes('aria-disabled')).toBe('true')
|
||||
expect(app.get(`#${submit.attributes('aria-describedby')}`).text()).toBe('넣을 연락처가 없습니다.')
|
||||
await click(submit)
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(true)
|
||||
expect(requests.some((request) => request.url === '/contact-imports')).toBe(false)
|
||||
})
|
||||
|
||||
it('도중에 멈추면 오류 알림 + [다시 시도](✕까지 남는다) — 남은 연락처부터 같은 uid 로 이어 넣는다', async () => {
|
||||
const many = Array.from({ length: 150 }, (_, index) => ['이', `사람${index}`, '', `p${index}@example.org`, '', '', '', '', '', ''])
|
||||
state.importReply = (_request, index) => (index === 2 ? 'network' : undefined)
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv(many))
|
||||
await click(app.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'error' && toast.action !== null))
|
||||
|
||||
const stopped = toasts().find((toast) => toast.action !== null)
|
||||
expect(stopped?.message).toBe('연락처 150명 가운데 100명을 넣고 멈췄습니다. 다시 시도해 주세요.')
|
||||
expect(stopped?.sticky).toBe(true)
|
||||
expect(stopped?.action?.label).toBe('다시 시도')
|
||||
const firstBatches = requests.filter((request) => request.url === '/contact-imports')
|
||||
const secondUids = (firstBatches[1]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.uid)
|
||||
expect(secondUids).toHaveLength(50)
|
||||
|
||||
state.importReply = undefined
|
||||
const retry = app.findAll('.toast').find((toast) => toast.text().includes('멈췄습니다'))
|
||||
await click(retry!.get('.toast__action'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'success'))
|
||||
const batches = requests.filter((request) => request.url === '/contact-imports')
|
||||
expect(batches).toHaveLength(3)
|
||||
expect((batches[2]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.uid)).toEqual(secondUids)
|
||||
expect(toastMessages()).toContain('연락처 150명을 넣었습니다.')
|
||||
expect(state.contacts).toHaveLength(152)
|
||||
})
|
||||
|
||||
it('칸을 한도까지 채운 연락처 100명 — 확인 · 넣기 묶음이 값 8,000개에서 나뉜다 (서버 본문 한도 값 10,000 · 1MB 를 넘지 않게)', async () => {
|
||||
/** 서버(json-shape.ts)와 같은 셈 — 맨 바깥 1 + 객체 · 배열마다 항목 수 */
|
||||
const serverValueCount = (body: unknown): number => {
|
||||
let values = 1
|
||||
const pending: unknown[] = [body]
|
||||
while (pending.length > 0) {
|
||||
const next = pending.pop()
|
||||
if (typeof next !== 'object' || next === null) continue
|
||||
const children = Array.isArray(next) ? next : Object.values(next)
|
||||
values += children.length
|
||||
pending.push(...children)
|
||||
}
|
||||
return values
|
||||
}
|
||||
const lines = ['이름,이메일,휴대폰']
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const emails = Array.from({ length: 20 }, (_, k) => `p${index}.${k}@example.org`).join('; ')
|
||||
const phones = Array.from({ length: 20 }, (_, k) => `010-${1000 + index}-${2000 + k}`).join('; ')
|
||||
lines.push(`사람${index},"${emails}","${phones}"`)
|
||||
}
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, new File([lines.join('\n')], '큰 연락처.csv'))
|
||||
expect(app.get('[data-import-submit]').text()).toBe('연락처 100명 넣기')
|
||||
await click(app.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'success'))
|
||||
|
||||
for (const url of ['/contact-import-checks', '/contact-imports']) {
|
||||
const sent = requests.filter((request) => request.url === url)
|
||||
expect(sent.map((request) => (request.data as { contacts: unknown[] }).contacts.length)).toEqual([61, 39])
|
||||
for (const request of sent) {
|
||||
expect(serverValueCount(request.data)).toBeLessThanOrEqual(8000)
|
||||
expect(new TextEncoder().encode(JSON.stringify(request.data)).length).toBeLessThanOrEqual(700 * 1024)
|
||||
}
|
||||
}
|
||||
expect(toastMessages()).toContain('연락처 100명을 넣었습니다.')
|
||||
expect(state.contacts).toHaveLength(102)
|
||||
})
|
||||
|
||||
it('새 그룹 만들기의 응답만 잃은 뒤 [다시 시도] — 같은 이름(CONTACT_001)이 돌아오면 이번에 만든 그룹으로 보고 [되돌리기]가 지운다', async () => {
|
||||
state.groupCreateReply = () => 'network'
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv([rows[4]!]))
|
||||
await click(app.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.action !== null))
|
||||
expect(toasts().find((toast) => toast.action !== null)?.message).toBe('연락처를 넣지 못했습니다. 다시 시도해 주세요.')
|
||||
// 서버에는 만들어졌다
|
||||
expect(state.groups.map((group) => group.name)).toContain('동호회')
|
||||
|
||||
state.groupCreateReply = undefined
|
||||
const retry = app.findAll('.toast').find((toast) => toast.text().includes('넣지 못했습니다'))
|
||||
await click(retry!.get('.toast__action'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'success'))
|
||||
const sent = requests.filter((request) => request.url === '/contact-imports')
|
||||
expect((sent[0]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.groupIds)).toEqual([['ng4']])
|
||||
|
||||
const result = app.findAll('.toast').find((toast) => toast.text().includes('넣었습니다'))
|
||||
await click(result!.get('.toast__action'))
|
||||
await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 1명')))
|
||||
expect(toastMessages()).toContain('가져온 연락처 1명과 새로 만든 그룹 1개를 지웠습니다.')
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/contact-groups/ng4',
|
||||
])
|
||||
expect(state.groups.map((group) => group.name)).toEqual(['거래처', '세무', '협력사 담당'])
|
||||
})
|
||||
|
||||
it('그사이 다른 곳에서 같은 이름의 그룹을 만들었으면(CONTACT_001) 그 그룹에 넣고 [되돌리기]는 그 그룹을 지우지 않는다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv([rows[4]!]))
|
||||
state.groups.push({ id: 'other', name: '동호회', memberCount: 0 })
|
||||
await click(app.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.kind === 'success'))
|
||||
const sent = requests.filter((request) => request.url === '/contact-imports')
|
||||
expect((sent[0]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.groupIds)).toEqual([['other']])
|
||||
|
||||
await click(app.get('.toast__action'))
|
||||
await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 1명')))
|
||||
expect(toastMessages()).toEqual(['가져온 연락처 1명을 지웠습니다.'])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(state.groups.map((group) => group.id)).toContain('other')
|
||||
})
|
||||
|
||||
it('처음부터 넣지 못하면 "연락처를 넣지 못했습니다." + [다시 시도]', async () => {
|
||||
state.importReply = () => fail(503, 'MAIL_001')
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, naverCsv([rows[0]!]))
|
||||
await click(app.get('[data-import-submit]'))
|
||||
await until(() => toasts().some((toast) => toast.action !== null))
|
||||
expect(toasts().find((toast) => toast.action !== null)?.message).toBe('연락처를 넣지 못했습니다. 다시 시도해 주세요.')
|
||||
})
|
||||
|
||||
it('엑셀(.xlsx — 네이버 내보내기처럼 칸 안 문자열)도 같은 미리 보기', async () => {
|
||||
const bytes = await buildXlsx([
|
||||
['성', '이름', '휴대폰번호', '이메일', '그룹명', '회사·소속명', '부서명'],
|
||||
['남궁', '서연', '01077142093', 'seoyeon.namgung@example.com', '거래처', '서린디자인', ''],
|
||||
['류', '지환', '', 'jihwan.ryu@example.com', '세무', '바른회계법인', ''],
|
||||
])
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, new File([bytes.slice()], 'NAVER_Contacts.xlsx'))
|
||||
expect(app.get('[data-import-found]').text()).toBe('연락처 2명을 찾았습니다.')
|
||||
expect(app.findAll('[data-import-row]').map((row) => row.get('[role="cell"]').text())).toEqual(['남궁서연', '류지환'])
|
||||
expect(app.get('[data-import-groups-text]').text()).toBe('파일에 적힌 그룹 거래처, 세무는 이미 있는 그룹에 넣습니다.')
|
||||
// 칸 없는 정보가 없으면 메모 체크 칸을 두지 않는다
|
||||
expect(app.find('[data-import-info]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('vCard — 줄 대신 "N번째 연락처", 묶음 이름은 "넣지 못하는 연락처"', async () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:신예린',
|
||||
'EMAIL;type=INTERNET;type=HOME:yerin.shin@example.com',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'TEL;type=CELL:010-3192-5540',
|
||||
'END:VCARD',
|
||||
].join('\r\n')
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await readPreview(app, new File([vcf], '연락처.vcf'))
|
||||
const toggle = app.get('[data-import-problems-toggle]')
|
||||
expect(toggle.text()).toContain('넣지 못하는 연락처')
|
||||
expect(toggle.text()).toContain('1명')
|
||||
await click(toggle)
|
||||
expect(app.get('[data-import-problems] li').text()).toContain('2번째 연락처')
|
||||
})
|
||||
|
||||
it('[취소] · Esc 는 묻지 않고 닫고, 읽는 중이면 읽기를 멈춘다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await openImport(app)
|
||||
await chooseFile(app, naverCsv(rows))
|
||||
await click(app.get('[data-import-cancel]'))
|
||||
expect(isOpen(app, '[data-contact-import]')).toBe(false)
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
await openImport(app)
|
||||
expect(app.find('[data-import-found]').exists()).toBe(false)
|
||||
expect(app.find('[data-import-pick]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('내보내기 창', () => {
|
||||
it('처음 — 내 주소록 전체 · vCard, 파일 이름은 주소록-날짜. [내보내기]는 세션을 확인한 뒤 바로 내려받는다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await click(app.get('[data-contacts-export]'))
|
||||
const dialog = app.get('[data-contact-export]')
|
||||
expect(isOpen(app, '[data-contact-export]')).toBe(true)
|
||||
expect(dialog.get('#contact-export-title').text()).toBe('연락처 내보내기')
|
||||
expect(dialog.get('[data-export-scope="all"]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.get('[data-export-format="vcard"]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.text()).toContain('연락처 2명')
|
||||
expect(dialog.text()).toContain('내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다.')
|
||||
expect(dialog.get('[data-export-file-name]').text()).toBe(`주소록-${formatDate(new Date(NOW))}.vcf`)
|
||||
|
||||
await click(dialog.get('[data-export-submit]'))
|
||||
expect(isOpen(app, '[data-contact-export]')).toBe(false)
|
||||
await until(() => downloads.length > 0)
|
||||
expect(requests.some((request) => request.url === '/auth/me')).toBe(true)
|
||||
expect(downloads).toEqual(['/api/contact-exports?format=vcard'])
|
||||
})
|
||||
|
||||
it('그룹 하나를 CSV 로 — 첫 그룹이 골라지고 파일 이름에 그룹 이름', async () => {
|
||||
const { app } = await mountApp()
|
||||
await click(app.get('[data-contacts-export]'))
|
||||
const dialog = app.get('[data-contact-export]')
|
||||
await dialog.get('[data-export-scope="group"]').setValue(true)
|
||||
await dialog.get('[data-export-format="csv"]').setValue(true)
|
||||
expect((dialog.get('[data-export-group]').element as HTMLSelectElement).value).toBe('g1')
|
||||
expect(dialog.get('[data-export-group]').findAll('option').map((option) => option.text())).toEqual([
|
||||
'거래처 · 1명',
|
||||
'세무 · 0명',
|
||||
'협력사 담당 · 0명',
|
||||
])
|
||||
expect(dialog.get('[data-export-file-name]').text()).toBe(`주소록-거래처-${formatDate(new Date(NOW))}.csv`)
|
||||
await click(dialog.get('[data-export-submit]'))
|
||||
await until(() => downloads.length > 0)
|
||||
expect(downloads).toEqual(['/api/contact-exports?format=csv&groupId=g1'])
|
||||
})
|
||||
|
||||
it('구성원이 없는 그룹이면 [내보내기]를 끄고 까닭을 그룹 칸 아래에 — 다른 그룹을 고르면 돌아온다', async () => {
|
||||
const { app } = await mountApp()
|
||||
await click(app.get('[data-contacts-export]'))
|
||||
const dialog = app.get('[data-contact-export]')
|
||||
await dialog.get('[data-export-scope="group"]').setValue(true)
|
||||
await dialog.get('[data-export-group]').setValue('g3')
|
||||
const submit = dialog.get('[data-export-submit]')
|
||||
expect(submit.attributes('aria-disabled')).toBe('true')
|
||||
expect(dialog.get('#contact-export-empty').text()).toBe('‘협력사 담당’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다.')
|
||||
expect(dialog.find('[data-export-file-name]').exists()).toBe(false)
|
||||
await click(submit)
|
||||
expect(downloads).toEqual([])
|
||||
|
||||
await dialog.get('[data-export-group]').setValue('g1')
|
||||
expect(submit.attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,8 @@ import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import type { Draft, MailSignature, Mailbox, MessageDetail } from '@/shared/types/mail'
|
||||
import type { Draft, Mailbox, MessageDetail } from '@/shared/types/mail'
|
||||
import type { MailSignature, MailSignatureDefaults } from '@/shared/types/mail-signature'
|
||||
import { formatDraftSavedTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -68,32 +69,30 @@ const MAILBOXES: Mailbox[] = [
|
||||
{ id: 'trash', name: '휴지통', role: 'trash', parentId: null, sortOrder: 5, totalCount: 0, unreadCount: 0 },
|
||||
]
|
||||
|
||||
const NO_SIGNATURE: MailSignature = {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
}
|
||||
|
||||
/** 서명 — 새 메일 · 답장과 전달 기본 (16단계 — 여러 서명, 계약 2절 "서명") */
|
||||
const SIGNATURE: MailSignature = {
|
||||
id: 'sig-out',
|
||||
name: '대외',
|
||||
bodyHtml:
|
||||
'<div style="font-family: \'Malgun Gothic\'; font-size: 14px; line-height: 1.75">' +
|
||||
'<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
|
||||
`<p style="margin: 0"><img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p></div>`,
|
||||
body: '김아라 · 인프라팀',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [{ id: IMAGE_ID, type: 'image/png', size: 1000, width: 480, height: 120 }],
|
||||
updatedAt: SAVED_AT,
|
||||
}
|
||||
|
||||
/** 서명이 없는 직원의 기본 둘 */
|
||||
const NO_DEFAULTS: MailSignatureDefaults = { newMessageId: null, replyId: null }
|
||||
|
||||
/** 먼저 보는 응답 — undefined 면 기본 서버가 답한다 */
|
||||
type ExtraHandler = (request: MockRequest) => MockResult | Promise<MockResult> | undefined
|
||||
|
||||
interface ServerOptions {
|
||||
signature?: MailSignature
|
||||
/** 내 서명 — 비면 서명 없음 */
|
||||
signatures?: MailSignature[]
|
||||
/** 기본 둘 — 비면 첫 서명을 두 곳 모두에, 서명이 없으면 넣지 않음 */
|
||||
defaults?: MailSignatureDefaults
|
||||
/** POST · PUT /drafts 의 응답 — 비면 d1, d2 … 로 성공 */
|
||||
draftResults?: MockResult[]
|
||||
/** GET /drafts/{id} */
|
||||
@@ -122,7 +121,14 @@ function server(options: ServerOptions = {}): MockHandler {
|
||||
if (method === 'GET' && url === '/mailboxes') {
|
||||
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
}
|
||||
if (method === 'GET' && url === '/mail-signature') return ok(options.signature ?? NO_SIGNATURE)
|
||||
if (method === 'GET' && url === '/mail-signatures') {
|
||||
const signatures = options.signatures ?? []
|
||||
return ok({ items: signatures, pageInfo: { hasNext: false, nextCursor: null, total: signatures.length } })
|
||||
}
|
||||
if (method === 'GET' && url === '/mail-signature-defaults') {
|
||||
const first = options.signatures?.[0]?.id ?? null
|
||||
return ok(options.defaults ?? (first ? { newMessageId: first, replyId: first } : NO_DEFAULTS))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/drafts/')) {
|
||||
const draft = options.draft ?? drafts.get(url.slice('/drafts/'.length))
|
||||
if (!draft) return fail(404, 'RES_001')
|
||||
@@ -1045,7 +1051,7 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
|
||||
it('저장본으로 채우고 답장으로 연다 — 서명은 다시 넣지 않고, 고치면 그 id 로 PUT (inReplyTo 그대로)', async () => {
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?draft=d7',
|
||||
server({ draft: DRAFT, signature: SIGNATURE }),
|
||||
server({ draft: DRAFT, signatures: [SIGNATURE] }),
|
||||
)
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('답장')
|
||||
@@ -1143,23 +1149,63 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('서명 (계약 2절 "서명")', () => {
|
||||
it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
|
||||
const paragraphs = wrapper.findAll('#compose-body p')
|
||||
describe('서명 (계약 2절 "서명" — 16단계 여러 서명)', () => {
|
||||
/** 둘째 서명 — 사내 (그림 없음) */
|
||||
const INSIDE: MailSignature = {
|
||||
id: 'sig-in',
|
||||
name: '사내',
|
||||
bodyHtml: '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p>',
|
||||
body: '김아라 · 인프라팀\n내선 1234',
|
||||
images: [],
|
||||
updatedAt: SAVED_AT,
|
||||
}
|
||||
|
||||
expect(paragraphs.map((paragraph) => paragraph.text())).toEqual(['', '', '김아라 · 인프라팀', ''])
|
||||
/** 맨 위 단계 블록 — 서명 자리는 [서명:글], 원문 인용은 [인용] */
|
||||
function blocks(wrapper: VueWrapper): string[] {
|
||||
const out: string[] = []
|
||||
editorOf(wrapper.element).state.doc.forEach((node) => {
|
||||
if (node.type.name === 'mailSignature') out.push(`[서명:${node.textContent}]`)
|
||||
else out.push(node.type.name === 'blockquote' ? '[인용]' : node.textContent)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function menuItems(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('.compose-signature__menu [role^="menuitem"]')
|
||||
}
|
||||
|
||||
/** [서명 ▾]를 열고 이름이 같은 항목을 누른다 */
|
||||
async function pickSignature(wrapper: VueWrapper, label: string) {
|
||||
await wrapper.get('#compose-signature').trigger('click')
|
||||
await flushPromises()
|
||||
const item = menuItems(wrapper).find((entry) => entry.text() === label)
|
||||
if (!item) throw new Error(`메뉴에 ${label} 이 없다`)
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function signatureStatus(wrapper: VueWrapper): string {
|
||||
return wrapper.get('[data-signature-status]').text()
|
||||
}
|
||||
|
||||
it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 새 메일 기본 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signatures: [SIGNATURE] }))
|
||||
|
||||
// 끝의 빈 줄은 편집기가 첫 거래 때 스스로 붙인다 — 연 직후에는 서명 자리가 끝이다
|
||||
expect(blocks(wrapper)).toEqual(['', '', '[서명:김아라 · 인프라팀]'])
|
||||
expect(wrapper.get('#compose-body img[src]').attributes()).toMatchObject({
|
||||
src: `/api/mail-signature/images/${IMAGE_ID}`,
|
||||
width: '144',
|
||||
})
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
|
||||
await wait(30_000)
|
||||
expect(draftCalls(requests)).toEqual([])
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
it('보내면 서명 그림이 서식 본문에 남는다 — 텍스트 대체 본문에는 그림이 없다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
|
||||
it('보내면 서명 자리는 빈 문단 뒤의 속성 없는 div(앱과 같은 경계) — 그림은 서식 본문에만', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signatures: [SIGNATURE] }))
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
@@ -1171,33 +1217,219 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
|
||||
const send = requests.find((request) => request.method === 'POST' && request.url === '/messages')
|
||||
const data = send?.data as { body: string; bodyHtml: string }
|
||||
expect(data.body).toBe('\n\n김아라 · 인프라팀')
|
||||
expect(data.bodyHtml).toContain(`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt="">`)
|
||||
})
|
||||
|
||||
it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 · 인용', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server({ signature: SIGNATURE }))
|
||||
const doc = editorOf(wrapper.element).state.doc
|
||||
const blocks: string[] = []
|
||||
doc.forEach((node) => blocks.push(node.type.name === 'blockquote' ? '인용' : node.textContent))
|
||||
expect(blocks.slice(0, 5)).toEqual(['', '', '김아라 · 인프라팀', '', '인용'])
|
||||
})
|
||||
|
||||
it('답장 · 전달에 넣지 않기로 했으면 넣지 않는다', async () => {
|
||||
const { wrapper } = await mountCompose(
|
||||
'/mail/compose?replyTo=m1',
|
||||
server({ signature: { ...SIGNATURE, isForReply: false } }),
|
||||
expect(data.bodyHtml).toContain(
|
||||
'<p style="margin: 0"><br></p><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
|
||||
`<p style="margin: 0"><img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p></div>`,
|
||||
)
|
||||
expect(wrapper.find('#compose-body img[src]').exists()).toBe(false)
|
||||
expect(wrapper.get('#compose-body').text()).not.toContain('김아라 · 인프라팀')
|
||||
})
|
||||
|
||||
it('서명을 못 받아도 서명 없이 연다 — 알리지 않는다', async () => {
|
||||
const { wrapper } = await mountCompose(
|
||||
it('답장 · 전달은 인용 위에 답장과 전달 기본 서명', async () => {
|
||||
const reply = await mountCompose(
|
||||
'/mail/compose?replyTo=m1',
|
||||
server({ signatures: [SIGNATURE, INSIDE], defaults: { newMessageId: SIGNATURE.id, replyId: INSIDE.id } }),
|
||||
)
|
||||
expect(blocks(reply.wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
|
||||
expect(reply.wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
|
||||
})
|
||||
|
||||
it('답장과 전달 기본이 넣지 않음이면 넣지 않는다', async () => {
|
||||
const none = await mountCompose(
|
||||
'/mail/compose?replyTo=m1',
|
||||
server({ signatures: [SIGNATURE], defaults: { newMessageId: SIGNATURE.id, replyId: null } }),
|
||||
)
|
||||
expect(none.wrapper.find('#compose-body img[src]').exists()).toBe(false)
|
||||
expect(blocks(none.wrapper).slice(0, 2)).toEqual(['', '[인용]'])
|
||||
expect(none.wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 넣지 않음')
|
||||
})
|
||||
|
||||
it('서명을 못 받아도 서명 없이 연다 — 알리지 않고, [서명 ▾]를 열 때 다시 받아 본다', async () => {
|
||||
let failing = true
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
server({ extra: (request) => (request.url === '/mail-signature' ? fail(503, 'MAIL_001') : undefined) }),
|
||||
server({
|
||||
signatures: [SIGNATURE],
|
||||
extra: (request) => (failing && request.url === '/mail-signatures' ? fail(503, 'MAIL_001') : undefined),
|
||||
}),
|
||||
)
|
||||
expect(wrapper.get('#compose-body').text()).toBe('')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
failing = false
|
||||
await wrapper.get('#compose-signature').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.url === '/mail-signatures')).toHaveLength(2)
|
||||
expect(menuItems(wrapper).map((item) => item.text())).toEqual(['대외', '넣지 않음', '서명 관리'])
|
||||
})
|
||||
|
||||
describe('[서명 ▾] — 쓰기 화면에서 서명 바꾸기 (디자인 원본 Signature16 6)', () => {
|
||||
const BOTH = { signatures: [SIGNATURE, INSIDE], defaults: { newMessageId: SIGNATURE.id, replyId: INSIDE.id } }
|
||||
|
||||
it('메뉴 — 서명(만든 차례) · 넣지 않음 · 가는 선 · 서명 관리, 지금 든 서명에 ✓. 키보드로 열면 첫 항목, Esc 는 단추로', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', server(BOTH), [], true)
|
||||
const button = wrapper.get('#compose-signature')
|
||||
expect(button.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
|
||||
;(button.element as HTMLButtonElement).focus()
|
||||
await button.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
expect(wrapper.get('.compose-signature__menu').attributes('aria-label')).toBe('서명')
|
||||
expect(
|
||||
menuItems(wrapper).map((item) => [item.text(), item.attributes('role'), item.attributes('aria-checked')]),
|
||||
).toEqual([
|
||||
['대외', 'menuitemradio', 'true'],
|
||||
['사내', 'menuitemradio', 'false'],
|
||||
['넣지 않음', 'menuitemradio', 'false'],
|
||||
['서명 관리', 'menuitem', undefined],
|
||||
])
|
||||
expect(wrapper.find('.compose-signature__menu hr').exists()).toBe(true)
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('대외')
|
||||
|
||||
await wrapper.get('.compose-signature__menu').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.compose-signature__menu').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('compose-signature')
|
||||
})
|
||||
|
||||
it('다른 서명을 고르면 서명 자리만 바뀐다 — 쓴 글은 그대로, 화면 낭독기에 알리고(알림 없음) 실행 취소로 되돌린다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', server(BOTH))
|
||||
editorOf(wrapper.element).commands.insertContentAt(1, '도윤님, 안녕하세요.')
|
||||
await flushPromises()
|
||||
|
||||
await pickSignature(wrapper, '사내')
|
||||
expect(blocks(wrapper)).toEqual(['도윤님, 안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]', ''])
|
||||
expect(signatureStatus(wrapper)).toBe('서명을 ‘사내’로 바꿨습니다.')
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
// 바꾼 서명도 바꿈이다 — 임시 저장한다
|
||||
await wait(3_000)
|
||||
expect(draftCalls(requests)).toHaveLength(1)
|
||||
|
||||
editorOf(wrapper.element).commands.undo()
|
||||
await flushPromises()
|
||||
expect(blocks(wrapper)).toEqual(['도윤님, 안녕하세요.', '', '[서명:김아라 · 인프라팀]', ''])
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
|
||||
})
|
||||
|
||||
it('넣지 않음 — 서명 자리와 그 앞의 빈 줄을 빼고, 다시 고르면 처음 넣는 자리(본문 끝)에 넣는다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', server(BOTH))
|
||||
editorOf(wrapper.element).commands.insertContentAt(1, '안녕하세요.')
|
||||
await flushPromises()
|
||||
|
||||
await pickSignature(wrapper, '넣지 않음')
|
||||
expect(blocks(wrapper)).toEqual(['안녕하세요.', ''])
|
||||
expect(signatureStatus(wrapper)).toBe('서명을 ‘넣지 않음’으로 바꿨습니다.')
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 넣지 않음')
|
||||
|
||||
await pickSignature(wrapper, '사내')
|
||||
expect(blocks(wrapper)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]', ''])
|
||||
})
|
||||
|
||||
it('답장은 인용 위의 서명 자리만 바뀐다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server(BOTH))
|
||||
await pickSignature(wrapper, '대외')
|
||||
expect(blocks(wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀]', '[인용]'])
|
||||
await pickSignature(wrapper, '넣지 않음')
|
||||
expect(blocks(wrapper).slice(0, 2)).toEqual(['', '[인용]'])
|
||||
await pickSignature(wrapper, '사내')
|
||||
expect(blocks(wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
|
||||
})
|
||||
|
||||
it('서명 안을 고치면 어느 서명과도 같지 않다 — ✓ 없이, 단추는 "서명"만. 고르면 고친 것도 새 서명으로 바뀐다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', server(BOTH))
|
||||
const editor = editorOf(wrapper.element)
|
||||
let inside = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (inside < 0 && node.isText && node.text === '김아라') inside = pos
|
||||
})
|
||||
editor.commands.insertContentAt(inside, '대표 ')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명')
|
||||
await wrapper.get('#compose-signature').trigger('click')
|
||||
await flushPromises()
|
||||
expect(menuItems(wrapper).map((item) => item.attributes('aria-checked'))).toEqual(['false', 'false', 'false', undefined])
|
||||
await menuItems(wrapper)[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(blocks(wrapper)).toEqual(['', '', '[서명:김아라 · 인프라팀]', ''])
|
||||
})
|
||||
|
||||
it('이어 쓰는 임시 저장본 — 서명 자리를 다시 찾아 같은 서명이면 ✓, 바꾸면 그 자리만 바뀐다 (서명을 다시 넣지 않는다)', async () => {
|
||||
const saved: Draft = {
|
||||
id: 'd9',
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '견적',
|
||||
body: '확인했습니다.\n\n김아라 · 인프라팀\n내선 1234',
|
||||
// 서버가 정화한 저장본 — 감싸개 · 빈 문단 뒤의 속성 없는 div (data-* 없음)
|
||||
bodyHtml:
|
||||
'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 14px; line-height: 1.75">' +
|
||||
'<p style="margin: 0">확인했습니다.</p><p style="margin: 0"><br /></p>' +
|
||||
'<div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p></div></div>',
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-26T01:00:00Z',
|
||||
}
|
||||
const { wrapper } = await mountCompose('/mail/compose?draft=d9', server({ ...BOTH, draft: saved }))
|
||||
|
||||
expect(blocks(wrapper)).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀내선 1234]'])
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
|
||||
await pickSignature(wrapper, '대외')
|
||||
expect(blocks(wrapper)).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀]', ''])
|
||||
})
|
||||
|
||||
it('서명 위 빈 줄에 글을 써도 빈 줄을 다시 두어 저장 · 다시 열기에서 서명 자리를 찾는다 (리뷰 M3)', async () => {
|
||||
// 같은 서버 — 저장한 판을 기억했다가 다시 열 때 내준다
|
||||
const handler = server(BOTH)
|
||||
const first = await mountCompose('/mail/compose', handler)
|
||||
const editor = editorOf(first.wrapper.element)
|
||||
// 쓸 줄은 비우고 서명 바로 위 빈 줄(둘째 줄)에 쓴다
|
||||
editor.commands.insertContentAt(editor.state.doc.child(0).nodeSize + 1, '감사합니다.')
|
||||
await flushPromises()
|
||||
expect(blocks(first.wrapper)).toEqual(['', '감사합니다.', '', '[서명:김아라 · 인프라팀]', ''])
|
||||
|
||||
await wait(3_000)
|
||||
const saved = draftCalls(first.requests)[0]
|
||||
expect(saved?.method).toBe('POST')
|
||||
expect((saved?.data as { bodyHtml: string }).bodyHtml).toContain(
|
||||
'<p style="margin: 0">감사합니다.</p><p style="margin: 0"><br></p><div>',
|
||||
)
|
||||
first.wrapper.unmount()
|
||||
|
||||
// 저장한 판을 다시 연다 — 서명 자리를 찾고 그 서명에 ✓
|
||||
const { wrapper } = await mountCompose('/mail/compose?draft=d1', handler)
|
||||
expect(blocks(wrapper).slice(0, 4)).toEqual(['', '감사합니다.', '', '[서명:김아라 · 인프라팀]'])
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
|
||||
})
|
||||
|
||||
it('Backspace 로 서명 위 빈 줄을 지워도 빈 줄을 다시 두어 저장 · 다시 열기에서 서명 자리를 찾는다 (리뷰 M3)', async () => {
|
||||
const handler = server(BOTH)
|
||||
const first = await mountCompose('/mail/compose', handler)
|
||||
const editor = editorOf(first.wrapper.element)
|
||||
editor.commands.insertContentAt(1, '안녕하세요.')
|
||||
// 서명 바로 위 빈 줄(둘째 줄)에서 Backspace
|
||||
const blankLine = editor.state.doc.child(0).nodeSize + 1
|
||||
editor.commands.setTextSelection(blankLine)
|
||||
editor.commands.keyboardShortcut('Backspace')
|
||||
await flushPromises()
|
||||
expect(blocks(first.wrapper).slice(0, 3)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀]'])
|
||||
|
||||
await wait(3_000)
|
||||
expect(draftCalls(first.requests)).toHaveLength(1)
|
||||
first.wrapper.unmount()
|
||||
|
||||
const { wrapper } = await mountCompose('/mail/compose?draft=d1', handler)
|
||||
expect(blocks(wrapper).slice(0, 3)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀]'])
|
||||
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
|
||||
})
|
||||
|
||||
it('서명 관리 — 설정 › 메일 › 서명으로 간다 (바꾼 것이 없으면 묻지 않는다)', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', server(BOTH))
|
||||
await pickSignature(wrapper, '서명 관리')
|
||||
expect(router.currentRoute.value.name).toBe('settings-mail-signature')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// 받는 사람을 채운 새 메일(8단계 — 주소록의 [메일 쓰기] · 주소 링크 · [그룹에 메일 쓰기]): 주소는 URL 이 아니라 라우터 상태로 온다(compose-prefill.ts).
|
||||
// 채운 받는 사람은 "바꿈"이 아니다(답장처럼 — 손대지 않고 닫으면 저장하지 않는다). 초점은 제목으로 (받는 사람은 이미 있다)
|
||||
// 서명: 열 때 설정의 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위 (디자인 README "2단계 › 서명").
|
||||
// 16단계(여러 서명 — 디자인 원본 Signature16 6): 넣는 서명은 "메일에 넣을 서명"의 새 메일 · 답장과 전달 기본이고, 서명 자리(빈 줄 뒤의 서명 덩어리 —
|
||||
// 앱과 같은 경계, mail-signature-block.ts)로 넣는다. 아래 단추 줄 [임시 저장] 다음의 [서명 ▾](ComposeSignatureMenu)로 그 자리만 다른 서명 · 넣지 않음으로
|
||||
// 바꾼다(쓴 글 · 인용 · 첨부는 그대로, 실행 취소로 되돌린다) — 바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."(알림 없음).
|
||||
// 어느 서명이 들었는지는 서명 자리의 글 · 그림을 저장한 서명과 견준다(이어 쓰는 저장본도 — 고쳤으면 ✓ 없이). 서명 관리는 설정 › 메일 › 서명으로(나가기 규칙대로).
|
||||
// 받는 사람에 따라 저절로 바꾸지 않는다.
|
||||
// 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장", useDraftSaving):
|
||||
// - 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지 — 쉬지 않고 쓰면 처음 바꾼 뒤 30초에) · 「임시 저장」을 누를 때 ·
|
||||
// 화면을 떠날 때 저장한다. 상태는 아래 단추 줄에.
|
||||
@@ -71,6 +76,7 @@ import ComposeFooter from '@/components/mail/ComposeFooter.vue'
|
||||
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
|
||||
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
|
||||
import ComposeScheduleDialog from '@/components/mail/ComposeScheduleDialog.vue'
|
||||
import ComposeSignatureMenu from '@/components/mail/ComposeSignatureMenu.vue'
|
||||
import RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import {
|
||||
MAIL_EDITOR_RETRY,
|
||||
@@ -106,8 +112,11 @@ import {
|
||||
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
|
||||
import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS } from '@/shared/constants/mail-send-later'
|
||||
import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
|
||||
import { COMPOSE_SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
|
||||
import type { ComposeSignatureState } from '@/shared/types/mail-signature'
|
||||
import {
|
||||
isSameComposeForm,
|
||||
snapshotComposeForm,
|
||||
@@ -131,8 +140,9 @@ import {
|
||||
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
|
||||
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
|
||||
import { buildReplyDraft } from '@/shared/utils/mail-reply'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
import { partialSendMessage } from '@/shared/utils/mail-send-result'
|
||||
import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
|
||||
import { newMailHtmlWithSignature, signatureBlockHtml, signatureHtmlOf } from '@/shared/utils/mail-signature'
|
||||
import { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
|
||||
import { undoToastDeadline } from '@/shared/utils/pending-send'
|
||||
import { rejectedScheduleProblem, sendAtOf, type ScheduleRejection } from '@/shared/utils/schedule-send'
|
||||
@@ -239,6 +249,12 @@ let focusBodyWhenReady = false
|
||||
const preparing = ref<boolean>(false)
|
||||
/** 새 메일에 넣을 서명을 기다리는 중 — 편집기를 서명과 함께 만든다 (받는 사람 · 제목은 그대로 쓸 수 있다) */
|
||||
const bodyPending = ref<boolean>(false)
|
||||
/** 본문에 든 서명 — [서명 ▾]의 ✓ · 단추의 화면 낭독 이름 (16단계 — 편집기가 준비되면 · 본문이 바뀔 때마다 다시 잰다) */
|
||||
const signatureState = ref<ComposeSignatureState>({ kind: 'none' })
|
||||
/** 서명을 바꾼 뒤 화면 낭독기에 읽어 줄 글 — "서명을 ‘사내’로 바꿨습니다." */
|
||||
const signatureAnnouncement = ref<string>('')
|
||||
/** 서명 HTML 마다의 비교 값 — 편집기 스키마로 한 번 읽어 둔다 (스키마는 편집기를 새로 만들어도 같다) */
|
||||
const signatureFingerprints = new Map<string, string>()
|
||||
/** 제목에 보이는 쓰기 종류 — 이어 쓰는 저장본은 불러온 뒤 답장 · 전달 · 새 메일 중 하나로 정한다 */
|
||||
const composeKind = ref<ComposeMode>('new')
|
||||
/** 발송 · 임시 저장의 inReplyTo — 답장할 메일 id (이어 쓰는 저장본은 저장본의 값) */
|
||||
@@ -520,6 +536,8 @@ const recipientSuggestions = useRecipientSuggestions({
|
||||
})
|
||||
provide(RECIPIENT_SUGGESTIONS, recipientSuggestions)
|
||||
recipientSuggestions.preload()
|
||||
// [서명 ▾]의 목록 · 이어 쓰는 저장본의 서명 견주기 — 메일 화면이 받아 두었으면 그대로 (새 메일 · 답장은 받은 뒤 서명을 넣는다)
|
||||
void signatureStore.ensureLoaded()
|
||||
|
||||
function commitRecipientInputs() {
|
||||
toField.value?.commit()
|
||||
@@ -637,33 +655,32 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
|
||||
|
||||
let prepareSeq = 0
|
||||
|
||||
/** 새 메일 — 서명을 받은 뒤 편집기를 만든다 (받는 사람 · 제목은 그동안에도 쓸 수 있다) */
|
||||
/** 새 메일 — 서명을 받은 뒤 편집기를 만든다 (받는 사람 · 제목은 그동안에도 쓸 수 있다). 넣는 서명은 새 메일 기본 (못 받았으면 넣지 않는다) */
|
||||
async function prepareNew() {
|
||||
prepareSeq += 1
|
||||
const seq = prepareSeq
|
||||
bodyPending.value = true
|
||||
const signature = signatureHtmlFor(await signatureStore.ensureSignature(), 'new')
|
||||
await signatureStore.ensureLoaded()
|
||||
if (seq !== prepareSeq) return
|
||||
const signature = signatureHtmlOf(signatureStore.defaultSignature('new'))
|
||||
if (signature) draftHtml.value = newMailHtmlWithSignature(signature)
|
||||
bodyPending.value = false
|
||||
}
|
||||
|
||||
/** 답장·전달 — 원본 메일과 서명을 불러와 양식을 채운다 */
|
||||
/** 답장·전달 — 원본 메일과 서명을 불러와 양식을 채운다. 넣는 서명은 답장과 전달 기본 */
|
||||
async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, 'new'>) {
|
||||
prepareSeq += 1
|
||||
const seq = prepareSeq
|
||||
preparing.value = true
|
||||
try {
|
||||
const [original, signature] = await Promise.all([
|
||||
getMessage(messageId),
|
||||
signatureStore.ensureSignature(),
|
||||
])
|
||||
const [original] = await Promise.all([getMessage(messageId), signatureStore.ensureLoaded()])
|
||||
if (seq !== prepareSeq) return
|
||||
const signatureHtml = signatureHtmlFor(signature, 'reply')
|
||||
const signatureHtml = signatureHtmlOf(signatureStore.defaultSignature('reply'))
|
||||
const signatureBlock = signatureHtml ? signatureBlockHtml(signatureHtml) : null
|
||||
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
|
||||
let filledAttachments: readonly number[] = []
|
||||
if (sourceMode === 'forward') {
|
||||
const draft = buildForwardDraft(original, undefined, signatureHtml)
|
||||
const draft = buildForwardDraft(original, undefined, signatureBlock)
|
||||
subject.value = draft.subject
|
||||
draftHtml.value = draft.html
|
||||
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
|
||||
@@ -671,7 +688,7 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
.filter((item) => item.status === 'done')
|
||||
.map((item) => item.key)
|
||||
} else {
|
||||
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode, undefined, signatureHtml)
|
||||
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode, undefined, signatureBlock)
|
||||
to.value = draft.to.map(tokenFromAddress)
|
||||
cc.value = draft.cc.map(tokenFromAddress)
|
||||
showCopies.value = cc.value.length > 0
|
||||
@@ -761,6 +778,7 @@ function isFocusFree(): boolean {
|
||||
function onEditorReady() {
|
||||
editorReady.value = true
|
||||
bodyChanged.value = false
|
||||
refreshSignatureState()
|
||||
if (!focusBodyWhenReady) return
|
||||
focusBodyWhenReady = false
|
||||
void nextTick(() => {
|
||||
@@ -1094,13 +1112,72 @@ function clearLengthError(field: 'subject' | 'body') {
|
||||
if (lengthError.value === field) lengthError.value = null
|
||||
}
|
||||
|
||||
/** 본문이 바뀌었다 — 길이 안내를 내리고, 저장한 때와 다른지 다시 잰 뒤 자동 저장 차례를 잡는다 */
|
||||
/** 본문이 바뀌었다 — 길이 안내를 내리고, 저장한 때와 다른지 다시 잰 뒤 자동 저장 차례를 잡는다. 든 서명도 다시 잰다 */
|
||||
function onEditorChange() {
|
||||
clearLengthError('body')
|
||||
bodyChanged.value = editorRef.value?.isChanged() ?? false
|
||||
refreshSignatureState()
|
||||
drafts.touch()
|
||||
}
|
||||
|
||||
/** 서명 HTML 의 비교 값 — 서명마다 한 번 편집기 스키마로 읽는다 */
|
||||
function signatureFingerprintOf(editor: MailEditorHandle, html: string): string {
|
||||
let fingerprint = signatureFingerprints.get(html)
|
||||
if (fingerprint === undefined) {
|
||||
fingerprint = editor.signatureFingerprint(html)
|
||||
signatureFingerprints.set(html, fingerprint)
|
||||
}
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 든 서명을 다시 잰다 — 서명 자리가 없으면 넣지 않음, 있으면 글 · 그림이 같은 서명(만든 차례로 먼저 맞는 것),
|
||||
* 어느 서명과도 같지 않으면(그 메일에서 고쳤거나 지운 서명) ✓ 없이 (디자인 원본 Signature16 "서명 자리")
|
||||
*/
|
||||
function refreshSignatureState() {
|
||||
const editor = editorRef.value
|
||||
if (!editor || !editorReady.value) return
|
||||
const place = editor.signaturePlace()
|
||||
if (place === null) {
|
||||
signatureState.value = { kind: 'none' }
|
||||
return
|
||||
}
|
||||
const match = signatureStore.signatures.find((signature) => {
|
||||
const html = signatureHtmlOf(signature)
|
||||
return html !== null && signatureFingerprintOf(editor, html) === place
|
||||
})
|
||||
signatureState.value = match ? { kind: 'signature', id: match.id } : { kind: 'edited' }
|
||||
}
|
||||
|
||||
/** [서명 ▾]를 열었다 — 서명을 받지 못했으면 다시 받아 본다 (받으면 목록이 채워진다) */
|
||||
function onSignatureMenuOpen() {
|
||||
void signatureStore.ensureLoaded()
|
||||
}
|
||||
|
||||
/**
|
||||
* [서명 ▾]에서 골랐다 — 본문의 서명 자리만 그 서명으로(넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다).
|
||||
* 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 인용 위)에. 바꾼 뒤 화면 낭독기에만 알린다
|
||||
*/
|
||||
async function onPickSignature(id: string | null) {
|
||||
const editor = editorRef.value
|
||||
if (!editor || !editorReady.value) return
|
||||
const signature = signatureStore.signatureById(id)
|
||||
// 그사이 다른 창에서 지운 서명이면 바꾸지 않는다
|
||||
if (id !== null && !signature) return
|
||||
editor.replaceSignature(signatureHtmlOf(signature))
|
||||
refreshSignatureState()
|
||||
const name = signature?.name ?? COMPOSE_SIGNATURE_TEXT.none
|
||||
// 같은 글이어도 다시 읽게 비웠다가 넣는다
|
||||
signatureAnnouncement.value = ''
|
||||
await nextTick()
|
||||
signatureAnnouncement.value = COMPOSE_SIGNATURE_TEXT.changed(name, directionParticle(name))
|
||||
}
|
||||
|
||||
/** 서명 관리 — 설정 › 메일 › 서명으로. 쓰던 메일은 나가기 규칙대로 먼저 임시 저장한다 (저장하지 못했을 때만 묻는다) */
|
||||
function onManageSignatures() {
|
||||
void router.push({ name: SETTINGS_ROUTES.signature })
|
||||
}
|
||||
|
||||
function pickFiles() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
@@ -1173,6 +1250,12 @@ watch([sendable, () => authStore.generation], ([can], [, previousSession]) => {
|
||||
watch(hasMissingLarge, (missing) => {
|
||||
if (!missing) drafts.touch()
|
||||
})
|
||||
|
||||
// 서명을 (다시) 받았다 — [서명 ▾]의 ✓ 를 다시 잰다
|
||||
watch(
|
||||
() => signatureStore.signatures,
|
||||
() => refreshSignatureState(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1349,7 +1432,19 @@ watch(hasMissingLarge, (missing) => {
|
||||
:sender="sender"
|
||||
@save-draft="onSaveDraft"
|
||||
@schedule="openSchedule"
|
||||
/>
|
||||
>
|
||||
<template #signature>
|
||||
<ComposeSignatureMenu
|
||||
:signatures="signatureStore.signatures"
|
||||
:state="signatureState"
|
||||
:disabled="!editorReady || preparing || bodyPending || sending || discarding"
|
||||
:announcement="signatureAnnouncement"
|
||||
@open="onSignatureMenuOpen"
|
||||
@select="onPickSignature"
|
||||
@manage="onManageSignatures"
|
||||
/>
|
||||
</template>
|
||||
</ComposeFooter>
|
||||
</form>
|
||||
|
||||
<!-- 예약 발송 창 (9단계) — [예약]이면 보내기와 같은 흐름에 sendAt 을 더한다 -->
|
||||
|
||||
@@ -40,8 +40,8 @@ provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
|
||||
void mailStore.ensureMailboxes()
|
||||
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
// 쓰기 화면이 열 때 넣을 서명 · [서명 ▾]의 목록 — 서명 목록과 기본 둘(16단계)을 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureLoaded()
|
||||
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
|
||||
void useMailVacationStore().load()
|
||||
// 패널의 「예약됨」 — 예약한 메일이 있을 때만 그린다
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { TWO_FACTOR_CHANGE_TIMEOUT_MS } from '@/shared/constants/account-security'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AccountTwoFactorPage from './AccountTwoFactorPage.vue'
|
||||
|
||||
// 설정 › 계정 › 2단계 인증 — 휴대폰 바꾸기 (16단계 — 디자인 원본 Settings16 5~8 · 계약 5절 "2단계 인증")
|
||||
// ① 새 휴대폰 등록(QR · 키 · 새 휴대폰의 코드 → [다음]) ② 지금 휴대폰으로 확인(지금 휴대폰의 코드 · 지금 비밀번호 → [바꾸기])
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' }
|
||||
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
|
||||
const PASSWORD = 'old-pass-1234'
|
||||
const DONE = '2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다.'
|
||||
const UNCONFIRMED =
|
||||
'휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요.'
|
||||
const UNCONFIRMED_CLOSED =
|
||||
'휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요.'
|
||||
const SUPPORT = '지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요.'
|
||||
|
||||
function enrollment(seq: number): TwoFactorEnrollment {
|
||||
const secret = seq === 1 ? 'R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6' : 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'
|
||||
return {
|
||||
enrollmentId: `enroll-${seq}`,
|
||||
secret,
|
||||
otpauthUrl: `otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=${secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
|
||||
expiresAt: '2026-11-10T00:22:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
|
||||
interface Server {
|
||||
status?: () => MockResult
|
||||
verify?: Handler
|
||||
replace?: Handler
|
||||
supportContact?: () => MockResult
|
||||
}
|
||||
|
||||
/** 응답을 손으로 풀어 주는 요청 — 보내는 동안의 모양을 본다 */
|
||||
function held() {
|
||||
const replies: ((result: MockResult) => void)[] = []
|
||||
const handler: Handler = () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
replies.push(resolve)
|
||||
})
|
||||
return { handler, replies }
|
||||
}
|
||||
|
||||
describe('AccountTwoFactorPage — 휴대폰 바꾸기 (16단계)', () => {
|
||||
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 = {}) {
|
||||
let enrollSeq = 0
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/two-factor') return server.status ? server.status() : ok(ON)
|
||||
if (request.url === '/two-factor/enrollments') {
|
||||
enrollSeq += 1
|
||||
return ok(enrollment(enrollSeq), 201)
|
||||
}
|
||||
if (/^\/two-factor\/enrollments\/[^/]+\/verification$/.test(request.url)) {
|
||||
return server.verify ? server.verify(request) : ok({ verified: true })
|
||||
}
|
||||
if (request.url === '/two-factor/replacements') return server.replace ? server.replace(request) : ok(ON)
|
||||
if (request.url === '/support-contact') return server.supportContact ? server.supportContact() : ok({ text: null })
|
||||
return ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/two-factor')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountTwoFactorPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const calls = (pattern: string | RegExp) =>
|
||||
requests.filter((request) => (typeof pattern === 'string' ? request.url === pattern : pattern.test(request.url)))
|
||||
const verifyCalls = () => calls(/\/verification$/)
|
||||
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const dialog = () => document.querySelector<HTMLDialogElement>('dialog[aria-labelledby="two-factor-replace-title"]')
|
||||
const inDialog = <T extends Element = HTMLElement>(selector: string) => dialog()?.querySelector<T>(selector) ?? null
|
||||
const inputValue = (id: string) => (document.getElementById(id) as HTMLInputElement | null)?.value
|
||||
const stepItems = () =>
|
||||
Array.from(inDialog('[data-replace-steps]')?.querySelectorAll('li') ?? []).map((item) => ({
|
||||
text: item.textContent?.trim() ?? '',
|
||||
current: item.getAttribute('aria-current'),
|
||||
done: item.querySelector('svg') !== null && !item.hasAttribute('aria-hidden'),
|
||||
}))
|
||||
|
||||
async function openReplace(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-two-factor-replace]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function next(wrapper: VueWrapper, code: string) {
|
||||
await wrapper.get('#two-factor-replace-new-code').setValue(code)
|
||||
await wrapper.get('[data-replace-next]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function fillCurrent(wrapper: VueWrapper, values: { code?: string; password?: string }) {
|
||||
if (values.code !== undefined) await wrapper.get('#two-factor-replace-current-code').setValue(values.code)
|
||||
if (values.password !== undefined) await wrapper.get('#two-factor-replace-password').setValue(values.password)
|
||||
}
|
||||
|
||||
async function submit(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-replace-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** ②까지 — 새 휴대폰의 코드를 확인했다 */
|
||||
async function toCurrentStep(wrapper: VueWrapper) {
|
||||
await openReplace(wrapper)
|
||||
await next(wrapper, '482913')
|
||||
}
|
||||
|
||||
describe('① 새 휴대폰 등록', () => {
|
||||
it('[휴대폰 바꾸기]를 누르면 창 — 켜진 채 새 키를 받고, 차례 · 글 · QR · 키는 바꾸기 글로, 처음 초점은 새 휴대폰의 코드 칸', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openReplace(wrapper)
|
||||
|
||||
expect(dialog()?.hasAttribute('open')).toBe(true)
|
||||
expect(dialog()?.getAttribute('aria-describedby')).toBe('two-factor-replace-message')
|
||||
expect(calls('/two-factor/enrollments').map((request) => request.method)).toEqual(['POST'])
|
||||
expect(inDialog('#two-factor-replace-title')?.textContent?.trim()).toBe('2단계 인증 휴대폰 바꾸기')
|
||||
expect(inDialog('[data-replace-steps]')?.getAttribute('aria-label')).toBe('휴대폰 바꾸기 차례')
|
||||
expect(stepItems()).toEqual([
|
||||
{ text: '새 휴대폰 등록', current: 'step', done: false },
|
||||
{ text: '', current: null, done: false },
|
||||
{ text: '지금 휴대폰으로 확인', current: null, done: false },
|
||||
])
|
||||
expect(inDialog('#two-factor-replace-message')?.textContent?.trim()).toBe(
|
||||
'새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다.',
|
||||
)
|
||||
const text = dialog()?.textContent ?? ''
|
||||
expect(text).toContain('새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요')
|
||||
expect(text).toContain('인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요.')
|
||||
expect(text).toContain('앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다.')
|
||||
expect(text).toContain('바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.')
|
||||
expect(inDialog('[data-qr-code]')?.getAttribute('aria-label')).toBe('인증 앱에 등록할 QR 코드')
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
|
||||
expect(document.querySelector('label[for="two-factor-replace-new-code"]')?.textContent).toBe('새 휴대폰의 앱에 표시된 6자리 코드')
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
|
||||
// 단추는 [다음] [취소]
|
||||
expect(Array.from(dialog()?.querySelectorAll('.modal-dialog__actions button') ?? []).map((button) => button.textContent?.trim())).toEqual([
|
||||
'다음',
|
||||
'취소',
|
||||
])
|
||||
})
|
||||
|
||||
it('키 이름 id 가 켜기 창과 겹치지 않는다 — [복사]는 바꾸기 창의 키 이름을 가리킨다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openReplace(wrapper)
|
||||
|
||||
expect(document.querySelectorAll('#two-factor-key-label')).toHaveLength(1)
|
||||
expect(document.querySelectorAll('#two-factor-replace-key-label')).toHaveLength(1)
|
||||
expect(inDialog('[data-enable-copy]')?.getAttribute('aria-describedby')).toBe('two-factor-replace-key-label')
|
||||
})
|
||||
|
||||
it('6자리가 아니면 보내지 않고 칸 아래에 AUTH_007 문구 · 그 칸에 초점', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openReplace(wrapper)
|
||||
await next(wrapper, '12')
|
||||
|
||||
expect(verifyCalls()).toEqual([])
|
||||
expect(wrapper.get('#two-factor-replace-new-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
|
||||
})
|
||||
|
||||
it('[다음]은 새 휴대폰의 코드를 먼저 확인한다 — 확인하는 동안 「확인하는 중…」(aria-busy · aria-disabled), 다시 눌러도 요청은 하나', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ verify: handler })
|
||||
await openReplace(wrapper)
|
||||
await next(wrapper, '482913')
|
||||
|
||||
expect(verifyCalls().map((request) => [request.method, request.url, request.data])).toEqual([
|
||||
['POST', '/two-factor/enrollments/enroll-1/verification', { otpCode: '482913' }],
|
||||
])
|
||||
const button = wrapper.get('[data-replace-next]')
|
||||
expect(button.text()).toBe('확인하는 중…')
|
||||
expect(button.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(verifyCalls()).toHaveLength(1)
|
||||
|
||||
replies[0]?.(ok({ verified: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('새 휴대폰의 코드가 틀리면(AUTH_006) 칸 아래 · 칸을 비우고 초점 — QR · 키는 그대로, 알림 없음', async () => {
|
||||
const wrapper = await mountPage({ verify: () => fail(400, 'AUTH_006') })
|
||||
await openReplace(wrapper)
|
||||
await next(wrapper, '000111')
|
||||
|
||||
expect(wrapper.get('#two-factor-replace-new-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect(inputValue('two-factor-replace-new-code')).toBe('')
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(false)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('AUTH_008(10분 지남 · 더 새 등록) — 창 위에 사전 문구, 새 키로 다시 그리고 코드는 비운다 · 처음 초점은 코드 칸', async () => {
|
||||
const wrapper = await mountPage({ verify: () => fail(409, 'AUTH_008') })
|
||||
await openReplace(wrapper)
|
||||
await next(wrapper, '482913')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls('/two-factor')).toHaveLength(2)
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(2)
|
||||
const notice = wrapper.get('[data-replace-notice]')
|
||||
expect(notice.text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
|
||||
expect(notice.attributes('role')).toBe('alert')
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(inputValue('two-factor-replace-new-code')).toBe('')
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
|
||||
expect(dialog()?.hasAttribute('open')).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('코드 칸에서 Enter 도 [다음]과 같다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openReplace(wrapper)
|
||||
await wrapper.get('#two-factor-replace-new-code').setValue('482913')
|
||||
await wrapper.get('#two-factor-replace-new-code').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyCalls()).toHaveLength(1)
|
||||
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('② 지금 휴대폰으로 확인', () => {
|
||||
it('새 휴대폰을 확인하면 ② — 차례(마친 걸음 체크 · 지금 걸음) · 글 · 칸 둘 · 문의 줄 · [이전] [바꾸기] [취소], 처음 초점은 지금 휴대폰의 코드 칸', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await toCurrentStep(wrapper)
|
||||
|
||||
expect(stepItems()).toEqual([
|
||||
{ text: '새 휴대폰 등록', current: null, done: true },
|
||||
{ text: '', current: null, done: false },
|
||||
{ text: '지금 휴대폰으로 확인', current: 'step', done: false },
|
||||
])
|
||||
expect(inDialog('#two-factor-replace-message')?.textContent?.trim()).toBe(
|
||||
'새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다.',
|
||||
)
|
||||
expect(document.querySelector('label[for="two-factor-replace-current-code"]')?.textContent).toBe('지금 휴대폰의 앱에 표시된 6자리 코드')
|
||||
expect(document.querySelector('label[for="two-factor-replace-password"]')?.textContent).toBe('지금 비밀번호')
|
||||
expect(wrapper.get('#two-factor-replace-current-code').attributes()).toMatchObject({
|
||||
inputmode: 'numeric',
|
||||
autocomplete: 'one-time-code',
|
||||
})
|
||||
expect(wrapper.get('#two-factor-replace-password').attributes('autocomplete')).toBe('current-password')
|
||||
// 연락처가 없으면 글만
|
||||
expect(inDialog('[data-support-line]')?.textContent?.trim()).toBe(SUPPORT)
|
||||
expect(inDialog('[data-support-contact]')).toBeNull()
|
||||
// ①의 칸 · QR 은 없다
|
||||
expect(inDialog('[data-qr-code]')).toBeNull()
|
||||
expect(document.getElementById('two-factor-replace-new-code')).toBeNull()
|
||||
expect(Array.from(dialog()?.querySelectorAll('.modal-dialog__actions button') ?? []).map((button) => button.textContent?.trim())).toEqual([
|
||||
'이전',
|
||||
'바꾸기',
|
||||
'취소',
|
||||
])
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
|
||||
})
|
||||
|
||||
it('문의 연락처가 있으면 물음 다음 줄에 연락처', async () => {
|
||||
const wrapper = await mountPage({ supportContact: () => ok({ text: 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it' }) })
|
||||
await toCurrentStep(wrapper)
|
||||
|
||||
expect(inDialog('[data-support-line]')?.textContent).toContain(SUPPORT)
|
||||
expect(inDialog('[data-support-contact]')?.textContent).toBe('IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it')
|
||||
expect(calls('/support-contact')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('보내기 전에 본다 — 칸 차례(지금 코드 → 비밀번호): 코드가 6자리가 아니면 AUTH_007 문구, 비밀번호를 비우면 VAL_001 문구', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await toCurrentStep(wrapper)
|
||||
|
||||
await submit(wrapper)
|
||||
expect(calls('/two-factor/replacements')).toEqual([])
|
||||
expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
|
||||
|
||||
await fillCurrent(wrapper, { code: '135 790' })
|
||||
await submit(wrapper)
|
||||
expect(calls('/two-factor/replacements')).toEqual([])
|
||||
expect(wrapper.find('#two-factor-replace-current-code-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#two-factor-replace-password-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-password')
|
||||
})
|
||||
|
||||
it('[바꾸기] — POST /two-factor/replacements { enrollmentId, currentOtpCode, currentPassword } · 30초, 창을 닫고 알림 · 화면은 그대로 · 초점은 [휴대폰 바꾸기]', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(calls('/two-factor/replacements').map((request) => request.data)).toEqual([
|
||||
{ enrollmentId: 'enroll-1', currentOtpCode: '135790', currentPassword: PASSWORD },
|
||||
])
|
||||
expect(calls('/two-factor/replacements')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
|
||||
expect(dialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(toastMessages()).toEqual([DONE])
|
||||
expect(useToastStore().toasts[0]).toMatchObject({ kind: 'success' })
|
||||
// 켠 때는 그대로
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
|
||||
expect(document.activeElement?.hasAttribute('data-two-factor-replace')).toBe(true)
|
||||
// 키 · 칸은 지웠다 (닫힌 창은 ①의 빈 자리로 돌아간다)
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('')
|
||||
expect(inDialog('[data-qr-code]')).toBeNull()
|
||||
expect(inputValue('two-factor-replace-password')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('바꾸는 동안 「바꾸는 중…」 — [바꾸기] · [이전]은 aria-disabled, 다시 눌러도 요청은 하나 · [이전]도 움직이지 않는다', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ replace: handler })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
|
||||
const button = wrapper.get('[data-replace-submit]')
|
||||
expect(button.text()).toBe('바꾸는 중…')
|
||||
expect(button.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
|
||||
expect(wrapper.get('[data-replace-back]').attributes('aria-disabled')).toBe('true')
|
||||
await submit(wrapper)
|
||||
await wrapper.get('[data-replace-back]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(calls('/two-factor/replacements')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
|
||||
|
||||
replies[0]?.(ok(ON))
|
||||
await flushPromises()
|
||||
expect(toastMessages()).toEqual([DONE])
|
||||
})
|
||||
|
||||
it('지금 비밀번호가 틀리면(AUTH_005) 비밀번호 칸 아래 · 그 칸에 초점 — 코드는 남긴다, 알림 없음', async () => {
|
||||
const wrapper = await mountPage({ replace: () => fail(400, 'AUTH_005') })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: 'wrong-pass' })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.get('#two-factor-replace-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-password')
|
||||
expect(inputValue('two-factor-replace-current-code')).toBe('135790')
|
||||
expect(dialog()?.hasAttribute('open')).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('지금 휴대폰의 코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비우고 초점 — 비밀번호는 남긴다, 알림 없음', async () => {
|
||||
const wrapper = await mountPage({ replace: () => fail(400, 'AUTH_006') })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '000111', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect(inputValue('two-factor-replace-current-code')).toBe('')
|
||||
expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('AUTH_008 · 아직 켜져 있음 — ①로 돌아와 창 위에 사전 문구, 새 키 · 새 휴대폰의 코드는 비운다', async () => {
|
||||
const wrapper = await mountPage({ replace: () => fail(409, 'AUTH_008') })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
|
||||
expect(stepItems()[0]).toMatchObject({ current: 'step', done: false })
|
||||
expect(wrapper.get('[data-replace-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(2)
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(inputValue('two-factor-replace-new-code')).toBe('')
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
// 새 키로 다시 확인하면 ②의 창 위 글은 내린다 — 그 등록으로 보낸다
|
||||
await next(wrapper, '246802')
|
||||
expect(wrapper.find('[data-replace-notice]').exists()).toBe(false)
|
||||
expect(verifyCalls().at(-1)?.url).toBe('/two-factor/enrollments/enroll-2/verification')
|
||||
})
|
||||
|
||||
it('AUTH_008 · 그사이 다른 곳에서 꺼짐 — 창을 닫고 사전 문구로 알린다, 화면은 「꺼짐」', async () => {
|
||||
let disabledElsewhere = false
|
||||
const wrapper = await mountPage({
|
||||
status: () => ok(disabledElsewhere ? OFF : ON),
|
||||
replace: () => {
|
||||
disabledElsewhere = true
|
||||
return fail(409, 'AUTH_008')
|
||||
},
|
||||
})
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_008])
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('결과를 모르면(503 MAIL_001) 바뀌었다고도 아니라고도 하지 않는다 — 창 위 상자 · ② 그대로 · 코드는 비우고 초점, 사전 알림 없음', async () => {
|
||||
const wrapper = await mountPage({ replace: () => fail(503, 'MAIL_001') })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
|
||||
const notice = wrapper.get('[data-replace-notice]')
|
||||
expect(notice.text()).toBe(UNCONFIRMED)
|
||||
expect(notice.attributes('role')).toBe('alert')
|
||||
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
|
||||
expect(inputValue('two-factor-replace-current-code')).toBe('')
|
||||
expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
|
||||
expect(toastMessages()).toEqual([])
|
||||
// 화면의 상태는 건드리지 않는다
|
||||
expect(useTwoFactorStore().twoFactor).toEqual(ON)
|
||||
})
|
||||
|
||||
it('결과를 모르는 채 다시 [바꾸기] — 코드가 맞지 않다고 나와도 창 위 상자는 남긴다', async () => {
|
||||
let replies = 0
|
||||
const wrapper = await mountPage({
|
||||
replace: () => {
|
||||
replies += 1
|
||||
return replies === 1 ? 'network' : fail(400, 'AUTH_006')
|
||||
},
|
||||
})
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
expect(wrapper.get('[data-replace-notice]').text()).toBe(UNCONFIRMED)
|
||||
// 응답 없음(SYS_001)도 사전 알림 없이 같은 상자
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
await fillCurrent(wrapper, { code: '975310' })
|
||||
await submit(wrapper)
|
||||
expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect(wrapper.get('[data-replace-notice]').text()).toBe(UNCONFIRMED)
|
||||
})
|
||||
|
||||
it('[이전] — ①로(같은 키 · 새 키를 받지 않음), 새 휴대폰의 코드는 비우고 초점, 다시 [다음]은 같은 등록으로', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
|
||||
await wrapper.get('[data-replace-back]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
|
||||
expect(stepItems()[0]).toMatchObject({ current: 'step', done: false })
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(1)
|
||||
expect(inputValue('two-factor-replace-new-code')).toBe('')
|
||||
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
|
||||
|
||||
await next(wrapper, '246802')
|
||||
expect(verifyCalls().map((request) => request.url)).toEqual([
|
||||
'/two-factor/enrollments/enroll-1/verification',
|
||||
'/two-factor/enrollments/enroll-1/verification',
|
||||
])
|
||||
// 지금 휴대폰의 코드는 시간이 지나 비웠고, 지금 비밀번호는 창을 닫을 때까지 남는다
|
||||
expect(inputValue('two-factor-replace-current-code')).toBe('')
|
||||
expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
|
||||
})
|
||||
|
||||
it('두 칸에서 Enter 도 [바꾸기]와 같다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await wrapper.get('#two-factor-replace-password').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(calls('/two-factor/replacements')).toHaveLength(1)
|
||||
expect(toastMessages()).toEqual([DONE])
|
||||
})
|
||||
})
|
||||
|
||||
describe('닫은 뒤 · 세션', () => {
|
||||
it('[취소]로 닫아도 서버가 바꿨으면 화면을 맞추고 알린다', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ replace: handler })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
await wrapper.get('[data-replace-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog()?.hasAttribute('open')).toBe(false)
|
||||
|
||||
replies[0]?.(ok({ ...ON, enabledAt: '2026-11-01T00:00:00.000Z' }))
|
||||
await flushPromises()
|
||||
expect(toastMessages()).toEqual([DONE])
|
||||
expect(useTwoFactorStore().twoFactor?.enabledAt).toBe('2026-11-01T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('닫은 뒤 온 결과를 모르는 실패는 알림으로 · 칸 아래 거절은 사전 문구 알림으로', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ replace: handler })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
await wrapper.get('[data-replace-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
replies[0]?.(fail(503, 'MAIL_001'))
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([['error', UNCONFIRMED_CLOSED]])
|
||||
|
||||
useToastStore().clear()
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: 'wrong-pass' })
|
||||
await submit(wrapper)
|
||||
await wrapper.get('[data-replace-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
replies[1]?.(fail(400, 'AUTH_005'))
|
||||
await flushPromises()
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_005])
|
||||
})
|
||||
|
||||
it('닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다 — 새 키로 ①부터', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ replace: handler })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
await wrapper.get('[data-replace-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await openReplace(wrapper)
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-replace-next]').text()).toBe('다음')
|
||||
|
||||
replies[0]?.(fail(400, 'AUTH_006'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#two-factor-replace-new-code-error').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-replace-notice]').exists()).toBe(false)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('닫으면 키 · 칸을 지운다 — 다시 열면 새 키, 닫은 뒤 늦게 온 확인은 ②로 넘기지 않는다', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ verify: handler })
|
||||
await openReplace(wrapper)
|
||||
await next(wrapper, '482913')
|
||||
await wrapper.get('[data-replace-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('')
|
||||
|
||||
replies[0]?.(ok({ verified: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(false)
|
||||
|
||||
await openReplace(wrapper)
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(inputValue('two-factor-replace-new-code')).toBe('')
|
||||
})
|
||||
|
||||
it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 창을 닫고 키 · 코드 · 비밀번호를 지운다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(inDialog('[data-qr-code]')).toBeNull()
|
||||
expect(inDialog('[data-enable-key]')?.textContent).toBe('')
|
||||
expect(document.getElementById('two-factor-replace-password')).toBeNull()
|
||||
expect(document.getElementById('two-factor-replace-current-code')).toBeNull()
|
||||
expect(inputValue('two-factor-replace-new-code')).toBe('')
|
||||
})
|
||||
|
||||
it('바꾸는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 쓰지 않는다 — 알림도 없다', async () => {
|
||||
const { handler, replies } = held()
|
||||
const wrapper = await mountPage({ replace: handler })
|
||||
await toCurrentStep(wrapper)
|
||||
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submit(wrapper)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
replies[0]?.(ok(ON))
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual([])
|
||||
expect(useTwoFactorStore().twoFactor).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -110,6 +110,8 @@ describe('AccountTwoFactorPage', () => {
|
||||
expect(wrapper.get('[data-two-factor-state]').text()).toContain('지금은 로그인할 때 비밀번호만 확인합니다.')
|
||||
expect(wrapper.get('[data-two-factor-enable]').text()).toBe('2단계 인증 켜기')
|
||||
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
|
||||
// 꺼져 있으면 [휴대폰 바꾸기]가 없다 — 켜기 창으로 새 휴대폰에서 켠다
|
||||
expect(wrapper.find('[data-two-factor-replace]').exists()).toBe(false)
|
||||
const warning = wrapper.get('[data-two-factor-warning]')
|
||||
expect(warning.text()).toContain('켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다')
|
||||
expect(warning.get('a').text()).toBe('앱 비밀번호')
|
||||
@@ -120,12 +122,18 @@ describe('AccountTwoFactorPage', () => {
|
||||
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
|
||||
it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [휴대폰 바꾸기] [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON) })
|
||||
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
|
||||
expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on')
|
||||
expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기')
|
||||
// [휴대폰 바꾸기]는 [2단계 인증 끄기] 바로 앞 (16단계)
|
||||
const actions = wrapper.get('.two-factor__actions').element.children
|
||||
expect(Array.from(actions).map((child) => child.textContent?.trim())).toEqual(['휴대폰 바꾸기', '2단계 인증 끄기'])
|
||||
expect(wrapper.get('[data-two-factor-notes]').text()).toContain(
|
||||
'휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.',
|
||||
)
|
||||
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
|
||||
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('메일 프로그램은 앱 비밀번호로 연결합니다.')
|
||||
@@ -661,14 +669,19 @@ describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => {
|
||||
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 휴대폰 바꾸기 안내는 관리자에게 해제 요청', async () => {
|
||||
it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 그 앞에 [휴대폰 바꾸기] · 안내 첫 줄은 보통 직원과 같은 글 (16단계)', async () => {
|
||||
const wrapper = await mountWith({ ...ON, isRequired: true, deadline: null })
|
||||
|
||||
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다')
|
||||
expect(wrapper.get('[data-two-factor-replace]').text()).toBe('휴대폰 바꾸기')
|
||||
// [휴대폰 바꾸기]가 끌 수 없다는 글 앞
|
||||
const actions = wrapper.get('.two-factor__actions').element.children
|
||||
expect(Array.from(actions).map((child) => child.textContent?.trim())).toEqual(['휴대폰 바꾸기', '회사 정책에 따라 끌 수 없습니다'])
|
||||
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
|
||||
const notes = wrapper.get('[data-two-factor-notes]').text()
|
||||
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.')
|
||||
expect(notes).toContain('휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.')
|
||||
expect(notes).not.toContain('관리자에게 2단계 인증을 풀어 달라고 요청해 주세요')
|
||||
expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 2단계 인증 (디자인 원본 SettingsTwoFactor · README "12단계 › 설정 › 계정 › 2단계 인증" · 계약 5절 "2단계 인증")
|
||||
// — /settings/account/two-factor · 탭 제목 "2단계 인증"
|
||||
// 머리 + 설명 → 상태 상자(글자로만 — 뱃지 · 스위치 없음): 「꺼짐」(회색 굵게) + [2단계 인증 켜기] / 「켜짐 — 2026년 10월 31일부터」(초록 굵게 — 켠 때를 모르면 「켜짐」) + [2단계 인증 끄기]
|
||||
// 머리 + 설명 → 상태 상자(글자로만 — 뱃지 · 스위치 없음): 「꺼짐」(회색 굵게) + [2단계 인증 켜기] /
|
||||
// 「켜짐 — 2026년 10월 31일부터」(초록 굵게 — 켠 때를 모르면 「켜짐」) + [휴대폰 바꾸기](테두리 44 — 16단계) [2단계 인증 끄기]
|
||||
// - 꺼져 있을 때: 갈색 경고(켜면 메일 프로그램은 앱 비밀번호로 — 앱 비밀번호 링크) + 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자)
|
||||
// - 켜져 있을 때: 안내 상자(휴대폰 바꾸기 · 잃어버림 · 메일 프로그램은 앱 비밀번호로)
|
||||
// - 켜기 창(TwoFactorEnableDialog — 620) · 끄기 창(TwoFactorDisableDialog — 400, 처음 초점 「취소」)
|
||||
// - 켜져 있을 때: 안내 상자(휴대폰 바꾸기 — [휴대폰 바꾸기]로 · 잃어버림 · 메일 프로그램은 앱 비밀번호로)
|
||||
// - 켜기 창(TwoFactorEnableDialog — 620) · 끄기 창(TwoFactorDisableDialog — 400, 처음 초점 「취소」) ·
|
||||
// 휴대폰 바꾸기 창(TwoFactorReplaceDialog — 620, 두 걸음 — 16단계 · 디자인 원본 Settings16 5~8)
|
||||
// - 켠 직후: 머리 아래 안내 상자 "2단계 인증을 켰습니다" + [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다(화면 상태). 초점은 그 상자로
|
||||
// - 끈 뒤: 알림 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」, 초점은 [2단계 인증 켜기]로
|
||||
// - 휴대폰을 바꾼 뒤: 알림(창이 띄운다) — 화면은 그대로(켠 때도 그대로), 초점은 [휴대폰 바꾸기]로
|
||||
// - 들어올 때마다 상태를 새로 받는다(two-factor.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
|
||||
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫는다 — 세션 만료 창 뒤에 QR · 키 · 입력한 비밀번호가 남지 않게
|
||||
// (창은 닫힐 때 키 · 칸을 지운다)
|
||||
// 회사가 2단계 인증을 필수로 정한 직원(13단계 — GET /two-factor 의 isRequired · deadline, 디자인 README "13단계 › 설정 › 2단계 인증 — 필수인 직원"):
|
||||
// - 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패) "11월 30일까지 2단계 인증을 켜 주세요" (날짜는 시작일 전날 — 올해가 아니면 해를 붙인다)
|
||||
// - 켜짐 — [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 VAL_001), 아래 안내의 첫 줄은 관리자에게 해제를 요청하는 글
|
||||
// - 켜짐 — [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 VAL_001). [휴대폰 바꾸기]는 그 앞에 그대로 —
|
||||
// 바꾸는 동안 2단계 인증이 꺼지지 않아 정책과 어긋나지 않는다(16단계). 아래 안내의 첫 줄도 보통 직원과 같은 글
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -21,9 +25,10 @@ import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import TwoFactorDisableDialog from '@/components/settings/TwoFactorDisableDialog.vue'
|
||||
import TwoFactorEnableDialog from '@/components/settings/TwoFactorEnableDialog.vue'
|
||||
import TwoFactorReplaceDialog from '@/components/settings/TwoFactorReplaceDialog.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
|
||||
import { TWO_FACTOR_REPLACE_TEXT, TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
|
||||
import { TWO_FACTOR_GRACE_TEXT } from '@/shared/constants/session-restriction'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import { twoFactorStateLabel } from '@/shared/utils/account-security'
|
||||
@@ -37,9 +42,11 @@ const toast = useToast()
|
||||
const clock = useClock()
|
||||
|
||||
const enableRef = ref<HTMLButtonElement | null>(null)
|
||||
const replaceRef = ref<HTMLButtonElement | null>(null)
|
||||
const noticeRef = ref<HTMLElement | null>(null)
|
||||
const enableOpen = ref<boolean>(false)
|
||||
const disableOpen = ref<boolean>(false)
|
||||
const replaceOpen = ref<boolean>(false)
|
||||
/** 이 화면에서 방금 켰다 — 머리 아래 안내 상자 (떠나면 사라진다) */
|
||||
const justEnabled = ref<boolean>(false)
|
||||
|
||||
@@ -73,12 +80,20 @@ async function onDisabled() {
|
||||
enableRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 휴대폰을 바꿨다 — 알림은 창이 띄웠다. 화면은 그대로, 초점은 [휴대폰 바꾸기]로 */
|
||||
async function onReplaced() {
|
||||
replaceOpen.value = false
|
||||
await nextTick()
|
||||
replaceRef.value?.focus()
|
||||
}
|
||||
|
||||
// 세션이 바뀌면 창을 모두 닫는다 (켠 직후 안내도 내린다)
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
enableOpen.value = false
|
||||
disableOpen.value = false
|
||||
replaceOpen.value = false
|
||||
justEnabled.value = false
|
||||
},
|
||||
)
|
||||
@@ -169,20 +184,34 @@ void twoFactorStore.load()
|
||||
/>
|
||||
<span>{{ TWO_FACTOR_TEXT.enable }}</span>
|
||||
</button>
|
||||
<span
|
||||
v-else-if="required"
|
||||
class="two-factor__locked"
|
||||
data-two-factor-locked
|
||||
>{{ TWO_FACTOR_GRACE_TEXT.cannotDisable }}</span>
|
||||
<button
|
||||
<div
|
||||
v-else
|
||||
type="button"
|
||||
class="two-factor__disable"
|
||||
data-two-factor-disable
|
||||
@click="disableOpen = true"
|
||||
class="two-factor__actions"
|
||||
>
|
||||
{{ TWO_FACTOR_TEXT.disable }}
|
||||
</button>
|
||||
<button
|
||||
ref="replaceRef"
|
||||
type="button"
|
||||
class="two-factor__replace"
|
||||
data-two-factor-replace
|
||||
@click="replaceOpen = true"
|
||||
>
|
||||
{{ TWO_FACTOR_REPLACE_TEXT.open }}
|
||||
</button>
|
||||
<span
|
||||
v-if="required"
|
||||
class="two-factor__locked"
|
||||
data-two-factor-locked
|
||||
>{{ TWO_FACTOR_GRACE_TEXT.cannotDisable }}</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="two-factor__disable"
|
||||
data-two-factor-disable
|
||||
@click="disableOpen = true"
|
||||
>
|
||||
{{ TWO_FACTOR_TEXT.disable }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!enabled">
|
||||
@@ -239,7 +268,7 @@ void twoFactorStore.load()
|
||||
>
|
||||
<p class="two-factor__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ required ? TWO_FACTOR_GRACE_TEXT.noteChangePhone : TWO_FACTOR_TEXT.noteChangePhone }}</span>
|
||||
<span>{{ TWO_FACTOR_TEXT.noteChangePhone }}</span>
|
||||
</p>
|
||||
<p class="two-factor__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
@@ -275,6 +304,12 @@ void twoFactorStore.load()
|
||||
@close="disableOpen = false"
|
||||
@disabled="onDisabled"
|
||||
/>
|
||||
<TwoFactorReplaceDialog
|
||||
:open="replaceOpen"
|
||||
:email="email"
|
||||
@close="replaceOpen = false"
|
||||
@replaced="onReplaced"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
@@ -395,6 +430,29 @@ void twoFactorStore.load()
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 켜져 있을 때의 단추 묶음 — [휴대폰 바꾸기] [2단계 인증 끄기](또는 끌 수 없다는 글). 좁으면 줄을 바꾼다 */
|
||||
.two-factor__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-12);
|
||||
}
|
||||
|
||||
/* 휴대폰 바꾸기 — 흰 바탕 · 테두리 (아이콘 없음 — 끄기 단추와 같은 꼴, 16단계) */
|
||||
.two-factor__replace {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.two-factor__replace:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 끄기 — 흰 바탕 · 빨간 글 · 테두리 */
|
||||
.two-factor__disable {
|
||||
min-height: var(--size-touch);
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { MailSignature, SaveSignaturePayload, SignatureImage } from '@/shared/types/mail'
|
||||
import type { SignatureImage } from '@/shared/types/mail'
|
||||
import type { MailSignature, MailSignatureDefaults, MailSignaturePayload } from '@/shared/types/mail-signature'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -21,62 +22,95 @@ import { editorOf, installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import MailSignaturePage from './MailSignaturePage.vue'
|
||||
|
||||
// 설정 › 메일 › 서명 (디자인 원본 Settings · 계약 2절 "서명")
|
||||
// 설정 › 메일 › 서명 — 여러 서명 (디자인 원본 Signature16 · 계약 2절 "서명", 16단계)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 쓰기 화면 테스트와 같은 값 */
|
||||
/** 서명 화면(지연 로딩한 TipTap 청크)을 기다리는 시간 — 쓰기 화면 테스트와 같은 값 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
|
||||
const NEW_IMAGE_ID = '00000000-0000-4000-8000-000000000001'
|
||||
const SAVED_AT = '2026-10-27T06:00:00Z'
|
||||
const SAVED_AT = '2026-11-10T06:00:00Z'
|
||||
|
||||
const SIGNATURE: MailSignature = {
|
||||
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p></div>',
|
||||
body: '김아라 · 인프라팀',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: '2026-10-27T05:00:00Z',
|
||||
function signature(id: string, name: string, html: string): MailSignature {
|
||||
return { id, name, bodyHtml: html, body: name, images: [], updatedAt: '2026-11-10T05:00:00Z' }
|
||||
}
|
||||
|
||||
/** 보드의 예시 — 사내(답장과 전달 기본) · 대외(새 메일 기본) · 영문 */
|
||||
const IN = signature('in', '사내', '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p>')
|
||||
const OUT = signature('out', '대외', '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM</p>')
|
||||
const EN = signature('en', '영문', '<p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p>')
|
||||
const DEFAULTS: MailSignatureDefaults = { newMessageId: 'out', replyId: 'in' }
|
||||
|
||||
function imageOf(id: string, width: number): SignatureImage {
|
||||
return { id, type: 'image/png', size: 1000, width, height: 60 }
|
||||
}
|
||||
|
||||
interface Server {
|
||||
signature?: MailSignature | MockResult
|
||||
interface ServerOptions {
|
||||
list?: MailSignature[]
|
||||
defaults?: MailSignatureDefaults
|
||||
/** 목록 받기 응답을 바꾼다 (실패 등) — undefined 면 지금 목록 */
|
||||
listReply?: () => MockResult | undefined
|
||||
/** 그림 올리기 응답 — 비면 NEW_IMAGE_ID · 폭 480 */
|
||||
uploads?: MockResult[]
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** 서명 만들기 · 고치기 응답 — undefined 면 서버처럼 저장한다 */
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined
|
||||
/** 기본 둘 저장 응답 — undefined 면 서버처럼 저장한다 */
|
||||
saveDefaults?: (request: MockRequest) => MockResult | undefined
|
||||
}
|
||||
|
||||
function server(options: Server = {}): MockHandler {
|
||||
/** 서명 서버 — 목록 · 기본 둘을 기억한다 (만들기 · 고치기 · 지우기가 다음 받기에 보인다) */
|
||||
function server(options: ServerOptions = {}): MockHandler {
|
||||
let list = [...(options.list ?? [IN, OUT, EN])]
|
||||
let defaults = { ...(options.defaults ?? DEFAULTS) }
|
||||
const uploads = [...(options.uploads ?? [])]
|
||||
return (request) => {
|
||||
let created = 0
|
||||
const page = () => ok({ items: list, pageInfo: { hasNext: false, nextCursor: null, total: list.length } })
|
||||
return async (request) => {
|
||||
const { method, url } = request
|
||||
if (method === 'GET' && url === '/mail-signature') {
|
||||
const signature = options.signature ?? SIGNATURE
|
||||
return typeof signature === 'string' || 'status' in signature ? signature : ok(signature)
|
||||
if (method === 'GET' && url === '/mail-signatures') return options.listReply?.() ?? page()
|
||||
if (method === 'GET' && url === '/mail-signature-defaults') return ok(defaults)
|
||||
if (method === 'PUT' && url === '/mail-signature-defaults') {
|
||||
const reply = options.saveDefaults?.(request)
|
||||
if (reply) return reply
|
||||
defaults = { ...(request.data as MailSignatureDefaults) }
|
||||
return ok(defaults)
|
||||
}
|
||||
if (method === 'POST' && url === '/mail-signature/images') {
|
||||
return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
|
||||
if (method === 'POST' && url === '/mail-signature/images') return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
|
||||
if ((method === 'POST' && url === '/mail-signatures') || (method === 'PUT' && url.startsWith('/mail-signatures/'))) {
|
||||
const reply = await options.save?.(request)
|
||||
if (reply) return reply
|
||||
const body = request.data as MailSignaturePayload
|
||||
if (method === 'POST') {
|
||||
created += 1
|
||||
const saved: MailSignature = { id: `new-${created}`, ...body, images: [], updatedAt: SAVED_AT }
|
||||
if (list.length === 0) defaults = { newMessageId: saved.id, replyId: saved.id }
|
||||
list = [...list, saved]
|
||||
return ok(saved, 201)
|
||||
}
|
||||
const id = url.slice('/mail-signatures/'.length)
|
||||
const saved: MailSignature = { id, ...body, images: [], updatedAt: SAVED_AT }
|
||||
list = list.map((item) => (item.id === id ? saved : item))
|
||||
return ok(saved)
|
||||
}
|
||||
if (method === 'PUT' && url === '/mail-signature') {
|
||||
if (options.save) return options.save(request)
|
||||
const body = request.data as SaveSignaturePayload
|
||||
return ok({ ...body, images: [], updatedAt: SAVED_AT })
|
||||
if (method === 'DELETE' && url.startsWith('/mail-signatures/')) {
|
||||
const id = url.slice('/mail-signatures/'.length)
|
||||
list = list.filter((item) => item.id !== id)
|
||||
defaults = {
|
||||
newMessageId: defaults.newMessageId === id ? null : defaults.newMessageId,
|
||||
replyId: defaults.replyId === id ? null : defaults.replyId,
|
||||
}
|
||||
return { status: 204 }
|
||||
}
|
||||
return ok(null)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailSignaturePage', () => {
|
||||
describe('MailSignaturePage — 여러 서명 (16단계)', () => {
|
||||
let pinia: Pinia
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/signature/SignatureForm.vue')
|
||||
await import('@/components/mail/editor/signature/SignatureSettings.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -89,11 +123,13 @@ describe('MailSignaturePage', () => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountPage(handler: MockHandler, waitForEditor = true) {
|
||||
/** 서명 화면을 띄운다 — waitFor 가 나타날 때까지 기다린다 (null 이면 기다리지 않는다) */
|
||||
async function mountPage(handler: MockHandler, waitFor: string | null = '#signature-body') {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/signature')
|
||||
@@ -105,10 +141,10 @@ describe('MailSignaturePage', () => {
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
if (waitForEditor) {
|
||||
if (waitFor) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
|
||||
if (!wrapper.find(waitFor).exists()) throw new Error('서명 화면을 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
@@ -117,24 +153,66 @@ describe('MailSignaturePage', () => {
|
||||
return { wrapper, requests, router }
|
||||
}
|
||||
|
||||
function choose(wrapper: VueWrapper, files: File[]) {
|
||||
const picker = wrapper.get('#signature-image-input')
|
||||
Object.defineProperty(picker.element, 'files', { value: files, configurable: true })
|
||||
return picker.trigger('change')
|
||||
/** 목록 줄 — 이름과 아래 회색 글 (사이에 빈칸 하나) */
|
||||
function rows(wrapper: VueWrapper): string[] {
|
||||
return wrapper.findAll('[data-signature-row]').map((row) =>
|
||||
row
|
||||
.findAll('span')
|
||||
.map((part) => part.text())
|
||||
.join(' '),
|
||||
)
|
||||
}
|
||||
|
||||
const png = (name = '로고.png', size = 1000) => new File([new Uint8Array(size)], name, { type: 'image/png' })
|
||||
|
||||
function saveRequest(requests: MockRequest[]) {
|
||||
return requests.find((request) => request.method === 'PUT' && request.url === '/mail-signature')
|
||||
function currentRow(wrapper: VueWrapper): string | undefined {
|
||||
return wrapper.find('[data-signature-row][aria-current="true"]').attributes('data-signature-row')
|
||||
}
|
||||
|
||||
it('저장한 서명으로 칸을 채우고, 넣을 메일 · 미리 보기를 보인다', async () => {
|
||||
function dialogOpen(wrapper: VueWrapper, id: string): boolean {
|
||||
return wrapper.find(`#${id}`).exists() && wrapper.get(`#${id}`).attributes('open') !== undefined
|
||||
}
|
||||
|
||||
/** 서명 끝에 한 줄을 더 쓴다 */
|
||||
async function typeLine(wrapper: VueWrapper, text: string) {
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, `<p>${text}</p>`)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 다른 서명을 열고 새 편집기가 붙을 때까지 기다린다 */
|
||||
async function pick(wrapper: VueWrapper, id: string) {
|
||||
await wrapper.get(`[data-signature-row="${id}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const saveRequests = (requests: MockRequest[]) =>
|
||||
requests.filter((request) => request.url.startsWith('/mail-signatures') && request.method !== 'GET')
|
||||
|
||||
it('메일에 넣을 서명 · 내 서명 목록 · 고른 서명 — 처음에는 새 메일에 넣는 서명이 골라져 있다', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('서명')
|
||||
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀')
|
||||
expect(wrapper.get('#signature-body').attributes('aria-labelledby')).toBe('signature-label')
|
||||
expect(wrapper.text()).toContain(
|
||||
'보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.',
|
||||
)
|
||||
// 기본 둘 — 서명(만든 차례) · 넣지 않음
|
||||
expect(wrapper.get('legend').text()).toBe('메일에 넣을 서명')
|
||||
const options = wrapper.findAll('#signature-default-new option').map((option) => option.text())
|
||||
expect(options).toEqual(['사내', '대외', '영문', '넣지 않음'])
|
||||
expect((wrapper.get('#signature-default-new').element as HTMLSelectElement).value).toBe('out')
|
||||
expect((wrapper.get('#signature-default-reply').element as HTMLSelectElement).value).toBe('in')
|
||||
expect(wrapper.get('#signature-defaults-hint').text()).toBe(
|
||||
'메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.',
|
||||
)
|
||||
// 목록 — 이름 아래 그 서명을 기본으로 넣는 메일
|
||||
expect(wrapper.get('#signature-list-label').text()).toBe('내 서명')
|
||||
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '대외 새 메일', '영문'])
|
||||
expect(currentRow(wrapper)).toBe('out')
|
||||
expect(wrapper.get('#signature-count').text()).toBe('서명 3개를 만들었고, 10개까지 만들 수 있습니다.')
|
||||
// 고른 서명 — 이름 · 서명 칸 · 지우기
|
||||
expect((wrapper.get('#signature-name').element as HTMLInputElement).value).toBe('대외')
|
||||
expect(wrapper.get('#signature-name').attributes('maxlength')).toBe('30')
|
||||
expect(wrapper.get('#signature-name-hint').text()).toBe('메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.')
|
||||
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀ARA SYSTEM')
|
||||
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('서명 서식')
|
||||
expect(wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'굵게',
|
||||
@@ -144,25 +222,33 @@ describe('MailSignaturePage', () => {
|
||||
'링크',
|
||||
'그림 넣기',
|
||||
])
|
||||
expect((wrapper.get('#signature-for-new').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((wrapper.get('#signature-for-reply').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.get('[data-signature-preview]').text()).toBe('김아라 · 인프라팀')
|
||||
expect(wrapper.get('#signature-delete').text()).toBe('이 서명 지우기')
|
||||
expect(wrapper.find('#signature-cancel').exists()).toBe(false)
|
||||
// 미리 보기 — 고른 서명이 들어갈 자리
|
||||
expect(wrapper.get('[data-signature-preview]').text()).toBe('김아라 · 인프라팀ARA SYSTEM')
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('새 메일은 본문 끝에 서명을 넣습니다.')
|
||||
await wrapper.get('[data-mode="reply"]').trigger('click')
|
||||
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 인용한 원문 위에 서명을 넣습니다.')
|
||||
})
|
||||
|
||||
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
|
||||
let fails = 1
|
||||
const { wrapper, requests } = await mountPage(
|
||||
(request) => {
|
||||
if (request.url === '/mail-signature' && fails > 0) {
|
||||
server({
|
||||
listReply: () => {
|
||||
if (fails === 0) return undefined
|
||||
fails -= 1
|
||||
return fail(503, 'MAIL_001')
|
||||
}
|
||||
return server()(request)
|
||||
},
|
||||
false,
|
||||
},
|
||||
}),
|
||||
null,
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
if (!wrapper.find('[role="alert"]').exists()) throw new Error('받는 중')
|
||||
})
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('[role="alert"] button').trigger('click')
|
||||
await vi.waitFor(
|
||||
@@ -171,162 +257,357 @@ describe('MailSignaturePage', () => {
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
expect(requests.filter((request) => request.url === '/mail-signature')).toHaveLength(2)
|
||||
expect(requests.filter((request) => request.url === '/mail-signatures')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('[저장] — 쓰기 본문과 같은 서식 본문 · 텍스트 서명 · 넣을 메일을 보내고 "저장했습니다"를 보인다 (바꾸면 내린다)', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>ARA SYSTEM</p>')
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
describe('서명이 없을 때 · 새 서명', () => {
|
||||
it('안내와 [새 서명]만 — 메일에 넣을 서명 칸과 목록은 없다', async () => {
|
||||
const { wrapper } = await mountPage(
|
||||
server({ list: [], defaults: { newMessageId: null, replyId: null } }),
|
||||
'#signature-empty-new',
|
||||
)
|
||||
expect(wrapper.text()).toContain('아직 서명이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.',
|
||||
)
|
||||
expect(wrapper.find('#signature-default-new').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-signature-row]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[새 서명] — 목록 끝에 "저장하지 않음" 줄 · 빈 이름 칸에 초점, [취소]하면 돌아간다', async () => {
|
||||
const { wrapper } = await mountPage(
|
||||
server({ list: [], defaults: { newMessageId: null, replyId: null } }),
|
||||
'#signature-empty-new',
|
||||
)
|
||||
await wrapper.get('#signature-empty-new').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 여는 중')
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(rows(wrapper)).toEqual(['새 서명 저장하지 않음'])
|
||||
expect(currentRow(wrapper)).toBe('new')
|
||||
expect(document.activeElement?.id).toBe('signature-name')
|
||||
expect(wrapper.get('#signature-name').attributes('placeholder')).toBe('예: 사내, 대외, 영문')
|
||||
expect(wrapper.find('#signature-delete').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('#signature-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-signature-row]').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('signature-empty-new')
|
||||
})
|
||||
|
||||
it('첫 서명을 저장하면 두 곳의 기본이 그 서명 — "다음에 메일을 쓸 때부터 들어갑니다."', async () => {
|
||||
const { wrapper, requests } = await mountPage(
|
||||
server({ list: [], defaults: { newMessageId: null, replyId: null } }),
|
||||
'#signature-empty-new',
|
||||
)
|
||||
await wrapper.get('#signature-empty-new').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 여는 중')
|
||||
})
|
||||
await wrapper.get('#signature-name').setValue(' 기본 서명 ')
|
||||
// 빈 서명 칸의 첫 줄에 쓴다
|
||||
editorOf(wrapper.element).commands.insertContent('김아라 · 인프라팀')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveRequests(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /mail-signatures'])
|
||||
expect(saveRequests(requests)[0]?.data).toEqual({
|
||||
name: '기본 서명',
|
||||
bodyHtml: expect.stringMatching(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75">/),
|
||||
body: '김아라 · 인프라팀',
|
||||
})
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.')
|
||||
expect(rows(wrapper)).toEqual(['기본 서명 새 메일, 답장과 전달'])
|
||||
expect(currentRow(wrapper)).toBe('new-1')
|
||||
expect((wrapper.get('#signature-name').element as HTMLInputElement).value).toBe('기본 서명')
|
||||
// 같은 칸 그대로 — 이제 [이 서명 지우기] · 메일에 넣을 서명 칸이 보인다
|
||||
expect(wrapper.find('#signature-delete').exists()).toBe(true)
|
||||
expect((wrapper.get('#signature-default-reply').element as HTMLSelectElement).value).toBe('new-1')
|
||||
// 다시 저장하면 고치기(PUT)
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saveRequests(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'POST /mail-signatures',
|
||||
'PUT /mail-signatures/new-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('두 번째 서명은 기본을 바꾸지 않는다 — "저장했습니다."만', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
await wrapper.get('#signature-new').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#signature-name').setValue('견적용')
|
||||
await typeLine(wrapper, '견적 담당 김아라')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다.')
|
||||
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '대외 새 메일', '영문', '견적용'])
|
||||
expect(useMailSignatureStore().defaults).toEqual(DEFAULTS)
|
||||
})
|
||||
|
||||
it('10개면 [새 서명]은 누를 수 없다 — 초점은 받고 까닭이 목록 아래 글', async () => {
|
||||
const ten = Array.from({ length: 10 }, (_unused, index) => signature(`s${index}`, `서명 ${index + 1}`, '<p>글</p>'))
|
||||
const { wrapper } = await mountPage(server({ list: ten, defaults: { newMessageId: 's0', replyId: null } }))
|
||||
const button = wrapper.get('#signature-new')
|
||||
|
||||
expect(button.attributes('aria-disabled')).toBe('true')
|
||||
expect(button.attributes('aria-describedby')).toBe('signature-count')
|
||||
expect(wrapper.get('#signature-count').text()).toBe('서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-signature-row="new"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('이름 검사 — 1~30자 · 같은 이름(영문 대소문자 무시)', () => {
|
||||
it('비었거나 같은 이름이면 칸 아래 빨간 글 · 이름 칸에 초점 (알림 · 요청 없음), 고치면 안내로', async () => {
|
||||
const ENGLISH = signature('english', 'English', '<p>Ara Kim</p>')
|
||||
const { wrapper, requests } = await mountPage(server({ list: [IN, OUT, ENGLISH] }))
|
||||
const input = wrapper.get('#signature-name')
|
||||
|
||||
await input.setValue(' ')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-name-error').text()).toBe('이름을 넣어 주세요.')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('signature-name-error')
|
||||
expect(document.activeElement?.id).toBe('signature-name')
|
||||
|
||||
await input.setValue('사내')
|
||||
expect(wrapper.find('#signature-name-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#signature-name-hint').exists()).toBe(true)
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-name-error').text()).toBe('같은 이름의 서명이 있습니다.')
|
||||
|
||||
// 영문은 대소문자를 가리지 않는다
|
||||
await input.setValue(' ENGLISH ')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-name-error').text()).toBe('같은 이름의 서명이 있습니다.')
|
||||
expect(saveRequests(requests)).toEqual([])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('그사이 다른 창에서 같은 이름을 만들었으면 서버의 거절(VAL_001)도 같은 빨간 글 — 아니면 서명 칸 아래에 사전 문구', async () => {
|
||||
let otherWindow = false
|
||||
const { wrapper, requests } = await mountPage(
|
||||
server({
|
||||
listReply: () =>
|
||||
otherWindow
|
||||
? ok({ items: [IN, OUT, EN, signature('x', '행사 안내', '<p>글</p>')], pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
|
||||
: undefined,
|
||||
save: () => fail(400, 'VAL_001'),
|
||||
}),
|
||||
)
|
||||
await wrapper.get('#signature-name').setValue('행사 안내')
|
||||
otherWindow = true
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-name-error').text()).toBe('같은 이름의 서명이 있습니다.')
|
||||
expect(requests.filter((request) => request.url === '/mail-signatures' && request.method === 'GET')).toHaveLength(2)
|
||||
|
||||
await wrapper.get('#signature-name').setValue('행사 안내 2')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#signature-name-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#signature-body').attributes('aria-describedby')).toBe('signature-hint signature-error')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('기본이 아닌 서명을 고쳐 저장 — PUT 이름 · 본문, "저장했습니다."', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
await pick(wrapper, 'en')
|
||||
expect((wrapper.get('#signature-name').element as HTMLInputElement).value).toBe('영문')
|
||||
await wrapper.get('#signature-name').setValue('English')
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveRequest(requests)?.data).toEqual({
|
||||
bodyHtml: expect.stringMatching(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75">/),
|
||||
body: '김아라 · 인프라팀\nARA SYSTEM',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
})
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.')
|
||||
expect(useMailSignatureStore().signature?.isForReply).toBe(false)
|
||||
const put = saveRequests(requests)[0]
|
||||
expect(put?.url).toBe('/mail-signatures/en')
|
||||
expect(put?.data).toMatchObject({ name: 'English', body: 'Ara Kim · Infrastructure Team\nARA SYSTEM' })
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다.')
|
||||
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '대외 새 메일', 'English'])
|
||||
|
||||
await wrapper.get('#signature-for-new').setValue(false)
|
||||
// 무엇이든 다시 바꾸면 내린다
|
||||
await wrapper.get('#signature-name').setValue('English 2')
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('비운 서명은 bodyHtml · body 모두 null 로 저장한다', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
editorOf(wrapper.element).commands.clearContent(true)
|
||||
describe('메일에 넣을 서명 — 고르는 대로 바로 저장', () => {
|
||||
it('고르면 PUT 기본 둘 → 고른 칸 오른쪽에 "저장했습니다", 목록의 회색 글도 바뀐다', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
await wrapper.get('#signature-default-reply').setValue('out')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
const put = requests.find((request) => request.method === 'PUT' && request.url === '/mail-signature-defaults')
|
||||
expect(put?.data).toEqual({ newMessageId: 'out', replyId: 'out' })
|
||||
expect(wrapper.get('[data-default-saved="reply"]').text()).toBe('저장했습니다')
|
||||
expect(wrapper.get('[data-default-saved="new"]').text()).toBe('')
|
||||
expect(rows(wrapper)).toEqual(['사내', '대외 새 메일, 답장과 전달', '영문'])
|
||||
|
||||
expect(saveRequest(requests)?.data).toEqual({ bodyHtml: null, body: null, isForNew: true, isForReply: true })
|
||||
// 다시 고르면 내리고, 넣지 않음은 null
|
||||
await wrapper.get('#signature-default-new').setValue('')
|
||||
expect(wrapper.get('[data-default-saved="reply"]').text()).toBe('')
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.url === '/mail-signature-defaults' && request.method === 'PUT').at(-1)?.data).toEqual({
|
||||
newMessageId: null,
|
||||
replyId: 'out',
|
||||
})
|
||||
expect(wrapper.get('[data-default-saved="new"]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림', async () => {
|
||||
const { wrapper } = await mountPage(server({ saveDefaults: () => fail(503, 'MAIL_001') }))
|
||||
await wrapper.get('#signature-default-new').setValue('en')
|
||||
await flushPromises()
|
||||
|
||||
expect((wrapper.get('#signature-default-new').element as HTMLSelectElement).value).toBe('out')
|
||||
expect(wrapper.get('[data-default-saved="new"]').text()).toBe('')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
})
|
||||
|
||||
it('서버가 서명을 거절하면(VAL_001) 알림 없이 서명 칸 아래에 사전 문구', async () => {
|
||||
const { wrapper } = await mountPage(server({ save: () => fail(400, 'VAL_001') }))
|
||||
describe('다른 서명을 고를 때 · [새 서명] — 저장하지 않은 것이 있으면 묻는다', () => {
|
||||
it('바꾼 것이 없으면 묻지 않고 연다', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
await pick(wrapper, 'in')
|
||||
expect(dialogOpen(wrapper, 'signature-switch-dialog')).toBe(false)
|
||||
expect(currentRow(wrapper)).toBe('in')
|
||||
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀내선 1234')
|
||||
})
|
||||
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
it('[계속 고치기]는 그대로(처음 초점), [바꾼 내용 버리기]는 누른 서명을 연다', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
await typeLine(wrapper, '덧붙인 줄')
|
||||
await pick(wrapper, 'in')
|
||||
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#signature-body').attributes('aria-describedby')).toBe('signature-hint signature-error')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(dialogOpen(wrapper, 'signature-switch-dialog')).toBe(true)
|
||||
expect(wrapper.get('#signature-switch-dialog').attributes('role')).toBe('alertdialog')
|
||||
expect(wrapper.get('#signature-switch-title').text()).toBe('저장하지 않은 서명이 있습니다')
|
||||
expect(wrapper.get('#signature-switch-message').text()).toBe('다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.')
|
||||
expect(wrapper.get('#signature-switch-stay').text()).toBe('계속 고치기')
|
||||
expect(wrapper.get('#signature-switch-confirm').text()).toBe('바꾼 내용 버리기')
|
||||
expect(document.activeElement?.id).toBe('signature-switch-stay')
|
||||
|
||||
await wrapper.get('#signature-switch-stay').trigger('click')
|
||||
await flushPromises()
|
||||
expect(currentRow(wrapper)).toBe('out')
|
||||
expect(wrapper.get('#signature-body').text()).toContain('덧붙인 줄')
|
||||
|
||||
await wrapper.get('#signature-new').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-switch-message').text()).toBe('새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.')
|
||||
await wrapper.get('#signature-switch-stay').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await pick(wrapper, 'in')
|
||||
await wrapper.get('#signature-switch-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(currentRow(wrapper)).toBe('in')
|
||||
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀내선 1234')
|
||||
})
|
||||
})
|
||||
|
||||
describe('서명 지우기', () => {
|
||||
it.each([
|
||||
['out', '‘대외’ 서명을 지울까요?', '새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.'],
|
||||
['in', '‘사내’ 서명을 지울까요?', '답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.'],
|
||||
['en', '‘영문’ 서명을 지울까요?', '지운 서명은 되돌릴 수 없습니다.'],
|
||||
])('묻는 글은 기본으로 넣던 곳마다 — %s (처음 초점 [취소])', async (id, title, message) => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
await pick(wrapper, id)
|
||||
await wrapper.get('#signature-delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen(wrapper, 'signature-delete-dialog')).toBe(true)
|
||||
expect(wrapper.get('#signature-delete-dialog').attributes('role')).toBe('alertdialog')
|
||||
expect(wrapper.get('#signature-delete-title').text()).toBe(title)
|
||||
expect(wrapper.get('#signature-delete-message').text()).toBe(message)
|
||||
expect(document.activeElement?.id).toBe('signature-delete-cancel')
|
||||
|
||||
await wrapper.get('#signature-delete-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper, 'signature-delete-dialog')).toBe(false)
|
||||
})
|
||||
|
||||
it('지우면 알림 · 목록의 첫 서명을 열고, 그 서명을 기본으로 넣던 곳은 넣지 않음', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
await wrapper.get('#signature-delete').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#signature-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-signatures/out')).toBe(true)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['‘대외’ 서명을 지웠습니다.'])
|
||||
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '영문'])
|
||||
expect(currentRow(wrapper)).toBe('in')
|
||||
expect((wrapper.get('#signature-default-new').element as HTMLSelectElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('마지막 서명을 지우면 서명이 없을 때의 화면', async () => {
|
||||
const { wrapper } = await mountPage(server({ list: [IN], defaults: { newMessageId: 'in', replyId: 'in' } }))
|
||||
await wrapper.get('#signature-delete').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#signature-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('아직 서명이 없습니다')
|
||||
expect(wrapper.find('#signature-default-new').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('signature-empty-new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('서명 그림', () => {
|
||||
const png = (name = '로고.png', size = 1000) => new File([new Uint8Array(size)], name, { type: 'image/png' })
|
||||
|
||||
function choose(wrapper: VueWrapper, files: File[]) {
|
||||
const picker = wrapper.get('#signature-image-input')
|
||||
Object.defineProperty(picker.element, 'files', { value: files, configurable: true })
|
||||
return picker.trigger('change')
|
||||
}
|
||||
|
||||
it('그림을 올려 보통(144) 폭으로 넣고 고른다 — 크기 줄: 작게 96 · 보통 144 · 원본(600 이하) · 그림 빼기', async () => {
|
||||
const { wrapper, requests } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 800), 201)] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
|
||||
const upload = requests.find((request) => request.url === '/mail-signature/images')
|
||||
expect(upload?.data).toBeInstanceOf(FormData)
|
||||
expect(requests.find((request) => request.url === '/mail-signature/images')?.data).toBeInstanceOf(FormData)
|
||||
const image = () => wrapper.get('#signature-body img[src]')
|
||||
expect(image().attributes()).toMatchObject({ src: `/api/mail-signature/images/${NEW_IMAGE_ID}`, width: '144' })
|
||||
expect(wrapper.get('[aria-labelledby="signature-image-size-label"]').attributes('role')).toBe('group')
|
||||
const pressed = () => wrapper.get('[data-size][aria-pressed="true"]').text()
|
||||
expect(pressed()).toBe('보통')
|
||||
|
||||
await wrapper.get('[data-size="small"]').trigger('click')
|
||||
expect(image().attributes('width')).toBe('96')
|
||||
expect(pressed()).toBe('작게')
|
||||
expect(wrapper.get('.signature-editor__sizes [role="group"]').attributes('aria-labelledby')).toBe(
|
||||
wrapper.get('.signature-editor__sizes-label').attributes('id'),
|
||||
)
|
||||
await wrapper.get('[data-size="original"]').trigger('click')
|
||||
expect(image().attributes('width')).toBe('600')
|
||||
expect(pressed()).toBe('원본')
|
||||
|
||||
await wrapper.get('.signature-editor__remove').trigger('click')
|
||||
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-size]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('작은 그림은 늘리지 않는다 — 원래 폭으로 넣는다', async () => {
|
||||
const { wrapper } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 80), 201)] }))
|
||||
await choose(wrapper, [png()])
|
||||
it('형식이 맞지 않으면 올리기 전에 막고 서명 칸 아래에 사전 문구 · 한 사람 20장 넘김(VAL_001)은 알림으로', async () => {
|
||||
const { wrapper, requests } = await mountPage(server({ uploads: [fail(400, 'VAL_001')] }))
|
||||
await choose(wrapper, [new File(['x'], '로고.webp', { type: 'image/webp' })])
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-body img[src]').attributes('width')).toBe('80')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PNG · JPG · GIF 가 아니면 FILE_001', new File(['x'], '로고.webp', { type: 'image/webp' }), 'FILE_001'],
|
||||
['확장자와 형식이 다르면 FILE_001', new File(['x'], '로고.gif', { type: 'image/png' }), 'FILE_001'],
|
||||
['300KB 를 넘으면 FILE_002', png('큰 로고.png', 300 * 1024 + 1), 'FILE_002'],
|
||||
] as const)('올리기 전에 막는다 — %s (서명 칸 아래에 사전 문구)', async (_label, file, code) => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
await choose(wrapper, [file])
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.url === '/mail-signature/images')).toBe(false)
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES[code])
|
||||
})
|
||||
|
||||
it('3장까지 — 서명 칸에 있는 서로 다른 그림을 센다 (같은 그림 두 번은 한 장, 뺀 그림은 세지 않는다)', async () => {
|
||||
const SECOND_ID = '00000000-0000-4000-8000-000000000002'
|
||||
const imageTag = (id: string) => `<img src="/api/mail-signature/images/${id}" width="96" alt="">`
|
||||
const withTwo: MailSignature = {
|
||||
...SIGNATURE,
|
||||
bodyHtml: `<p>${imageTag(IMAGE_ID)}${imageTag(IMAGE_ID)}${imageTag(SECOND_ID)}</p>`,
|
||||
images: [imageOf(IMAGE_ID, 200), imageOf(SECOND_ID, 200)],
|
||||
}
|
||||
const { wrapper, requests } = await mountPage(
|
||||
server({
|
||||
signature: withTwo,
|
||||
uploads: [
|
||||
ok(imageOf('00000000-0000-4000-8000-000000000003', 200), 201),
|
||||
ok(imageOf('00000000-0000-4000-8000-000000000004', 200), 201),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const uploads = () => requests.filter((request) => request.url === '/mail-signature/images')
|
||||
|
||||
// 두 장이 있다 — 한 장만 더 올리고 넷째 장은 올리지 않고 막는다
|
||||
await choose(wrapper, [png('a.png'), png('b.png')])
|
||||
await flushPromises()
|
||||
expect(uploads()).toHaveLength(1)
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 서명 칸에서 그림을 빼면 다시 넣을 수 있다 — 올려 두기만 한 그림은 세지 않는다 (그것은 서버의 10장 한도)
|
||||
const editor = editorOf(wrapper.element)
|
||||
let secondAt = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.attrs.src === `/api/mail-signature/images/${SECOND_ID}`) secondAt = pos
|
||||
})
|
||||
editor.commands.setNodeSelection(secondAt)
|
||||
await flushPromises()
|
||||
await wrapper.get('.signature-editor__remove').trigger('click')
|
||||
|
||||
await choose(wrapper, [png('c.png')])
|
||||
await flushPromises()
|
||||
expect(uploads()).toHaveLength(2)
|
||||
expect(wrapper.find('#signature-error').exists()).toBe(false)
|
||||
expect(wrapper.findAll('#signature-body img[src]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('서버가 그림을 거절하면(FILE_001 등) 알림 없이 서명 칸 아래에 사전 문구', async () => {
|
||||
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'FILE_001')] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('올려 둔 그림이 10장을 넘었다는 거절(VAL_001)은 인터셉터의 알림으로 — 서명 칸 아래 문구는 형식 · 크기 거절만', async () => {
|
||||
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'VAL_001')] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('#signature-error').exists()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 확인 (디자인 README "2단계 › 서명")', () => {
|
||||
describe('다른 화면으로 갈 때 (나가기 확인)', () => {
|
||||
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
|
||||
function blocksUnload(): boolean {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
@@ -334,42 +615,22 @@ describe('MailSignaturePage', () => {
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
function dialogOpen(wrapper: VueWrapper): boolean {
|
||||
return wrapper.get('#signature-leave-dialog').attributes('open') !== undefined
|
||||
}
|
||||
|
||||
/** 서명 끝에 한 줄을 더 쓴다 */
|
||||
function typeLine(wrapper: VueWrapper, text: string) {
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, `<p>${text}</p>`)
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
it('바꾸고 떠나면 묻는다 — alertdialog · 처음 초점은 계속 고치기, [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
|
||||
it('바꾸고 떠나면 묻는다 — [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
const stay = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.get('#signature-leave-dialog')
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
expect(dialog.attributes('role')).toBe('alertdialog')
|
||||
expect(dialog.attributes('aria-labelledby')).toBe('signature-leave-title')
|
||||
expect(dialog.attributes('aria-describedby')).toBe('signature-leave-message')
|
||||
expect(wrapper.get('#signature-leave-title').text()).toBe('저장하지 않은 서명이 있습니다')
|
||||
expect(dialogOpen(wrapper, 'signature-leave-dialog')).toBe(true)
|
||||
expect(wrapper.get('#signature-leave-message').text()).toBe('이 화면을 나가면 바꾼 서명이 사라집니다.')
|
||||
expect(wrapper.get('#signature-leave-stay').text()).toBe('계속 고치기')
|
||||
expect(wrapper.get('#signature-leave-confirm').text()).toBe('나가기')
|
||||
expect(document.activeElement?.id).toBe('signature-leave-stay')
|
||||
|
||||
await wrapper.get('#signature-leave-stay').trigger('click')
|
||||
await stay
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/signature')
|
||||
expect(wrapper.get('#signature-body').text()).toContain('ARA SYSTEM')
|
||||
|
||||
const leave = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
@@ -378,19 +639,13 @@ describe('MailSignaturePage', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('불러온(저장한 뒤에는 저장한) 서명과 같으면 묻지 않는다 — 바꿨다 되돌린 것 · 저장한 뒤, 창 닫기 확인도 그동안만', async () => {
|
||||
it('메일에 넣을 서명을 고른 것 · 저장한 뒤는 묻지 않는다', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
await wrapper.get('#signature-default-reply').setValue('en')
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 넣을 메일을 바꿨다 되돌리면 바꾼 것이 아니다
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('#signature-for-reply').setValue(true)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 쓰고 저장하면 저장한 서명이 새 기준이다
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
await wrapper.get('#signature-for-new').setValue(false)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -398,7 +653,6 @@ describe('MailSignaturePage', () => {
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
@@ -408,7 +662,8 @@ describe('MailSignaturePage', () => {
|
||||
server({
|
||||
save: (request) =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finishSave = () => resolve(ok({ ...(request.data as SaveSignaturePayload), images: [], updatedAt: SAVED_AT }))
|
||||
finishSave = () =>
|
||||
resolve(ok({ id: 'out', ...(request.data as MailSignaturePayload), images: [], updatedAt: SAVED_AT }))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -419,57 +674,30 @@ describe('MailSignaturePage', () => {
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(dialogOpen(wrapper, 'signature-leave-dialog')).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
|
||||
;(finishSave as (() => void) | null)?.()
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
expect(useMailSignatureStore().signature?.body).toBe('김아라 · 인프라팀\nARA SYSTEM')
|
||||
expect(useMailSignatureStore().signatureById('out')?.body).toBe('김아라 · 인프라팀\nARA SYSTEM\nARA SYSTEM')
|
||||
})
|
||||
|
||||
it('제한이 붙으면(13단계) 묻지 않는다 — 세션이 끝난 때처럼 창 닫기 확인도 뗀다', async () => {
|
||||
it('제한이 붙거나(13단계) 세션이 끝나면 묻지 않는다', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
await flushPromises()
|
||||
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await router.push('/login')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login')
|
||||
})
|
||||
|
||||
it('세션이 끝나면 묻지 않는다 — 서명 칸을 내리고 창 닫기 확인도 뗀다', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await router.push('/login')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#signature-leave-dialog').exists()).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
it('미리 보기 — [답장]은 인용 위에 서명, 넣지 않기로 했으면 빼고 안내가 바뀐다', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
const replyMode = wrapper.get('[data-mode="reply"]')
|
||||
await replyMode.trigger('click')
|
||||
|
||||
expect(replyMode.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 인용한 원문 위에 서명을 넣습니다.')
|
||||
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 서명을 넣지 않습니다.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 서명 (디자인 원본 Settings · README "2단계 › 서명") — /settings/mail/signature
|
||||
// 들어올 때마다 서명을 새로 받는다(다른 창에서 고쳤을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
|
||||
// 설정 › 메일 › 서명 (디자인 원본 Settings · Signature16 · README "2단계 › 서명" · "16단계 — 여러 서명") — /settings/mail/signature
|
||||
// 들어올 때마다 서명 목록과 메일에 넣을 서명(기본 둘)을 새로 받는다(다른 창에서 고쳤을 수 있다).
|
||||
// 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도". 받으면 서명 화면(SignatureSettings — 목록 · 고치는 칸 · 기본 둘)을 그린다.
|
||||
// 서명 칸(TipTap)은 지연 로딩한다 — 못 받으면 쓰기 화면과 같은 "다시 시도" (ComposeEditorLoadError · MAIL_EDITOR_RETRY).
|
||||
import { defineAsyncComponent, provide, ref } from 'vue'
|
||||
|
||||
@@ -10,19 +11,19 @@ import { MAIL_EDITOR_RETRY } from '@/components/mail/editor/mail-editor-handle'
|
||||
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
|
||||
/** 서명 칸 — TipTap 을 이 화면에서만 내려받는다 */
|
||||
const SignatureForm = defineAsyncComponent({
|
||||
loader: () => import('@/components/mail/editor/signature/SignatureForm.vue'),
|
||||
/** 서명 화면 — TipTap 을 이 화면에서만 내려받는다 */
|
||||
const SignatureSettings = defineAsyncComponent({
|
||||
loader: () => import('@/components/mail/editor/signature/SignatureSettings.vue'),
|
||||
errorComponent: ComposeEditorLoadError,
|
||||
})
|
||||
|
||||
const signatureStore = useMailSignatureStore()
|
||||
|
||||
/** 서명 칸을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
|
||||
const formKey = ref<number>(0)
|
||||
/** 서명 화면을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
|
||||
const settingsKey = ref<number>(0)
|
||||
|
||||
provide(MAIL_EDITOR_RETRY, () => {
|
||||
formKey.value += 1
|
||||
settingsKey.value += 1
|
||||
})
|
||||
|
||||
function reload() {
|
||||
@@ -57,10 +58,9 @@ reload()
|
||||
leave-label=""
|
||||
@retry="reload"
|
||||
/>
|
||||
<SignatureForm
|
||||
v-else-if="signatureStore.status === 'ready' && signatureStore.signature"
|
||||
:key="formKey"
|
||||
:signature="signatureStore.signature"
|
||||
<SignatureSettings
|
||||
v-else-if="signatureStore.status === 'ready'"
|
||||
:key="settingsKey"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { toMailHtml } from '@/components/mail/editor/mail-serialize'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
|
||||
import type { MailVacation, MailVacationExternal, MailVacationState } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -19,20 +21,48 @@ import {
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import { WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts'
|
||||
import MailVacationPage from './MailVacationPage.vue'
|
||||
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 16단계 Settings16 1~4 · 계약 2절 "부재중 자동 응답")
|
||||
// 16단계: 내용 칸이 서명 칸과 같은 서식 편집기(지연 로딩한 TipTap 청크)이고, 회사 밖에서 온 메일에 보낼 응답을 고른다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 양식(지연 로딩한 TipTap 청크)을 기다리는 시간 — 서명 설정 테스트와 같은 값 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
/** 회사 밖 응답의 처음 값 — 사내와 같은 응답 · 쓴 글 없음 */
|
||||
const SAME: MailVacationExternal = { mode: 'same', subject: null, textBody: null, htmlBody: null }
|
||||
/** 설정한 적 없는 계정 — 꺼짐 · 빈 값 */
|
||||
const NEVER: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
|
||||
const NEVER: MailVacation = {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
external: SAME,
|
||||
}
|
||||
/** 저장해 둔 서식 본문 — 편집기가 다시 내보내는 모양 그대로 (그대로 저장하면 같은 글이 간다) */
|
||||
const SAVED_HTML = toMailHtml('<p>안녕하세요, 김아라입니다.</p><p><strong>11월 4일</strong>에 돌아옵니다.</p>')
|
||||
const SAVED: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: startOfDayIso('2026-10-30'),
|
||||
toDate: endOfDayIso('2026-11-03'),
|
||||
subject: '부재중 안내 — 11월 4일 복귀',
|
||||
textBody: '안녕하세요, 김아라입니다.\n급한 일은 인프라팀으로 연락해 주세요.',
|
||||
subject: '부재중 안내 (11월 4일 복귀)',
|
||||
textBody: '안녕하세요, 김아라입니다.\n11월 4일에 돌아옵니다.',
|
||||
htmlBody: SAVED_HTML,
|
||||
external: SAME,
|
||||
}
|
||||
/** 회사 밖에 따로 쓴 응답 — 서식(굵게)이 있어 서식 본문도 함께 간다 */
|
||||
const OUTSIDE_PARAGRAPHS = '<p>안녕하세요, ARA SYSTEM 김아라입니다.</p><p>급한 일은 <strong>인프라팀</strong>으로 보내 주세요.</p>'
|
||||
const OUTSIDE_HTML = toMailHtml(OUTSIDE_PARAGRAPHS)
|
||||
const SEPARATE: MailVacationExternal = {
|
||||
mode: 'separate',
|
||||
subject: '부재중 안내',
|
||||
textBody: '안녕하세요, ARA SYSTEM 김아라입니다.\n급한 일은 인프라팀으로 보내 주세요.',
|
||||
htmlBody: OUTSIDE_HTML,
|
||||
}
|
||||
|
||||
interface Server {
|
||||
@@ -40,11 +70,23 @@ interface Server {
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
/** 편집 영역(id)의 TipTap 편집기 — TipTap 이 view.dom.editor 에 붙여 둔다 */
|
||||
function editorAt(id: string): Editor {
|
||||
const dom = document.getElementById(id) as (HTMLElement & { editor?: Editor }) | null
|
||||
if (!dom?.editor) throw new Error('편집기가 아직 없다')
|
||||
return dom.editor
|
||||
}
|
||||
|
||||
describe('MailVacationPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
beforeAll(async () => {
|
||||
await import('@/components/settings/VacationForm.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
@@ -60,7 +102,19 @@ describe('MailVacationPage', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
async function waitForForm(wrapper: VueWrapper) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('#vacation-body').exists() || !wrapper.find('#vacation-external-body').exists()) {
|
||||
throw new Error('부재중 양식을 불러오는 중')
|
||||
}
|
||||
},
|
||||
{ timeout: WAIT_FOR_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function mountPage(server: Server = {}, waitForEditors = true) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'GET' && request.url === '/mail-vacation') {
|
||||
const vacation = server.vacation ?? NEVER
|
||||
@@ -80,75 +134,155 @@ describe('MailVacationPage', () => {
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
if (waitForEditors) await waitForForm(wrapper)
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailVacation)
|
||||
const value = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
|
||||
const checked = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).checked
|
||||
const separateSection = (wrapper: VueWrapper) => wrapper.get('[data-vacation-external-separate]').element as HTMLElement
|
||||
|
||||
async function save(wrapper: VueWrapper) {
|
||||
await wrapper.get('#vacation-save').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸, 끈 동안은 아래 칸이 흐리다', async () => {
|
||||
/** 편집기의 내용을 바꾼다 — 사용자가 고친 것처럼 바뀜(update)을 알린다 */
|
||||
async function setBody(id: string, html: string) {
|
||||
editorAt(id).commands.setContent(html)
|
||||
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(checked(wrapper, '#vacation-enabled')).toBe(false)
|
||||
expect(checked(wrapper, '#vacation-period-until-off')).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()
|
||||
// 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거")
|
||||
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150')
|
||||
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600')
|
||||
// 내용 칸은 편집기 — 끈 동안은 읽기만
|
||||
const body = wrapper.get('#vacation-body')
|
||||
expect(body.attributes()).toMatchObject({ role: 'textbox', 'aria-disabled': 'true', contenteditable: 'false' })
|
||||
expect(body.attributes('aria-labelledby')).toBe('vacation-body-label')
|
||||
expect(body.attributes('aria-describedby')).toBe('vacation-body-hint')
|
||||
expect(wrapper.get('#vacation-body-label').text()).toBe('내용')
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('0 / 600')
|
||||
expect(wrapper.get('#vacation-body-hint').text()).toBe(
|
||||
'굵게, 기울임, 밑줄, 글자 색, 링크를 쓸 수 있습니다. 그림은 넣을 수 없고, 서명은 붙지 않습니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-vacation-external]').attributes('disabled')).toBeDefined()
|
||||
expect(checked(wrapper, '#vacation-external-same')).toBe(true)
|
||||
expect(separateSection(wrapper).style.display).toBe('none')
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
|
||||
})
|
||||
|
||||
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
|
||||
it('내용 칸 툴바 — 굵게 · 기울임 · 밑줄 | 글자 색 · 링크, 그림 단추 없음 (서명 칸과 같은 편집기)', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const toolbars = wrapper.findAll('[role="toolbar"]')
|
||||
|
||||
expect(toolbars.map((toolbar) => toolbar.attributes('aria-label'))).toEqual(['자동 응답 서식', '회사 밖 자동 응답 서식'])
|
||||
expect(toolbars[0]?.findAll('button').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'굵게',
|
||||
'기울임',
|
||||
'밑줄',
|
||||
'글자 색: 기본',
|
||||
'링크',
|
||||
])
|
||||
expect(toolbars[0]?.attributes('aria-controls')).toBe('vacation-body')
|
||||
expect(toolbars[1]?.attributes('aria-controls')).toBe('vacation-external-body')
|
||||
})
|
||||
|
||||
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 내용은 서식 그대로, 미리 보기는 보낸 사람 · 제목 · 서식 내용', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
|
||||
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(checked(wrapper, '#vacation-period-range')).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(VACATION_TEXT.rangeHint)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
expect(wrapper.get('#vacation-body').attributes('contenteditable')).toBe('true')
|
||||
expect(wrapper.get('#vacation-body').findAll('p').map((paragraph) => paragraph.text())).toEqual([
|
||||
'안녕하세요, 김아라입니다.',
|
||||
'11월 4일에 돌아옵니다.',
|
||||
])
|
||||
expect(wrapper.get('#vacation-body strong').text()).toBe('11월 4일')
|
||||
// 글자 수는 서식을 뺀 보이는 글
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe(`${SAVED.textBody.length} / 600`)
|
||||
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(preview.get('[data-vacation-preview-body]').element.innerHTML).toBe(
|
||||
'<p>안녕하세요, 김아라입니다.</p><p><strong>11월 4일</strong>에 돌아옵니다.</p>',
|
||||
)
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
|
||||
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('16단계 전에 저장한 글만 있는 응답(서식 본문 · 회사 밖 칸이 없는 서버)은 줄마다 문단으로 열고, 그대로 저장하면 일곱 칸을 보낸다', async () => {
|
||||
const old = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재중 안내',
|
||||
textBody: '안녕하세요.\n\n복귀 후 답장 드리겠습니다.',
|
||||
}
|
||||
const { wrapper } = await mountPage({ vacation: old })
|
||||
|
||||
expect(wrapper.get('#vacation-body').findAll('p').map((paragraph) => paragraph.text())).toEqual([
|
||||
'안녕하세요.',
|
||||
'',
|
||||
'복귀 후 답장 드리겠습니다.',
|
||||
])
|
||||
expect(checked(wrapper, '#vacation-external-same')).toBe(true)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([
|
||||
{
|
||||
...old,
|
||||
// 서식이 없으니 서식 본문은 보내지 않는다 — 글만 쓴 직원은 옛 판 그대로 (16단계 리뷰)
|
||||
htmlBody: null,
|
||||
external: SAME,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
|
||||
let reply: MailVacation | MockResult = fail(503, 'MAIL_001')
|
||||
const { wrapper } = await mountPage({
|
||||
get vacation() {
|
||||
return reply
|
||||
const { wrapper } = await mountPage(
|
||||
{
|
||||
get vacation() {
|
||||
return reply
|
||||
},
|
||||
},
|
||||
})
|
||||
false,
|
||||
)
|
||||
|
||||
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()
|
||||
await waitForForm(wrapper)
|
||||
expect(wrapper.find('#vacation-enabled').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
|
||||
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로, 내용은 보이는 글 · 서식 본문을 함께 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
expect(wrapper.get('#vacation-body').attributes('contenteditable')).toBe('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 setBody('vacation-body', '<p><strong>휴가</strong>입니다.</p>')
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('6 / 600')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([
|
||||
@@ -158,6 +292,8 @@ describe('MailVacationPage', () => {
|
||||
toDate: new Date(2026, 10, 4).toISOString(),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
htmlBody: toMailHtml('<p><strong>휴가</strong>입니다.</p>'),
|
||||
external: SAME,
|
||||
},
|
||||
])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe(
|
||||
@@ -165,12 +301,70 @@ describe('MailVacationPage', () => {
|
||||
)
|
||||
expect(useMailVacationStore().vacation?.isEnabled).toBe(true)
|
||||
|
||||
// 다시 바꾸면 내린다
|
||||
// 다시 바꾸면 내린다 — 제목 칸이든 내용 칸이든
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내 (수정)')
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#vacation-save-status').text()).not.toBe('')
|
||||
await setBody('vacation-body', '<p>휴가입니다!</p>')
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('켜 둘 때는 제목 · 내용 · 기간을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다', async () => {
|
||||
it('서식이 하나도 없으면 서식 본문을 보내지 않는다(htmlBody null) — 굵게 · 링크 · 글자 색이 하나라도 있으면 함께 보낸다 (16단계 리뷰)', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
const lastPut = () => puts().at(-1)
|
||||
|
||||
// 글만 — 문단 · 줄바꿈은 글자 본문에도 남아 서식이 아니다
|
||||
await setBody('vacation-body', '<p>휴가입니다.</p><p>11월 4일에<br>돌아옵니다.</p>')
|
||||
await save(wrapper)
|
||||
expect(lastPut()).toMatchObject({ textBody: '휴가입니다.\n11월 4일에\n돌아옵니다.', htmlBody: null })
|
||||
|
||||
// 굵게 하나
|
||||
await setBody('vacation-body', '<p><strong>휴가</strong>입니다.</p>')
|
||||
await save(wrapper)
|
||||
expect(lastPut()?.htmlBody).toBe(toMailHtml('<p><strong>휴가</strong>입니다.</p>'))
|
||||
|
||||
// 링크 · 글자 색도 서식이다
|
||||
await setBody('vacation-body', '<p><a href="mailto:infra@arasystem.it.kr">인프라팀</a>으로 연락해 주세요.</p>')
|
||||
await save(wrapper)
|
||||
expect(lastPut()?.htmlBody).toContain('href="mailto:infra@arasystem.it.kr"')
|
||||
await setBody('vacation-body', '<p><span style="color: #0E7C86">11월 4일</span>에 돌아옵니다.</p>')
|
||||
await save(wrapper)
|
||||
expect(lastPut()?.htmlBody).toContain('11월 4일')
|
||||
expect(lastPut()?.htmlBody).not.toBeNull()
|
||||
|
||||
// 서식을 모두 지우면 다시 글만 — 꺼 두고 저장할 때도 같다
|
||||
await setBody('vacation-body', '<p>11월 4일에 돌아옵니다.</p>')
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
await save(wrapper)
|
||||
expect(lastPut()).toMatchObject({ isEnabled: false, textBody: '11월 4일에 돌아옵니다.', htmlBody: null })
|
||||
})
|
||||
|
||||
it('서버가 MAIL_013(엔진 규칙 한도)으로 거절하면 알림 없이 [저장] 옆에 부재중 글 — 화면을 떠난 뒤면 그 글로 알림', async () => {
|
||||
let reply: MockResult | Promise<MockResult> = fail(400, 'MAIL_013')
|
||||
const { wrapper, router } = await mountPage({ vacation: SAVED, save: () => reply })
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요.')
|
||||
expect(wrapper.get('#vacation-save-status').classes()).toContain('is-error')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
reply = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
await save(wrapper)
|
||||
await router.push('/mail/inbox')
|
||||
wrapper.unmount()
|
||||
finish(fail(400, 'MAIL_013'))
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요.'],
|
||||
])
|
||||
})
|
||||
|
||||
it('켜 둘 때는 기간 · 제목 · 내용을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다, 고치기 시작한 칸만 내린다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
await wrapper.get('#vacation-period-range').setValue(true)
|
||||
@@ -183,46 +377,94 @@ describe('MailVacationPage', () => {
|
||||
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')
|
||||
// 편집기 칸도 aria-invalid · 빨간 테두리 — 설명은 오류 → 안내 차례
|
||||
expect(wrapper.get('#vacation-body').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#vacation-body').attributes('aria-describedby')).toBe('vacation-body-error vacation-body-hint')
|
||||
expect(wrapper.get('.signature-editor').classes()).toContain('signature-editor--invalid')
|
||||
expect(document.activeElement?.id).toBe('vacation-from')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 고친 칸의 문구만 내린다
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내')
|
||||
expect(wrapper.find('#vacation-subject-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
|
||||
await setBody('vacation-body', '<p>휴가입니다.</p>')
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#vacation-body').attributes('aria-invalid')).toBeUndefined()
|
||||
expect(wrapper.find('#vacation-period-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('켠 채 저장할 때 제목 150자 · 내용 600자를 넘으면(4단계 한도로 저장해 둔 긴 글 등) 칸 아래에 사전 문구 — 줄이면 저장한다', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, subject: 'S'.repeat(151), textBody: 'B'.repeat(601) } })
|
||||
it('내용 칸이 비었으면 내용 칸에 초점 — 사전 문구(VAL_001)', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: '', htmlBody: null } })
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([])
|
||||
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('0 / 600')
|
||||
expect(document.activeElement?.id).toBe('vacation-body')
|
||||
})
|
||||
|
||||
it('켠 채 저장할 때 제목 150자 · 내용 600자를 넘으면(4단계 한도로 저장해 둔 긴 글 등) 제목은 사전 문구, 내용은 "내용은 600자까지 쓸 수 있습니다." + 빨간 글자 수 — 줄이면 저장한다', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, subject: 'S'.repeat(151), textBody: 'B'.repeat(601), htmlBody: null } })
|
||||
const count = wrapper.get('[data-vacation-count="vacation"]')
|
||||
expect(count.text()).toBe('601 / 600')
|
||||
expect(count.classes()).toContain('is-over')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([])
|
||||
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-body-error').text()).toBe('내용은 600자까지 쓸 수 있습니다.')
|
||||
expect(document.activeElement?.id).toBe('vacation-subject')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('#vacation-subject').setValue('S'.repeat(150))
|
||||
await wrapper.get('#vacation-body').setValue('B'.repeat(600))
|
||||
await setBody('vacation-body', `<p>${'B'.repeat(600)}</p>`)
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('600 / 600')
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').classes()).not.toContain('is-over')
|
||||
await save(wrapper)
|
||||
expect(puts()).toEqual([{ ...SAVED, subject: 'S'.repeat(150), textBody: 'B'.repeat(600) }])
|
||||
expect(puts()).toEqual([
|
||||
{
|
||||
...SAVED,
|
||||
subject: 'S'.repeat(150),
|
||||
textBody: 'B'.repeat(600),
|
||||
htmlBody: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('글은 600자 안인데 서식이 많아 서식 본문이 10,000자를 넘으면 사전 문구(VAL_001) — 서명과 같은 한도', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
const colored = Array.from({ length: 200 }, () => '<span style="color: #B42318">가</span><span style="color: #067647">나</span>').join('')
|
||||
await setBody('vacation-body', `<p>${colored}</p>`)
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('400 / 600')
|
||||
expect(puts()).toEqual([])
|
||||
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
})
|
||||
|
||||
it('켜진 옛 긴 글(4단계에 저장한 내용 1,000자)도 끄는 것은 늘 저장된다 — 꺼 둘 때는 길이를 보지 않는다 (서버는 꺼 둔 채로 4단계 한도까지 받는다)', async () => {
|
||||
const longBody = 'B'.repeat(1_000)
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: longBody } })
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: longBody, htmlBody: null } })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
// 꺼 두면 넘은 글자 수도 빨갛지 않다 — 켤 때만 센다
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').classes()).not.toContain('is-over')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }])
|
||||
expect(puts()).toEqual([
|
||||
{ ...SAVED, isEnabled: false, textBody: longBody, htmlBody: null },
|
||||
])
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
|
||||
expect(useMailVacationStore().vacation?.isEnabled).toBe(false)
|
||||
expect(value(wrapper, '#vacation-body')).toBe(longBody)
|
||||
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('1,000 / 600')
|
||||
})
|
||||
|
||||
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
|
||||
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
expect(wrapper.get('#vacation-body').attributes('contenteditable')).toBe('false')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
|
||||
@@ -276,6 +518,220 @@ describe('MailVacationPage', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('회사 밖에서 온 메일 (16단계 — 디자인 원본 Settings16 1 · 2 · 4)', () => {
|
||||
const optionTexts = (wrapper: VueWrapper) =>
|
||||
wrapper.get('[data-vacation-external]').findAll('.vacation-external__option').map((option) => option.text())
|
||||
|
||||
it('내용 칸과 [저장] 사이 — 내 주소의 도메인 설명 · 고르기 셋(사내와 같은 응답 · 보내지 않기 · 따로 쓰기), 처음 값은 사내와 같은 응답', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
const fieldset = wrapper.get('[data-vacation-external]')
|
||||
|
||||
expect(fieldset.get('legend').text()).toBe('회사 밖에서 온 메일')
|
||||
expect(fieldset.get('#vacation-external-hint').text()).toBe('보낸 사람 주소가 회사 메일 주소(@arasystem.it.kr)가 아닌 메일입니다.')
|
||||
expect(optionTexts(wrapper)).toEqual([
|
||||
'사내와 같은 응답회사 안팎 모두 위의 제목과 내용으로 답합니다.',
|
||||
'보내지 않기회사 밖에서 온 메일에는 자동으로 답하지 않습니다.',
|
||||
'따로 쓰기회사 밖에서 온 메일에 보낼 제목과 내용을 따로 씁니다.',
|
||||
])
|
||||
expect(wrapper.get('#vacation-external-none').attributes('aria-describedby')).toBe('vacation-external-none-hint')
|
||||
expect(checked(wrapper, '#vacation-external-same')).toBe(true)
|
||||
// 자리 — 내용 칸 뒤 · [저장] 앞
|
||||
const position = (selector: string) => wrapper.get(selector).element
|
||||
expect(position('#vacation-body').compareDocumentPosition(fieldset.element)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(fieldset.element.compareDocumentPosition(position('#vacation-save'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(separateSection(wrapper).style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('따로 쓰기 — 바로 아래에 회사 밖에 보낼 제목 · 내용 칸이 열리고, 저장하면 external 에 함께 보낸다', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-external-separate').setValue(true)
|
||||
|
||||
expect(separateSection(wrapper).style.display).toBe('')
|
||||
expect(wrapper.get('label[for="vacation-external-subject"]').text()).toBe('회사 밖에 보낼 제목')
|
||||
expect(wrapper.get('#vacation-external-body-label').text()).toBe('회사 밖에 보낼 내용')
|
||||
// 회사 밖 내용 칸에는 아래 안내가 없다
|
||||
expect(wrapper.find('#vacation-external-body-hint').exists()).toBe(false)
|
||||
expect(wrapper.get('#vacation-external-body').attributes('aria-describedby')).toBeUndefined()
|
||||
await wrapper.get('#vacation-external-subject').setValue(SEPARATE.subject ?? '')
|
||||
await setBody('vacation-external-body', OUTSIDE_PARAGRAPHS)
|
||||
expect(wrapper.get('[data-vacation-count="vacation-external"]').text()).toBe(`${SEPARATE.textBody?.length} / 600`)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, external: SEPARATE }])
|
||||
})
|
||||
|
||||
it('회사 밖 칸도 서식이 없으면 서식 본문을 보내지 않는다 — 기울임 하나면 함께 보낸다 (16단계 리뷰)', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-external-separate').setValue(true)
|
||||
await wrapper.get('#vacation-external-subject').setValue('부재중 안내')
|
||||
await setBody('vacation-external-body', '<p>11월 20일까지 자리를 비웁니다.</p>')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts().at(-1)?.external).toEqual({
|
||||
mode: 'separate',
|
||||
subject: '부재중 안내',
|
||||
textBody: '11월 20일까지 자리를 비웁니다.',
|
||||
htmlBody: null,
|
||||
})
|
||||
// 사내 칸은 그대로 서식(굵게)이 있어 서식 본문이 간다
|
||||
expect(puts().at(-1)?.htmlBody).toBe(SAVED_HTML)
|
||||
|
||||
await setBody('vacation-external-body', '<p><em>11월 20일</em>까지 자리를 비웁니다.</p>')
|
||||
await save(wrapper)
|
||||
expect(puts().at(-1)?.external?.htmlBody).toBe(toMailHtml('<p><em>11월 20일</em>까지 자리를 비웁니다.</p>'))
|
||||
})
|
||||
|
||||
it('저장해 둔 따로 쓰기로 연다 — 칸이 열려 있고 회사 밖 제목 · 서식 내용이 그대로', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
|
||||
|
||||
expect(checked(wrapper, '#vacation-external-separate')).toBe(true)
|
||||
expect(separateSection(wrapper).style.display).toBe('')
|
||||
expect(value(wrapper, '#vacation-external-subject')).toBe(SEPARATE.subject)
|
||||
expect(wrapper.get('#vacation-external-body').findAll('p').map((paragraph) => paragraph.text())).toEqual([
|
||||
'안녕하세요, ARA SYSTEM 김아라입니다.',
|
||||
'급한 일은 인프라팀으로 보내 주세요.',
|
||||
])
|
||||
// 그대로 저장하면 같은 글이 간다
|
||||
await save(wrapper)
|
||||
expect(puts()).toEqual([{ ...SAVED, external: SEPARATE }])
|
||||
})
|
||||
|
||||
it('다른 고르기로 바꾸면 두 칸을 닫지만 써 둔 글은 남겨 두고 함께 저장한다 — 다시 따로 쓰기를 고르면 그대로', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
|
||||
await wrapper.get('#vacation-external-none').setValue(true)
|
||||
|
||||
expect(separateSection(wrapper).style.display).toBe('none')
|
||||
await save(wrapper)
|
||||
expect(puts()).toEqual([{ ...SAVED, external: { ...SEPARATE, mode: 'none' } }])
|
||||
|
||||
await wrapper.get('#vacation-external-separate').setValue(true)
|
||||
expect(separateSection(wrapper).style.display).toBe('')
|
||||
expect(value(wrapper, '#vacation-external-subject')).toBe(SEPARATE.subject)
|
||||
expect(wrapper.get('#vacation-external-body').text()).toContain('급한 일은 인프라팀으로 보내 주세요.')
|
||||
})
|
||||
|
||||
it('켤 때 회사 밖 두 칸은 따로 쓰기일 때만 본다 — 비었으면 사전 문구 · 600자를 넘으면 같은 전용 글, 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-external-separate').setValue(true)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([])
|
||||
expect(wrapper.get('#vacation-external-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-external-subject').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#vacation-external-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-external-body').attributes('aria-describedby')).toBe('vacation-external-body-error')
|
||||
expect(document.activeElement?.id).toBe('vacation-external-subject')
|
||||
|
||||
await wrapper.get('#vacation-external-subject').setValue('부재중 안내')
|
||||
await setBody('vacation-external-body', `<p>${'가'.repeat(601)}</p>`)
|
||||
const count = wrapper.get('[data-vacation-count="vacation-external"]')
|
||||
expect(count.text()).toBe('601 / 600')
|
||||
expect(count.classes()).toContain('is-over')
|
||||
await save(wrapper)
|
||||
expect(puts()).toEqual([])
|
||||
expect(wrapper.get('#vacation-external-body-error').text()).toBe('내용은 600자까지 쓸 수 있습니다.')
|
||||
expect(document.activeElement?.id).toBe('vacation-external-body')
|
||||
|
||||
// 보내지 않기로 바꾸면 회사 밖 두 칸의 문구를 내리고, 길어도 저장한다 (글은 남긴다)
|
||||
await wrapper.get('#vacation-external-none').setValue(true)
|
||||
expect(wrapper.find('#vacation-external-body-error').exists()).toBe(false)
|
||||
await save(wrapper)
|
||||
expect(puts()).toHaveLength(1)
|
||||
expect(puts()[0]?.external).toMatchObject({ mode: 'none', subject: '부재중 안내', textBody: '가'.repeat(601) })
|
||||
})
|
||||
|
||||
it('자동 응답을 끄면 이 칸도 다른 칸처럼 흐려진다 — 고르기 · 회사 밖 칸 모두', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
|
||||
expect(wrapper.get('[data-vacation-external]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-external-subject').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-external-body').attributes('contenteditable')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('미리 보기 [사내 | 회사 밖] (16단계 — 디자인 원본 Settings16 3)', () => {
|
||||
const preview = (wrapper: VueWrapper) => wrapper.get('[data-vacation-preview]')
|
||||
const sides = (wrapper: VueWrapper) =>
|
||||
preview(wrapper)
|
||||
.findAll('[data-preview-side]')
|
||||
.map((button) => [button.text(), button.attributes('aria-pressed')])
|
||||
|
||||
async function pick(wrapper: VueWrapper, side: 'inside' | 'outside') {
|
||||
await preview(wrapper).get(`[data-preview-side="${side}"]`).trigger('click')
|
||||
}
|
||||
|
||||
it('전환 — "미리 볼 답장" · 처음은 사내 쪽, 사내와 같은 응답이면 두 쪽이 같은 답장이고 아래 글만 다르다', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
|
||||
expect(preview(wrapper).get('[role="group"]').attributes('aria-label')).toBe('미리 볼 답장')
|
||||
expect(sides(wrapper)).toEqual([
|
||||
['사내', 'true'],
|
||||
['회사 밖', 'false'],
|
||||
])
|
||||
await pick(wrapper, 'outside')
|
||||
expect(sides(wrapper)).toEqual([
|
||||
['사내', 'false'],
|
||||
['회사 밖', 'true'],
|
||||
])
|
||||
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
|
||||
expect(preview(wrapper).get('[data-vacation-preview-body]').text()).toContain('11월 4일에 돌아옵니다.')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
|
||||
'회사 밖에서 온 메일에도 같은 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('따로 쓰기 — 사내 쪽은 위의 글 · "사내에서 온 메일에 …", 회사 밖 쪽은 회사 밖에 보낼 제목 · 내용 · "회사 밖에서 온 메일에 …"', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
|
||||
|
||||
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
|
||||
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
|
||||
'사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
)
|
||||
await pick(wrapper, 'outside')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe(SEPARATE.subject)
|
||||
expect(preview(wrapper).get('[data-vacation-preview-body]').element.innerHTML).toBe(
|
||||
OUTSIDE_PARAGRAPHS,
|
||||
)
|
||||
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
|
||||
'회사 밖에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
)
|
||||
|
||||
// 칸을 고친 값을 바로 그린다
|
||||
await wrapper.get('#vacation-external-subject').setValue('부재중 안내 (수정)')
|
||||
await setBody('vacation-external-body', '<p><em>잠시</em> 자리를 비웁니다.</p>')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe('부재중 안내 (수정)')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-body]').element.innerHTML).toBe('<p><em>잠시</em> 자리를 비웁니다.</p>')
|
||||
})
|
||||
|
||||
it('보내지 않기 — 회사 밖 쪽은 답장 대신 회색 상자 한 줄 · 아래 글 없음, 사내 쪽은 "사내에서 온 메일에 …"', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: { ...SAME, mode: 'none' } } })
|
||||
|
||||
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
|
||||
'사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
|
||||
)
|
||||
await pick(wrapper, 'outside')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-none]').text()).toBe('회사 밖에서 온 메일에는 자동으로 답하지 않습니다.')
|
||||
expect(preview(wrapper).find('[data-vacation-preview-subject]').exists()).toBe(false)
|
||||
expect(preview(wrapper).find('[data-vacation-preview-note]').exists()).toBe(false)
|
||||
|
||||
// 사내 쪽으로 돌아오면 내용을 다시 그린다
|
||||
await pick(wrapper, 'inside')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-body]').text()).toContain('안녕하세요, 김아라입니다.')
|
||||
})
|
||||
|
||||
it('사내 쪽 내용도 칸을 고친 값을 바로 그린다 · 꺼 두면 두 쪽 모두 "꺼져 있어 자동으로 답하지 않습니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await setBody('vacation-body', '<p><u>내일</u> 돌아옵니다.</p>')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-body]').element.innerHTML).toBe('<p><u>내일</u> 돌아옵니다.</p>')
|
||||
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
|
||||
await pick(wrapper, 'outside')
|
||||
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => {
|
||||
const INACTIVE_TEXT =
|
||||
'다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.'
|
||||
@@ -350,11 +806,39 @@ describe('MailVacationPage', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('되돌렸거나 저장했으면 묻지 않는다', async () => {
|
||||
it('내용 칸(편집기)만 고쳐도 묻는다 · 회사 밖 고르기 · 회사 밖 내용도', async () => {
|
||||
const { wrapper, router } = await mountPage({ vacation: SAVED })
|
||||
await setBody('vacation-body', '<p>바꾼 내용</p>')
|
||||
|
||||
const first = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
await wrapper.get('#vacation-leave-stay').trigger('click')
|
||||
await first
|
||||
|
||||
// 내용을 처음대로 되돌리면 묻지 않는 대신, 회사 밖 고르기를 바꾸면 묻는다
|
||||
await setBody('vacation-body', '<p>안녕하세요, 김아라입니다.</p><p><strong>11월 4일</strong>에 돌아옵니다.</p>')
|
||||
await wrapper.get('#vacation-external-none').setValue(true)
|
||||
const second = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
await wrapper.get('#vacation-leave-stay').trigger('click')
|
||||
await second
|
||||
|
||||
await wrapper.get('#vacation-external-same').setValue(true)
|
||||
await setBody('vacation-external-body', '<p>회사 밖 글</p>')
|
||||
const third = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
await wrapper.get('#vacation-leave-stay').trigger('click')
|
||||
await third
|
||||
})
|
||||
|
||||
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 setBody('vacation-body', '<p>바꾼 내용</p>')
|
||||
await save(wrapper)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 16단계 Settings16 · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
|
||||
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
|
||||
// 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있으면(isActive: false — 7단계, 디자인 원본 MailFilterApplyStates) 머리 아래 노란 줄
|
||||
// "켜 둔 자동 응답이 지금은 동작하지 않습니다 …"(필터 화면의 줄과 같은 모양, 단추 없음 — [저장]은 늘 누를 수 있다).
|
||||
// 저장하면 응답의 isActive 로 맞춘다(저장하면 다시 켜진다). 꺼 두었으면 보이지 않는다 — 칸을 고친 값이 아니라 저장한 값으로 본다
|
||||
// 16단계: 내용 칸이 서식 편집기(TipTap)라 양식(VacationForm)째 지연 로딩한다 — 못 받으면 쓰기 화면 · 서명 설정과 같은 "다시 시도"
|
||||
// (ComposeEditorLoadError · MAIL_EDITOR_RETRY)
|
||||
import { defineAsyncComponent, provide, ref } from 'vue'
|
||||
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import { MAIL_EDITOR_RETRY } from '@/components/mail/editor/mail-editor-handle'
|
||||
import MailFilterNotice from '@/components/settings/MailFilterNotice.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'
|
||||
|
||||
/** 부재중 양식 — TipTap 을 이 화면에서만 내려받는다 */
|
||||
const VacationForm = defineAsyncComponent({
|
||||
loader: () => import('@/components/settings/VacationForm.vue'),
|
||||
errorComponent: ComposeEditorLoadError,
|
||||
})
|
||||
|
||||
const vacationStore = useMailVacationStore()
|
||||
|
||||
/** 양식을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
|
||||
const formKey = ref<number>(0)
|
||||
|
||||
provide(MAIL_EDITOR_RETRY, () => {
|
||||
formKey.value += 1
|
||||
})
|
||||
|
||||
function reload() {
|
||||
void vacationStore.load()
|
||||
}
|
||||
@@ -40,7 +58,10 @@ reload()
|
||||
:message="VACATION_TEXT.inactive"
|
||||
data-vacation-inactive
|
||||
/>
|
||||
<VacationForm :vacation="vacationStore.vacation" />
|
||||
<VacationForm
|
||||
:key="formKey"
|
||||
:vacation="vacationStore.vacation"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
|
||||
Reference in New Issue
Block a user