diff --git a/frontend/src/components/contacts/ContactAvatar.vue b/frontend/src/components/contacts/ContactAvatar.vue new file mode 100644 index 0000000..2f6201a --- /dev/null +++ b/frontend/src/components/contacts/ContactAvatar.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/components/contacts/ContactDetail.vue b/frontend/src/components/contacts/ContactDetail.vue new file mode 100644 index 0000000..8e35c1e --- /dev/null +++ b/frontend/src/components/contacts/ContactDetail.vue @@ -0,0 +1,576 @@ + + + + + diff --git a/frontend/src/components/contacts/ContactDialog.spec.ts b/frontend/src/components/contacts/ContactDialog.spec.ts new file mode 100644 index 0000000..b4bffaf --- /dev/null +++ b/frontend/src/components/contacts/ContactDialog.spec.ts @@ -0,0 +1,550 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { Contact, ContactGroup, ContactInput } from '@/shared/types/contacts' +import type { PaginatedResult } from '@/shared/types/pagination' +import type { ContactPrefill } from '@/shared/utils/contacts' +import { useAuthStore } from '@/stores/auth.store' +import { useContactsStore } from '@/stores/contacts.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter, routeViewProvide } from '@/test-utils/router' +import ContactDialog from './ContactDialog.vue' + +// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기") + +const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } +const GROUPS: ContactGroup[] = [ + { id: 'g1', name: '거래처', memberCount: 4 }, + { id: 'g2', name: '프로젝트 알파', memberCount: 4 }, +] +const SEOYUN: Contact = { + id: 'c1', + name: '강서윤', + emails: [{ address: 'seoyun.kang@hanbit.example', label: 'work' }], + phones: [], + company: '(주)한빛상사', + department: '구매팀', + title: '과장', + note: null, + groupIds: ['g1'], +} +const JIHOON: Contact = { + id: 'c2', + name: '박지훈', + emails: [ + { address: 'jihoon.park@hanbit.example', label: 'work' }, + { address: 'jihoon.p@mail.example', label: 'personal' }, + ], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '(주)한빛상사', + department: '영업팀', + title: '팀장', + note: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.', + groupIds: ['g1', 'g2'], +} + +function pageOf(items: T[]): PaginatedResult { + return { items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } } +} + +interface Server { + /** GET /contacts?q= — 같은 주소 찾기 */ + search?: (q: string) => Contact[] + save?: (request: MockRequest) => MockResult | Promise +} + +describe('ContactDialog', () => { + let pinia: Pinia + let requests: MockRequest[] + const mounted: VueWrapper[] = [] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().startSession(USER) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + function installServer(server: Server = {}) { + requests = installMockApi((request) => { + if (request.url === '/contact-groups') return ok(pageOf(GROUPS)) + if (request.url === '/contacts' && request.method === 'GET') { + return ok(pageOf(server.search?.(String(request.params.q ?? '')) ?? [])) + } + if (request.url === '/contact-restorations') return ok({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } }, 201) + if (request.method === 'POST' || request.method === 'PATCH') { + return server.save ? server.save(request) : ok({ ...JIHOON, ...(request.data as ContactInput), id: 'c9' }) + } + if (request.method === 'DELETE') return ok({ contact: JIHOON, restoreToken: 't-c2' }) + return ok(null) + }) + } + + async function mountDialog( + props: { contact?: Contact | null; prefill?: ContactPrefill | null } = {}, + server: Server = {}, + path = '/contacts', + ) { + installServer(server) + const router = createTestRouter() + await router.push(path) + await router.isReady() + const closes: number[] = [] + const wrapper: VueWrapper = mount(ContactDialog, { + attachTo: document.body, + props: { + open: true, + contact: props.contact ?? null, + prefill: props.prefill ?? null, + onClose: () => { + closes.push(closes.length + 1) + return wrapper.setProps({ open: false }) + }, + }, + global: { plugins: [pinia, router], provide: routeViewProvide(router) }, + }) + mounted.push(wrapper) + await flushPromises() + return { wrapper, router, closes } + } + + // 이메일 칸은 type="text" + inputmode="email" (type="email" 이면 Chrome 이 한글 도메인을 퓨니코드로 바꿀 수 있다) + const emailInputs = (wrapper: VueWrapper) => wrapper.findAll('input[inputmode="email"]') + const phoneInputs = (wrapper: VueWrapper) => wrapper.findAll('input[type="tel"]') + const saves = () => + requests.filter( + (request) => (request.method === 'POST' && request.url === '/contacts') || request.method === 'PATCH', + ) + const toasts = () => useToastStore().toasts.map((toast) => toast.message) + const isOpen = (wrapper: VueWrapper, selector: string) => wrapper.find(`${selector}[open]`).exists() + + it('새 연락처 — 이메일 · 전화번호 빈 줄 하나씩과 안내, 그룹은 받아 둔다 · 처음 초점은 이름', async () => { + const { wrapper } = await mountDialog() + + expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가') + expect(document.activeElement?.id).toBe('contact-name') + expect(emailInputs(wrapper).map((input) => input.attributes('aria-label'))).toEqual(['첫째 이메일']) + expect(wrapper.get('select[aria-label="첫째 이메일의 종류"]').findAll('option').map((option) => option.text())).toEqual([ + '회사', + '개인', + '기타', + ]) + expect(wrapper.get('select[aria-label="첫째 전화번호의 종류"]').findAll('option').map((option) => option.text())).toEqual([ + '휴대전화', + '회사', + '집', + '기타', + ]) + expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다') + expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.') + expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000') + expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([ + 'g1', + 'g2', + ]) + expect(wrapper.find('#contact-remove').exists()).toBe(false) + expect(requests.map((request) => request.url)).toEqual(['/contact-groups']) + }) + + it('잘못은 [저장]을 누를 때만 칸 아래 빨간 글 — 첫 잘못된 칸에 초점, 알림 없음 · 고치면 그 칸의 잘못을 내린다', async () => { + const { wrapper } = await mountDialog() + + await wrapper.get('#contact-save').trigger('click') + expect(wrapper.get('#contact-required').classes()).toContain('form-message--error') + expect(document.activeElement?.id).toBe('contact-name') + + await wrapper.get('#contact-name').setValue('박지훈') + await emailInputs(wrapper)[0]?.setValue('jihoon.park@hanbit') + await phoneInputs(wrapper)[0]?.setValue('내선 12') + await wrapper.get('#contact-save').trigger('click') + + const email = emailInputs(wrapper)[0] + expect(email?.attributes('aria-invalid')).toBe('true') + expect(wrapper.get(`#${email?.attributes('aria-describedby')}`).text()).toBe('이메일 형식이 올바르지 않습니다.') + expect(document.activeElement).toBe(email?.element) + expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true') + expect(saves()).toEqual([]) + expect(toasts()).toEqual([]) + + await email?.setValue('jihoon.park@hanbit.example') + expect(email?.attributes('aria-invalid')).toBe('false') + }) + + it('저장 — 앞뒤 공백 · 빈 줄을 빼서 보내고 "연락처를 저장했습니다." · 창을 닫는다', async () => { + const { wrapper, closes } = await mountDialog() + + await wrapper.get('#contact-name').setValue(' 한도경 ') + await emailInputs(wrapper)[0]?.setValue(' dokyung.han@mail.example ') + await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal') + await wrapper.get('#contact-company').setValue(' ') + await wrapper.get('#contact-note').setValue('대학 동기\n') + expect(wrapper.get('[data-contact-note-count]').text()).toBe('6 / 1,000') + await wrapper.get('[data-contact-group="g2"]').setValue(true) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + + expect(saves().map((request) => [request.method, request.url])).toEqual([['POST', '/contacts']]) + expect(saves()[0]?.data).toEqual({ + name: '한도경', + emails: [{ address: 'dokyung.han@mail.example', label: 'personal' }], + phones: [], + company: null, + department: null, + title: null, + note: '대학 동기', + groupIds: ['g2'], + }) + expect(toasts()).toEqual(['연락처를 저장했습니다.']) + expect(closes).toHaveLength(1) + }) + + it('이메일 칸은 type="text" + inputmode="email" · [더하기]는 옆 안내("5개까지 …")를 까닭으로 잇는다(aria-describedby)', async () => { + const { wrapper } = await mountDialog() + + const email = emailInputs(wrapper)[0] + expect(email?.attributes('type')).toBe('text') + const addEmail = wrapper.findAll('.entry-rows__add-button')[0] + expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe( + '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다', + ) + }) + + it('고치기 — PATCH 로 바꾼 칸만 보낸다(이메일 · 전화번호 · 그룹은 바뀌면 통째로) · 바꾼 것이 없으면 보내지 않고 닫는다', async () => { + const { wrapper, closes } = await mountDialog({ contact: JIHOON }) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()).toEqual([]) + expect(closes).toHaveLength(1) + + await wrapper.setProps({ open: true }) + await flushPromises() + await wrapper.get('#contact-title').setValue('부장') + await phoneInputs(wrapper)[0]?.setValue('010-3192-0000') + await wrapper.get('[data-contact-group="g2"]').setValue(false) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']]) + expect(saves()[0]?.data).toEqual({ + phones: [{ number: '010-3192-0000', label: 'mobile' }], + title: '부장', + groupIds: ['g1'], + }) + expect(toasts()).toEqual(['연락처를 저장했습니다.']) + }) + + it('고치기는 바꾼 칸만 검사한다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모 · 100자 넘는 회사)은 그대로 저장된다', async () => { + const imported: Contact = { + ...JIHOON, + emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + phones: [{ number: '내선 12', label: 'work' }], + company: '한'.repeat(150), + note: '가'.repeat(1200), + } + const { wrapper } = await mountDialog({ contact: imported }) + + await wrapper.get('#contact-title').setValue('부장') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()[0]?.data).toEqual({ title: '부장' }) + }) + + it('고친 칸은 그 칸 전부가 규칙에 맞아야 한다 — 전화번호를 고치면 남의 "내선" 번호도, 회사를 고치면 100자, 이메일을 고치면 20개', async () => { + const imported: Contact = { + ...JIHOON, + emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + phones: [ + { number: '내선 12', label: 'work' }, + { number: '010-1111-2222', label: 'mobile' }, + ], + company: '한'.repeat(150), + } + const { wrapper } = await mountDialog({ contact: imported }) + + await phoneInputs(wrapper)[1]?.setValue('010-1111-3333') + await wrapper.get('#contact-company').setValue('한'.repeat(149)) + await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + + expect(saves()).toEqual([]) + expect(wrapper.get('[data-entry-count-error]').text()).toBe('이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요.') + expect(document.activeElement).toBe(emailInputs(wrapper)[0]?.element) + expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-company').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-company-error').text()).toBe('100자까지 쓸 수 있습니다.') + }) + + it('고치기 — 다른 프로그램이 넣은 6개 이상의 주소는 그대로 보이고 저장된다 · [이메일 더하기]는 5개에서 멈춘다', async () => { + const many: Contact = { + ...JIHOON, + emails: Array.from({ length: 6 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + } + const { wrapper } = await mountDialog({ contact: many }) + + expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 고치기') + expect(emailInputs(wrapper)).toHaveLength(6) + const addEmail = wrapper.findAll('.entry-rows__add-button')[0] + expect(addEmail?.attributes('aria-disabled')).toBe('true') + await addEmail?.trigger('click') + expect(emailInputs(wrapper)).toHaveLength(6) + // 전화번호는 하나 — 더할 수 있다 + const addPhone = wrapper.findAll('.entry-rows__add-button')[1] + expect(addPhone?.attributes('aria-disabled')).toBeUndefined() + await addPhone?.trigger('click') + await flushPromises() + expect(phoneInputs(wrapper)).toHaveLength(2) + expect(document.activeElement).toBe(phoneInputs(wrapper)[1]?.element) + + await phoneInputs(wrapper)[1]?.setValue('02-555-0147') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + // 이메일은 손대지 않았다 — 전화번호만 보낸다 + expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']]) + expect(saves()[0]?.data).toEqual({ + phones: [ + { number: '010-3192-5540', label: 'mobile' }, + { number: '02-555-0147', label: 'mobile' }, + ], + }) + }) + + it('같은 주소 — 이메일 칸을 떠나면 알림 없이 찾아 갈색 글 + [그 연락처 보기] (자기 자신은 뺀다) · 막지 않는다', async () => { + const { wrapper } = await mountDialog({}, { search: () => [SEOYUN] }) + await useContactsStore().loadGroups() + + const email = emailInputs(wrapper)[0] + await email?.setValue('Seoyun.Kang@hanbit.example') + await email?.trigger('blur') + await flushPromises() + + const search = requests.find((request) => request.url === '/contacts') + expect(search?.params).toEqual({ q: 'Seoyun.Kang@hanbit.example', limit: 100 }) + expect(search?.config.silent).toBe(true) + const warning = wrapper.get('[data-contact-duplicate]') + expect(warning.text()).toBe( + '이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기', + ) + expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id')) + expect(email?.attributes('aria-invalid')).toBe('false') + + // 고치기 창에서 자기 자신의 주소는 경고하지 않는다 + const own = await mountDialog({ contact: SEOYUN }, { search: () => [SEOYUN] }) + const ownEmail = emailInputs(own.wrapper)[0] + await ownEmail?.trigger('blur') + await flushPromises() + expect(own.wrapper.find('[data-contact-duplicate]').exists()).toBe(false) + + // 고치는 동안은 옛 주소의 안내를 내린다 + await email?.setValue('seoyun.k@hanbit.example') + expect(wrapper.find('[data-contact-duplicate]').exists()).toBe(false) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()).toHaveLength(1) + }) + + it('채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아보고, 채운 그대로면 닫을 때 묻지 않는다', async () => { + const { wrapper, closes } = await mountDialog( + { prefill: { name: '강서윤', email: 'seoyun.kang@hanbit.example' } }, + { search: () => [SEOYUN] }, + ) + + expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤') + expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(closes).toHaveLength(1) + }) + + it('[그 연락처 보기] — 창을 닫고 그 연락처를 고른 주소록으로 (바꾼 것이 있으면 먼저 묻는다)', async () => { + const { wrapper, router, closes } = await mountDialog({}, { search: () => [SEOYUN] }, '/mail/inbox') + await wrapper.get('#contact-name').setValue('강 과장') + const email = emailInputs(wrapper)[0] + await email?.setValue('seoyun.kang@hanbit.example') + await email?.trigger('blur') + await flushPromises() + + await wrapper.get('.entry-rows__link').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(wrapper.get('#contact-leave-title').text()).toBe('저장하지 않은 연락처가 있습니다') + expect(wrapper.get('#contact-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.') + await wrapper.get('#contact-leave-confirm').trigger('click') + await flushPromises() + + expect(closes).toHaveLength(1) + expect(router.currentRoute.value.name).toBe('contacts-contact') + expect(router.currentRoute.value.params).toEqual({ contactId: 'c1' }) + }) + + it('다른 프로그램이 넣은 제어 문자(탭 · CR LF)는 열 때 고친다 — 고친 것만으로는 [취소]에 묻지 않고, 저장하면 고친 값을 보낸다', async () => { + const imported: Contact = { ...JIHOON, name: '박지훈\t', title: '팀장\u0000', note: '첫 줄\r\n둘째\t줄' } + const { wrapper, closes } = await mountDialog({ contact: imported }) + + expect((wrapper.get('#contact-note').element as HTMLTextAreaElement).value).toBe('첫 줄\n둘째 줄') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(closes).toHaveLength(1) + + await wrapper.setProps({ open: true }) + await flushPromises() + await wrapper.get('#contact-department').setValue('영업\t2팀') + await wrapper.get('#contact-note').setValue('첫 줄\n셋째\t줄') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + // 고친 칸만 고친 값으로 — 손대지 않은 이름 · 직책(남의 제어 문자)은 보내지 않는다 + expect(saves()[0]?.data).toEqual({ department: '영업 2팀', note: '첫 줄\n셋째 줄' }) + }) + + it('다른 프로그램이 넣은 1,000자 넘는 메모를 고쳤으면 [저장] 때 메모 아래에 알린다 — 보내지 않는다', async () => { + const { wrapper } = await mountDialog({ contact: { ...JIHOON, note: '가'.repeat(1200) } }) + + expect(wrapper.get('[data-contact-note-count]').text()).toBe('1,200 / 1,000') + await wrapper.get('#contact-note').setValue('가'.repeat(1100)) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(wrapper.get('#contact-note').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-note-error').text()).toBe('메모는 1,000자까지 쓸 수 있습니다.') + expect(document.activeElement?.id).toBe('contact-note') + expect(saves()).toEqual([]) + }) + + it('나가기 확인 — 바꾼 것이 있으면 [취소] · Esc 에 묻는다: [계속 고치기]면 그대로, [닫기]면 닫는다 · 다른 화면으로 가면 [나가기]', async () => { + const { wrapper, router, closes } = await mountDialog({ contact: JIHOON }, {}, '/contacts/all/c2') + + await wrapper.get('#contact-title').setValue('부장') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(document.activeElement?.id).toBe('contact-leave-stay') + expect(wrapper.get('#contact-leave-stay').text()).toBe('계속 고치기') + expect(wrapper.get('#contact-leave-confirm').text()).toBe('닫기') + + await wrapper.get('#contact-leave-stay').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(isOpen(wrapper, '#contact-dialog')).toBe(true) + expect(closes).toEqual([]) + + const leaving = router.push('/mail/inbox') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(wrapper.get('#contact-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.') + expect(wrapper.get('#contact-leave-confirm').text()).toBe('나가기') + await wrapper.get('#contact-leave-confirm').trigger('click') + await leaving + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('서버의 형식 거절(VAL_001)은 단추 줄 위에 사전 문구 — 알림 없이 · 한도(CONTACT_002)는 사전 문구 알림, 창은 그대로', async () => { + let reply: MockResult = fail(400, 'VAL_001') + const { wrapper, closes } = await mountDialog( + { prefill: { name: null, email: 'dokyung.han@mail.example' } }, + { save: () => reply }, + ) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(wrapper.get('#contact-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001) + expect(toasts()).toEqual([]) + + reply = fail(409, 'CONTACT_002') + await wrapper.get('#contact-name').setValue('한도경') + expect(wrapper.find('#contact-error').exists()).toBe(false) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(toasts()).toEqual([ERROR_CODE_MESSAGES.CONTACT_002]) + expect(wrapper.find('#contact-error').exists()).toBe(false) + expect(closes).toEqual([]) + }) + + it('[이 연락처 지우기] — 확인 없이 지우고 닫는다 · 알림의 [되돌리기]가 지운 연락처를 다시 만든다', async () => { + const { wrapper, closes } = await mountDialog({ contact: JIHOON }) + await useContactsStore().loadGroups() + + await wrapper.get('#contact-remove').trigger('click') + await flushPromises() + + expect(requests.some((request) => request.method === 'DELETE' && request.url === '/contacts/c2')).toBe(true) + expect(closes).toHaveLength(1) + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('‘박지훈’ 연락처를 지웠습니다.') + expect(toast?.action?.label).toBe('되돌리기') + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + + // 되돌리기는 지우기 응답의 표로 — 서버가 지운 그대로(다른 프로그램의 칸까지) 다시 만든다 + expect(requests.find((request) => request.url === '/contact-restorations')).toMatchObject({ + method: 'POST', + data: { token: 't-c2' }, + }) + expect(saves()).toEqual([]) + expect(toasts()).toContain('연락처를 되돌렸습니다.') + }) + + it('저장을 보내는 사이 창을 닫았으면(취소) 창 안에 보일 거절(VAL_001)을 알림으로 알린다', async () => { + let finish: (result: MockResult) => void = () => undefined + const { wrapper, closes } = await mountDialog( + { prefill: { name: '한도경', email: 'dokyung.han@mail.example' } }, + { + save: () => + new Promise((resolve) => { + finish = resolve + }), + }, + ) + + await wrapper.get('#contact-save').trigger('click') + // 보내는 중에는 묻지 않고 닫는다 (결과는 알림으로) + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(closes).toHaveLength(1) + finish(fail(400, 'VAL_001')) + await flushPromises() + + expect(toasts()).toEqual([ERROR_CODE_MESSAGES.VAL_001]) + expect(wrapper.find('#contact-error').exists()).toBe(false) + }) + + it('보내는 사이 세션이 바뀌면(로그아웃 · 다른 계정) 늦게 온 응답으로 알리지도 닫지도 않는다', async () => { + let finish: (result: MockResult) => void = () => undefined + const late = new Promise((resolve) => { + finish = resolve + }) + const { wrapper, closes } = await mountDialog( + { prefill: { name: '한도경', email: 'dokyung.han@mail.example' } }, + { save: () => late }, + ) + + await wrapper.get('#contact-save').trigger('click') + expect(wrapper.get('#contact-save').attributes('aria-disabled')).toBe('true') + useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }) + finish(ok({ ...JIHOON, id: 'c9' })) + await flushPromises() + + expect(toasts()).toEqual([]) + expect(closes).toEqual([]) + }) +}) diff --git a/frontend/src/components/contacts/ContactDialog.vue b/frontend/src/components/contacts/ContactDialog.vue new file mode 100644 index 0000000..5a9b085 --- /dev/null +++ b/frontend/src/components/contacts/ContactDialog.vue @@ -0,0 +1,761 @@ + + +