generated from ttipo/project-base
fix(frontend): 받는 사람별 발송 결과 알림 · 보내는 중 세션 바뀜 막기 · 부재중 한도
- 일부 거절: 보낸 것으로 떠나며 "보냈지만 N명은 받지 못했습니다 — 주소 (까닭)" 오류 모양 알림 + [보낸편지함 보기] - MAIL_010: 받는 사람 안내 띠 · 초점(알림 없음, 칸은 aria-invalid · aria-describedby), MAIL_011: 인터셉터 알림 — 둘 다 같은 키로 다시 보낸다 - 보내기를 시작한 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 보내지 않고, 결과 알림 · 보내지 못한 메일의 저장본 남기기도 하지 않는다 — 다른 계정의 쿠키로 앞 사용자의 메일이 나가거나 저장되던 틈 - 응답의 rejectedRecipients 는 sendMessage 한 곳에서 배열로 맞춘다(필드가 없는 옛 서버) - 부재중 자동 응답 제목 150 · 내용 600 (켤 때 검사) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -859,6 +859,126 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('보내는 중에 세션이 바뀌면 — 보내지도 · 알리지도 · 저장본으로 남기지도 않는다 (요청은 지금 쿠키로 나간다)', () => {
|
||||
const RouterHost: FunctionalComponent = () => h('div')
|
||||
const OTHER = { email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }
|
||||
|
||||
/** 붙잡아 둔 응답 — 테스트가 풀어 준다 */
|
||||
function holder() {
|
||||
const held: { release: ((result: MockResult) => void) | null } = { release: null }
|
||||
const hold = () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
held.release = resolve
|
||||
})
|
||||
return { held, hold }
|
||||
}
|
||||
|
||||
/** 이 탭의 로그아웃 → 로그인 화면 → 다른 계정으로 로그인 (쓰기 화면은 내려간다) */
|
||||
async function switchAccount(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
|
||||
useAuthStore().clearAuth()
|
||||
await router.push('/login')
|
||||
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
|
||||
wrapper.unmount()
|
||||
useAuthStore().startSession(OTHER)
|
||||
}
|
||||
|
||||
async function writeAndAutosave(wrapper: VueWrapper) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await wrapper.get('#compose-subject').setValue('일정 공유')
|
||||
await wait(3_000)
|
||||
}
|
||||
|
||||
const sendCalls = (requests: MockRequest[]) =>
|
||||
requests.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
|
||||
it('임시 저장을 기다리는 사이 바뀌면 보내지 않는다 — 앞 사용자의 메일이 다른 계정에서 나가지 않게', async () => {
|
||||
const save = holder()
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
server({
|
||||
extra: (request) => (request.method === 'POST' && request.url === '/drafts' ? save.hold() : undefined),
|
||||
}),
|
||||
)
|
||||
await writeAndAutosave(wrapper)
|
||||
// 저장이 나가 있는 동안 보내기를 누른다 — 보내기는 그 저장을 기다린다
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
await switchAccount(wrapper, router)
|
||||
|
||||
save.held.release?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(sendCalls(requests)).toEqual([])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('보내기가 실패로 끝나도(MAIL_002) 알리지 않고 저장본으로 남기지 않는다 — 다른 계정의 임시보관함에 들어가지 않게', async () => {
|
||||
const send = holder()
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
server({
|
||||
extra: (request) => (request.method === 'POST' && request.url === '/messages' ? send.hold() : undefined),
|
||||
}),
|
||||
)
|
||||
await writeAndAutosave(wrapper)
|
||||
await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
await switchAccount(wrapper, router)
|
||||
|
||||
send.held.release?.(fail(400, 'MAIL_002'))
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /drafts'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('저장본으로 남기는 PUT 을 기다리는 사이 바뀌면 — 옛 저장본이 없어도(RES_001) 새로 남기지 않는다', async () => {
|
||||
const send = holder()
|
||||
const keep = holder()
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
server({
|
||||
extra: (request) => {
|
||||
if (request.method === 'POST' && request.url === '/messages') return send.hold()
|
||||
if (request.method === 'PUT' && request.url === '/drafts/d1') return keep.hold()
|
||||
return undefined
|
||||
},
|
||||
}),
|
||||
)
|
||||
await writeAndAutosave(wrapper)
|
||||
await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
// 같은 세션에서 다른 화면으로 — 보내기가 실패하면 보내려던 내용을 남긴다 (PUT /drafts/d1)
|
||||
await router.push('/mail/sent')
|
||||
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
|
||||
wrapper.unmount()
|
||||
send.held.release?.(fail(503, 'MAIL_001'))
|
||||
await flushPromises()
|
||||
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'POST /drafts',
|
||||
'PUT /drafts/d1',
|
||||
])
|
||||
|
||||
// 그 PUT 이 나가 있는 동안 다른 계정으로 로그인했다 — 새로 남기는 POST 는 그 계정의 쿠키로 나간다
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(OTHER)
|
||||
keep.held.release?.(fail(404, 'RES_001'))
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'POST /drafts',
|
||||
'PUT /drafts/d1',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('본문이 한도를 넘으면', () => {
|
||||
it('자동 저장을 보내지 않는다 — 상태 글은 실패, 본문 아래 MAIL_007 안내. 내용이 바뀔 때까지 다시 하지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', server())
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import type { Mailbox, MessageDetail, RejectedRecipient } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -265,6 +265,135 @@ describe('ComposePage', () => {
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
|
||||
})
|
||||
|
||||
describe('받는 사람별 결과 — 일부만 받지 못하면 보낸 것, 모두 받지 못하면 보내지 않은 것 (계약 2절 "받는 사람별 결과")', () => {
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 1, totalCount: 1, unreadCount: 0 },
|
||||
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
|
||||
]
|
||||
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
|
||||
|
||||
/** 보내기 응답을 차례로 돌려주는 서버 — 메일함 목록에는 보낸편지함이 있다 */
|
||||
function sendSequence(outcomes: MockResult[]): MockHandler {
|
||||
return (request) => {
|
||||
if (request.method === 'GET' && request.url === '/mailboxes') {
|
||||
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/messages') {
|
||||
return outcomes.shift() ?? ok({ id: 'sent-2', rejectedRecipients: [] }, 201)
|
||||
}
|
||||
return replyApi(request)
|
||||
}
|
||||
}
|
||||
|
||||
function sentKeys(requests: MockRequest[]): unknown[] {
|
||||
return requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
.map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
}
|
||||
|
||||
async function addRecipient(wrapper: VueWrapper, email: string) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue(email)
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
async function submit(wrapper: VueWrapper) {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('일부만 받지 못했으면 보낸 것이다 — 보던 곳으로 돌아가고, "보냈습니다" 대신 오류 알림(받지 못한 사람 · [보낸편지함])', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox')
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
sendSequence([ok({ id: 'sent-1', rejectedRecipients: [NOBODY] }, 201)]),
|
||||
)
|
||||
await useMailStore().loadMailboxes()
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await addRecipient(wrapper, NOBODY.email)
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useToastStore().toasts).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'error',
|
||||
message: '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
// 성공 알림과 같은 곳 — 링크가 있으니 10초 (toast.store)
|
||||
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// 보낸 것이니 키를 바꾼다 — 같은 화면에서 또 보내면 다른 메일이다
|
||||
await submit(wrapper)
|
||||
const keys = sentKeys(requests)
|
||||
expect(keys).toEqual([expect.any(String), expect.any(String)])
|
||||
expect(keys[1]).not.toBe(keys[0])
|
||||
})
|
||||
|
||||
it.each(['MAIL_002', 'MAIL_010'] as const)(
|
||||
'서버가 받는 사람을 거절하면(%s) 주소가 든 칸은 잘못된 입력 — 안내 띠를 가리킨다. 빈 칸은 그대로, 받는 사람을 고치면 푼다',
|
||||
async (code) => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', sendSequence([fail(400, code)]))
|
||||
await addRecipient(wrapper, NOBODY.email)
|
||||
const cc = wrapper.get('#compose-cc')
|
||||
await cc.setValue('infra@arasystem.it.kr')
|
||||
await cc.trigger('blur')
|
||||
expect(wrapper.get('#compose-to').attributes('aria-invalid')).toBe('false')
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES[code])
|
||||
for (const id of ['compose-to', 'compose-cc']) {
|
||||
expect(wrapper.get(`#${id}`).attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get(`#${id}`).attributes('aria-describedby')).toBe('compose-recipient-error')
|
||||
}
|
||||
// 빈 숨은참조에는 거절된 주소가 없다
|
||||
expect(wrapper.get('#compose-bcc').attributes('aria-invalid')).toBe('false')
|
||||
expect(wrapper.get('#compose-bcc').attributes('aria-describedby')).toBeUndefined()
|
||||
|
||||
// 받는 사람을 고치면 띠와 함께 푼다
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#compose-to').attributes('aria-invalid')).toBe('false')
|
||||
expect(wrapper.get('#compose-to').attributes('aria-describedby')).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it('모두 받지 못했으면(MAIL_010 — 없는 주소 등) 보내지 않은 것이다 — 알림 없이 받는 사람 띠에 그 문구 · 받는 사람 칸으로 초점, 쓴 내용 · 키는 그대로', async () => {
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
sendSequence([fail(400, 'MAIL_010')]),
|
||||
true,
|
||||
)
|
||||
await addRecipient(wrapper, NOBODY.email)
|
||||
await wrapper.get('#compose-subject').setValue('안녕하세요')
|
||||
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_010)
|
||||
expect(document.activeElement?.id).toBe('compose-to')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(chips(wrapper, 'compose-to')).toEqual([NOBODY.email])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
// 받는 사람을 고치면 띠를 내린다 — 없는 주소를 빼고 다시 보내면 같은 키로 간다 (서버가 보낸 사본을 지워 새로 보낸다)
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
|
||||
await wrapper.get(`button[aria-label="${NOBODY.email} 삭제"]`).trigger('click')
|
||||
expect(chips(wrapper, 'compose-to')).toEqual(['doyun.lee@arasystem.it.kr'])
|
||||
await submit(wrapper)
|
||||
|
||||
const keys = sentKeys(requests)
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys[1]).toBe(keys[0])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('전달 (?forward=)', () => {
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const ESTIMATE = { id: 'att2', name: '견적.pdf', type: 'application/pdf', size: 1000 }
|
||||
@@ -800,6 +929,8 @@ describe('ComposePage', () => {
|
||||
['413 FILE_002 (첨부 합계 초과)', fail(413, 'FILE_002'), 'FILE_002'],
|
||||
['403 AUTH_003 (보낼 권한 없음·출처 불일치)', fail(403, 'AUTH_003'), 'AUTH_003'],
|
||||
['400 MAIL_006 (첨부 20개 초과 — 붙일 때 미리 막지만 서버가 마지막에 본다)', fail(400, 'MAIL_006'), 'MAIL_006'],
|
||||
// 보내지 않은 것이다(서버가 보낸 사본을 지웠다) — 잠시 뒤 다시 누르면 같은 키로 새로 간다 (계약 2절 "받는 사람별 결과")
|
||||
['503 MAIL_011 (받는 사람 모두 지금은 받을 수 없음)', fail(503, 'MAIL_011'), 'MAIL_011'],
|
||||
] as const)(
|
||||
'%s — 사전 문구로 알리고 쓴 내용·키를 그대로 둔다. 자동으로 다시 보내지 않고, 다시 누르면 같은 키다',
|
||||
async (_label, failure, code) => {
|
||||
@@ -1230,6 +1361,32 @@ describe('ComposePage', () => {
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
it('받는 사람 일부가 받지 못했어도 보낸 것이다 — 묻지 않고 돌아가며 창 닫기도 막지 않는다 (계약 2절 "받는 사람별 결과")', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', (request) =>
|
||||
request.method === 'POST' && request.url === '/messages'
|
||||
? ok({ id: 'sent-1', rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'refused' }] }, 201)
|
||||
: replyApi(request),
|
||||
)
|
||||
for (const email of ['doyun.lee@arasystem.it.kr', 'nobody@arasystem.it.kr']) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue(email)
|
||||
await input.trigger('blur')
|
||||
}
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (받는 쪽이 거절)'],
|
||||
])
|
||||
})
|
||||
|
||||
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
|
||||
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
|
||||
const RouterHost: FunctionalComponent = () => h('div')
|
||||
@@ -1297,11 +1454,66 @@ describe('ComposePage', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
})
|
||||
|
||||
it('떠난 뒤 보내기가 받는 사람 일부를 받지 못한 채 끝나도 — "보냈습니다" 대신 받지 못한 사람 알림 하나, 간 곳에서 끌고 오지 않는다', async () => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1&all=1', handler)
|
||||
await startSending(wrapper)
|
||||
await router.push('/mail/sent')
|
||||
leaveScreen(wrapper, router)
|
||||
|
||||
held.release?.(
|
||||
ok({ id: 'sent-1', rejectedRecipients: [{ email: 'infra@arasystem.it.kr', reason: 'temporary' }] }, 201),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '보냈지만 1명은 받지 못했습니다 — infra@arasystem.it.kr (지금은 받을 수 없음)'],
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
})
|
||||
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
const OTHER = { email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }
|
||||
const PARTIAL = ok({ id: 'sent-1', rejectedRecipients: [{ email: 'infra@arasystem.it.kr', reason: 'not-found' }] }, 201)
|
||||
it.each([
|
||||
['로그아웃', (store: AuthStore) => store.clearAuth(), PARTIAL, true],
|
||||
[
|
||||
'다른 계정 로그인',
|
||||
(store: AuthStore) => {
|
||||
store.clearAuth()
|
||||
store.startSession(OTHER)
|
||||
},
|
||||
PARTIAL,
|
||||
true,
|
||||
],
|
||||
['로그아웃 — 모두 받았다', (store: AuthStore) => store.clearAuth(), ok({ id: 'sent-1', rejectedRecipients: [] }, 201), true],
|
||||
['세션 만료 — 쓰기 화면은 대화상자 뒤에 남는다', (store: AuthStore) => store.expireSession(), PARTIAL, false],
|
||||
] as const)(
|
||||
'보내는 중 %s — 결과가 와도 알리지 않는다 (일부 거절 알림에는 앞 사용자의 받는 사람 주소가 있다)',
|
||||
async (_label, endSession, result, leaves) => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1&all=1', handler)
|
||||
await startSending(wrapper)
|
||||
endSession(useAuthStore())
|
||||
if (leaves) {
|
||||
await router.push('/login')
|
||||
leaveScreen(wrapper, router)
|
||||
}
|
||||
|
||||
held.release?.(result)
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
|
||||
['MAIL_010 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_010'), 'MAIL_010'],
|
||||
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
|
||||
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
|
||||
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
|
||||
['MAIL_011 (평소에도 토스트)', fail(503, 'MAIL_011'), 'MAIL_011'],
|
||||
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
|
||||
@@ -21,10 +21,14 @@
|
||||
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정).
|
||||
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
|
||||
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
|
||||
// 엔진이 받는 사람을 모두 거절하면(MAIL_010 — 없는 주소 등, 보내지 않은 것) 같은 띠 · 같은 초점에 그 코드의 문구.
|
||||
// 일부만 받지 못했으면 보낸 것이다 — 떠나면서 "보냈습니다" 대신 받지 못한 사람을 오류 알림 모양으로 알린다 (계약 2절 "받는 사람별 결과").
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
|
||||
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
|
||||
// 보내는 중 · 보낸 뒤 · 버린 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
|
||||
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
|
||||
// 보내는 중에 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 결과를 알리지도 저장본으로 남기지도 않는다 — 앞 사용자의 받는 사람이
|
||||
// 로그인 화면 · 다른 계정에 보이거나 그 계정의 임시보관함에 들어가지 않게. 임시 저장을 기다리는 사이 바뀌었으면 보내지도 않는다.
|
||||
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
|
||||
// 휴지통 #compose-discard · 닫기 .compose__close
|
||||
import {
|
||||
@@ -71,6 +75,7 @@ import {
|
||||
MAIL_ATTACHMENT_ACCEPT,
|
||||
MAIL_MAX_ATTACHMENTS,
|
||||
MAIL_MAX_RECIPIENTS,
|
||||
MAIL_PARTIAL_SEND_TEXT,
|
||||
MAIL_SENT_ACTION_LABEL,
|
||||
MAIL_SENT_MESSAGE,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
@@ -78,7 +83,7 @@ import {
|
||||
} from '@/shared/constants/mail'
|
||||
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { Draft, DraftPayload, SendMessagePayload } from '@/shared/types/mail'
|
||||
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
|
||||
import {
|
||||
isSameComposeForm,
|
||||
snapshotComposeForm,
|
||||
@@ -101,6 +106,7 @@ import { attachmentHint } from '@/shared/utils/mail-attachment'
|
||||
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
|
||||
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
|
||||
import { buildReplyDraft } from '@/shared/utils/mail-reply'
|
||||
import { partialSendMessage } from '@/shared/utils/mail-send-result'
|
||||
import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
@@ -124,6 +130,9 @@ const BODY_ERROR_ID = 'compose-body-error'
|
||||
/** 쓰기 화면을 여는 까닭 — 새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기(임시 저장본) */
|
||||
type ComposeSource = ComposeMode | 'draft'
|
||||
|
||||
/** 서버가 받는 사람을 거부한 코드 — 받는 사람 칸 아래 띠로 안내한다 */
|
||||
type RecipientRejectionCode = Extract<ErrorCode, 'MAIL_002' | 'MAIL_010'>
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -179,8 +188,11 @@ const discarding = ref<boolean>(false)
|
||||
const discarded = ref<boolean>(false)
|
||||
/** 보내기를 한 번 눌렀는지 — 받는 사람이 비었다는 안내는 그 뒤에만 보인다 */
|
||||
const attempted = ref<boolean>(false)
|
||||
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
|
||||
const rejectedByServer = ref<boolean>(false)
|
||||
/**
|
||||
* 서버가 받는 사람을 거부했다 — MAIL_002(형식 · 합계 100명 초과) · MAIL_010(엔진이 모두 거절 — 없는 주소 등).
|
||||
* 받는 사람을 고치거나 다시 보내면 내린다
|
||||
*/
|
||||
const rejectedByServer = ref<RecipientRejectionCode | null>(null)
|
||||
/**
|
||||
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
|
||||
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
|
||||
@@ -210,7 +222,7 @@ let formGeneration = 0
|
||||
*/
|
||||
let resetCount = 0
|
||||
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
@@ -268,14 +280,18 @@ const allRecipients = computed(() => [...to.value, ...cc.value, ...bcc.value])
|
||||
const hasInvalidRecipient = computed(() => allRecipients.value.some((token) => !token.valid))
|
||||
/** 받는 사람 합계 100명 초과 — 서버도 MAIL_002 로 거절하므로 보내기 전에 같은 문구로 막는다 (계약 2절) */
|
||||
const tooManyRecipients = computed(() => allRecipients.value.length > MAIL_MAX_RECIPIENTS)
|
||||
const showRecipientError = computed(
|
||||
() =>
|
||||
hasInvalidRecipient.value ||
|
||||
rejectedByServer.value ||
|
||||
(attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
|
||||
/** 보내기 전 검사에 걸린 받는 사람 — 형식이 틀린 주소, 보내기를 누른 뒤의 빈 받는 사람 · 100명 초과 */
|
||||
const recipientPrecheckFailed = computed(
|
||||
() => hasInvalidRecipient.value || (attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
|
||||
)
|
||||
const showRecipientError = computed(() => recipientPrecheckFailed.value || rejectedByServer.value !== null)
|
||||
/**
|
||||
* 문구는 에러 코드 사전에서만 온다 — 보내기 전 검사와 서버의 MAIL_002 는 같은 문구,
|
||||
* 엔진이 모두 거절한 것(MAIL_010)은 그 코드의 문구 (보내기 전 검사에 걸렸으면 그것부터)
|
||||
*/
|
||||
const recipientErrorMessage = computed(() =>
|
||||
getErrorMessage(recipientPrecheckFailed.value ? 'MAIL_002' : (rejectedByServer.value ?? 'MAIL_002')),
|
||||
)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const recipientErrorMessage = getErrorMessage('MAIL_002')
|
||||
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const subjectErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
|
||||
@@ -528,7 +544,7 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
|
||||
bodyPending.value = false
|
||||
renewEditor()
|
||||
attempted.value = false
|
||||
rejectedByServer.value = false
|
||||
rejectedByServer.value = null
|
||||
lengthError.value = null
|
||||
resetUploads()
|
||||
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
|
||||
@@ -691,14 +707,20 @@ function settleSendKey(outcome: SendOutcome) {
|
||||
if (shouldRenewSendKey(outcome)) sendKey = createIdempotencyKey()
|
||||
}
|
||||
|
||||
function notifySent() {
|
||||
/**
|
||||
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — onSubmit). 받는 사람 일부가 받지 못했으면
|
||||
* "보냈습니다" 대신 오류 알림 모양(흰 카드)으로 받지 못한 사람을 적는다 — 링크는 같은 보낸편지함 (계약 2절 "받는 사람별 결과").
|
||||
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS)
|
||||
*/
|
||||
function notifySent(result: SentMessage) {
|
||||
const sentMailbox = mailStore.mailboxByRole('sent')
|
||||
toast.success(
|
||||
MAIL_SENT_MESSAGE,
|
||||
sentMailbox
|
||||
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
|
||||
: undefined,
|
||||
)
|
||||
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
|
||||
const partial = partialSendMessage(result.rejectedRecipients)
|
||||
if (partial) {
|
||||
toast.error(partial, sentLocation ? { label: MAIL_PARTIAL_SEND_TEXT.action, to: sentLocation } : undefined)
|
||||
return
|
||||
}
|
||||
toast.success(MAIL_SENT_MESSAGE, sentLocation ? { label: MAIL_SENT_ACTION_LABEL, to: sentLocation } : undefined)
|
||||
}
|
||||
|
||||
/** 보내지 못했다 — 쓰던 대로 두고 자동 저장을 다시 켠다. 기다린 저장이 저장본을 바꿨으면 주소도 맞춘다 */
|
||||
@@ -713,7 +735,7 @@ async function onSubmit() {
|
||||
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
|
||||
commitRecipientInputs()
|
||||
attempted.value = true
|
||||
rejectedByServer.value = false
|
||||
rejectedByServer.value = null
|
||||
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
|
||||
toField.value?.focus()
|
||||
return
|
||||
@@ -745,12 +767,24 @@ async function onSubmit() {
|
||||
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
|
||||
const generation = formGeneration
|
||||
const mark = resetCount
|
||||
/**
|
||||
* 이 보내기를 시작한 세션 — 그 뒤 끝났거나 바뀌었으면(로그아웃 · 만료 · 다른 계정 로그인) 보내지도 · 알리지도 · 저장본으로 남기지도 않는다.
|
||||
* 결과 알림에는 받는 사람 주소가 들 수 있고(일부 거절), 그 뒤의 요청은 지금 쿠키(다른 계정일 수 있다)로 나간다
|
||||
*/
|
||||
const session = authStore.generation
|
||||
const sameSession = () => authStore.generation === session
|
||||
sending.value = true
|
||||
// 진행 중인 임시 저장을 기다린다 — 보내는 본문의 draftId · 첨부 id 가 최신이어야 한다 (계약 2절 "임시 저장").
|
||||
// 그사이 화면을 떠나도 보낸다(보내기를 이미 눌렀다). 다른 메일로 바뀌었으면 이 메일은 보내지 않는다
|
||||
drafts.suspend()
|
||||
await drafts.settle()
|
||||
if (mark !== resetCount) return
|
||||
// 기다리는 사이 세션이 바뀌었다 — 보내지 않는다 (다른 계정의 쿠키로 나가지 않게). 자동 저장도 다시 켜지 않는다 —
|
||||
// 양식이 남아 있다면 세션 만료 대화상자 뒤다
|
||||
if (!sameSession()) {
|
||||
if (generation === formGeneration) sending.value = false
|
||||
return
|
||||
}
|
||||
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다) 그사이 새 첨부를 올리기 시작했다.
|
||||
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다
|
||||
if (hasMissing.value || isUploading.value) {
|
||||
@@ -780,15 +814,19 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
try {
|
||||
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 받는 사람 거부(MAIL_002) · 엔진이 받는 사람을 모두 거절(MAIL_010 — 없는 주소 등) · 첨부 만료(MAIL_004) ·
|
||||
// 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
|
||||
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
|
||||
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
|
||||
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
|
||||
notifySent()
|
||||
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
|
||||
mailStore.syncCounts()
|
||||
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애, MAIL_011 받는 사람 모두 지금은 받을 수 없음(보내지 않았다).
|
||||
// REQ_001 · MAIL_011 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
|
||||
if (sameSession()) {
|
||||
// 받는 사람 일부가 받지 못했어도 보낸 것이다 — 아래는 모두 받았을 때와 같고, 알림만 받지 못한 사람을 적는다
|
||||
notifySent(result)
|
||||
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
|
||||
mailStore.syncCounts()
|
||||
}
|
||||
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
|
||||
if (generation !== formGeneration) return
|
||||
settleSendKey('sent')
|
||||
@@ -800,18 +838,23 @@ async function onSubmit() {
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (generation !== formGeneration) {
|
||||
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
|
||||
// 양식이 이미 없다 — 세션이 바뀌었으면 알리지도 저장본으로 남기지도 않는다 (로그인 화면 · 다른 계정에 앞 사용자의 실패가 보이거나
|
||||
// 그 사람의 임시보관함에 이 메일이 들어가지 않게)
|
||||
if (!sameSession()) return
|
||||
// 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
|
||||
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
// 마지막 자동 저장 뒤에 쓴 글을 잃지 않게 보내려던 내용을 조용히 저장본으로 남긴다 (코드와 상관없이 — 알림은 위의 실패 알림뿐)
|
||||
void draftLink.keepUnsent(payload)
|
||||
void draftLink.keepUnsent(payload, session)
|
||||
return
|
||||
}
|
||||
const { code } = apiError
|
||||
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
|
||||
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절).
|
||||
// MAIL_010 · MAIL_011 도 그대로 — 아무에게도 가지 않아 서버가 보낸 사본을 지웠다. 같은 키로 다시 보내면 새로 보낸다
|
||||
settleSendKey(code)
|
||||
endSending(generation)
|
||||
if (code === 'MAIL_002') {
|
||||
rejectedByServer.value = true
|
||||
if (code === 'MAIL_002' || code === 'MAIL_010') {
|
||||
// 받는 사람 칸 아래 띠 — MAIL_010 은 보내지 않은 것이라 쓰던 메일은 그대로 두고 주소를 고치게 한다
|
||||
rejectedByServer.value = code
|
||||
toField.value?.focus()
|
||||
} else if (code === 'MAIL_007') {
|
||||
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
|
||||
@@ -949,7 +992,7 @@ watch(hasMissing, (missing) => {
|
||||
|
||||
// 받는 사람을 고치면 서버 거부 안내를 내린다
|
||||
watch(allRecipients, () => {
|
||||
rejectedByServer.value = false
|
||||
rejectedByServer.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -975,6 +1018,7 @@ watch(allRecipients, () => {
|
||||
input-id="compose-to"
|
||||
placeholder="메일 주소 입력 후 Enter"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:rejected="rejectedByServer !== null"
|
||||
:disabled="preparing"
|
||||
>
|
||||
<template #trailing>
|
||||
@@ -1001,6 +1045,7 @@ watch(allRecipients, () => {
|
||||
input-id="compose-cc"
|
||||
placeholder="메일 주소"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:rejected="rejectedByServer !== null"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
<RecipientField
|
||||
@@ -1010,6 +1055,7 @@ watch(allRecipients, () => {
|
||||
input-id="compose-bcc"
|
||||
placeholder="메일 주소"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:rejected="rejectedByServer !== null"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -99,8 +99,9 @@ describe('MailVacationPage', () => {
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('200')
|
||||
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('4000')
|
||||
// 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거")
|
||||
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150')
|
||||
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600')
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
|
||||
})
|
||||
|
||||
@@ -191,6 +192,34 @@ describe('MailVacationPage', () => {
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('켠 채 저장할 때 제목 150자 · 내용 600자를 넘으면(4단계 한도로 저장해 둔 긴 글 등) 칸 아래에 사전 문구 — 줄이면 저장한다', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, subject: 'S'.repeat(151), textBody: 'B'.repeat(601) } })
|
||||
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(document.activeElement?.id).toBe('vacation-subject')
|
||||
|
||||
await wrapper.get('#vacation-subject').setValue('S'.repeat(150))
|
||||
await wrapper.get('#vacation-body').setValue('B'.repeat(600))
|
||||
await save(wrapper)
|
||||
expect(puts()).toEqual([{ ...SAVED, subject: 'S'.repeat(150), textBody: 'B'.repeat(600) }])
|
||||
})
|
||||
|
||||
it('켜진 옛 긴 글(4단계에 저장한 내용 1,000자)도 끄는 것은 늘 저장된다 — 꺼 둘 때는 길이를 보지 않는다 (서버는 꺼 둔 채로 4단계 한도까지 받는다)', async () => {
|
||||
const longBody = 'B'.repeat(1_000)
|
||||
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: longBody } })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }])
|
||||
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)
|
||||
})
|
||||
|
||||
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
|
||||
Reference in New Issue
Block a user