generated from ttipo/project-base
feat(frontend): 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 화면
쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]), 인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절. 리뷰로 고친 것: - 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다). 읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다. - 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도). - 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다. - 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도). - 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다. - 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게), 차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { Draft, Mailbox, RejectedRecipient } from '@/shared/types/mail'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { schedulePresets, sendAtOf } from '@/shared/utils/schedule-send'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
// 쓰기 화면의 9단계 — 보내기 취소(보낸 뒤 알림 · [보내기 취소]) · [보내기 ▾] › 예약 발송
|
||||
// (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
/** 2026-10-29(목) 15:00 (서울) */
|
||||
const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
|
||||
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' }
|
||||
/** 보내기 취소로 임시보관함에 돌아온 메일 */
|
||||
const RETURNED: Draft = {
|
||||
id: 'sent-1',
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '일정 공유',
|
||||
body: '확인 부탁드립니다.',
|
||||
bodyHtml: null,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-29T06:00:00Z',
|
||||
}
|
||||
|
||||
describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/MailEditor.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
clock = createManualClock(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mountedWrappers.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** 보내기 응답을 차례로 — 메일함 · 예약됨 수 · 되돌아온 저장본은 늘 같다 */
|
||||
function server(sendReplies: MockResult[], cancelReply: MockResult = ok({ draftId: 'sent-1' })): MockHandler {
|
||||
return (request) => {
|
||||
if (request.method === 'POST' && request.url === '/messages') return sendReplies.shift() ?? ok({ id: 'sent-9' }, 201)
|
||||
if (request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/')) return cancelReply
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
if (request.url === '/drafts/sent-1') return ok(RETURNED)
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
async function mountCompose(handler: MockHandler, attach = false) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.push('/mail/compose')
|
||||
await router.isReady()
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox')
|
||||
await useMailStore().loadMailboxes()
|
||||
const wrapper = mount(ComposePage, {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
return { wrapper, router, requests }
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const sends = (requests: MockRequest[]) => requests.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
const toasts = (): Toast[] => useToastStore().toasts
|
||||
/** 알림의 단추를 누른다 — 알림 틀(ToastHost)처럼 알림을 닫고 할 일을 한다 */
|
||||
function runAction(toast: Toast | undefined) {
|
||||
if (!toast?.action || !('run' in toast.action)) throw new Error('단추가 없다')
|
||||
useToastStore().dismiss(toast.id)
|
||||
toast.action.run()
|
||||
}
|
||||
|
||||
describe('보내기 취소 — 엔진이 붙잡아 둔 발송 (pendingSend: undo)', () => {
|
||||
const undoReply = (rejected: RejectedRecipient[] = []) =>
|
||||
ok({ id: 'sent-1', rejectedRecipients: rejected, pendingSend: { kind: 'undo', sendAt: new Date(NOW + 10_000).toISOString() } }, 201)
|
||||
|
||||
it('"메일을 보냈습니다." + [보내기 취소] — 서버가 보낼 시각(sendAt)에 닫힌다(마감 · 남은 시간 막대)', async () => {
|
||||
const { wrapper, router } = await mountCompose(server([undoReply()]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'success',
|
||||
message: '메일을 보냈습니다.',
|
||||
action: expect.objectContaining({ label: '보내기 취소' }),
|
||||
expiresAt: NOW + 10_000,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('[보내기 취소] → DELETE → 그 메일로 쓰기 화면을 다시 연다(받는 사람 · 제목 그대로) + "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다."', async () => {
|
||||
const { wrapper, router, requests } = await mountCompose(server([undoReply()]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await wrapper.get('#compose-subject').setValue('일정 공유')
|
||||
await submit(wrapper)
|
||||
|
||||
runAction(toasts()[0])
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/scheduled-messages/sent-1',
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=sent-1')
|
||||
await vi.waitFor(() => {
|
||||
if (!requests.some((request) => request.url === '/drafts/sent-1')) throw new Error('저장본을 받는 중')
|
||||
})
|
||||
await flushPromises()
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('일정 공유')
|
||||
expect(toasts().map((toast) => toast.message)).toEqual(['보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.'])
|
||||
|
||||
// 다시 보내면 새 발송 키다 — 취소한 발송의 키로 다시 보내면 서버가 MAIL_005 로 막는다 (계약)
|
||||
await submit(wrapper)
|
||||
const keys = sends(requests).map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys[1]).not.toBe(keys[0])
|
||||
})
|
||||
|
||||
it('[보내기 취소] 뒤 쓰기 화면으로 가지 못하면(지금 화면의 나가기 확인에서 머묾) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기]', async () => {
|
||||
const { wrapper, router } = await mountCompose(server([undoReply()]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await submit(wrapper)
|
||||
router.beforeEach((to) => to.name !== 'mail-compose')
|
||||
|
||||
runAction(toasts()[0])
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'success',
|
||||
message: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
|
||||
action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 'sent-1' } } },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('늦게 누르면(이미 보냄 — 409) 쓰기 화면으로 가지 않는다 — 알림은 인터셉터', async () => {
|
||||
const { wrapper, router } = await mountCompose(server([undoReply()], fail(409, 'MAIL_014')))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await submit(wrapper)
|
||||
|
||||
runAction(toasts()[0])
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(toasts().map((toast) => toast.kind)).toEqual(['error'])
|
||||
})
|
||||
|
||||
it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — …" 에도 [보내기 취소] (같은 마감)', async () => {
|
||||
const { wrapper } = await mountCompose(server([undoReply([NOBODY])]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await addRecipient(wrapper, NOBODY.email)
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'error',
|
||||
message: '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
|
||||
action: expect.objectContaining({ label: '보내기 취소' }),
|
||||
expiresAt: NOW + 10_000,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('보낼 시각이 이 PC 시계로 이미 지났으면 취소할 수 없다 — 지금처럼 [보낸편지함 보기]', async () => {
|
||||
const past = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW - 1_000).toISOString() } }, 201)
|
||||
const { wrapper } = await mountCompose(server([past]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
message: '메일을 보냈습니다.',
|
||||
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
|
||||
expiresAt: null,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('이 PC 시계가 늦어 보낼 시각이 멀리 보여도 알림은 30초(설정의 가장 긴 값)까지만 남는다', async () => {
|
||||
const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
|
||||
const { wrapper } = await mountCompose(server([far]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
const sentAt = Date.now()
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(toasts()[0]?.expiresAt).toBe(sentAt + 30_000)
|
||||
})
|
||||
|
||||
it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
|
||||
const settings = useMailSettingsStore()
|
||||
settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
|
||||
settings.status = 'ready'
|
||||
const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
|
||||
const { wrapper } = await mountCompose(server([far]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
const sentAt = Date.now()
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(toasts()[0]?.expiresAt).toBe(sentAt + 4_000)
|
||||
})
|
||||
|
||||
it('세션이 바뀌면(로그아웃) 알림을 닫는다 — 메일은 그대로 보내진다', async () => {
|
||||
const { wrapper, requests } = await mountCompose(server([undoReply()]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await submit(wrapper)
|
||||
expect(toasts()).toHaveLength(1)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toEqual([])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('[보내기 ▾] › 「예약 발송…」', () => {
|
||||
async function openSchedule(wrapper: VueWrapper) {
|
||||
await wrapper.get('.compose__send-more').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.get('[role="menu"] [role="menuitem"]')
|
||||
expect(item.text()).toBe('예약 발송…')
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('▾ 는 보내기와 같은 규칙으로 꺼진다 — 메뉴는 「예약 발송…」(시계) 하나', async () => {
|
||||
const { wrapper } = await mountCompose(server([]))
|
||||
const more = wrapper.get('.compose__send-more')
|
||||
expect(more.attributes('aria-label')).toBe('보내기 방법 더 보기')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(more.attributes('disabled')).toBeUndefined()
|
||||
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['예약 발송…'])
|
||||
expect(items[0]?.find('polyline').attributes('points')).toBe('12 6 12 12 16 14')
|
||||
})
|
||||
|
||||
it('받는 사람이 없으면 창을 열지 않고 받는 사람 칸에 알린다 (보내기와 같은 검사)', async () => {
|
||||
const { wrapper } = await mountCompose(server([]))
|
||||
|
||||
await openSchedule(wrapper)
|
||||
|
||||
expect(wrapper.find('[data-schedule-dialog]').exists()).toBe(true)
|
||||
expect(wrapper.get('#compose-schedule-title').element.closest('dialog')?.hasAttribute('open')).toBe(false)
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
|
||||
const preset = schedulePresets(NOW)[0]
|
||||
if (!preset) throw new Error('고르기가 없다')
|
||||
const sendAt = sendAtOf(preset.at)
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
server([ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'scheduled', sendAt } }, 201)]),
|
||||
true,
|
||||
)
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
|
||||
await openSchedule(wrapper)
|
||||
const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
expect((wrapper.get(`[data-schedule-preset="${preset.id}"]`).element as HTMLInputElement).checked).toBe(true)
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sends(requests).map((request) => (request.data as { sendAt?: string }).sendAt)).toEqual([sendAt])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'success',
|
||||
message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
|
||||
action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
|
||||
expiresAt: null,
|
||||
}),
|
||||
])
|
||||
expect(requests.some((request) => request.url === '/scheduled-messages' && request.params.limit === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('서버가 예약 시각을 받지 않으면(VAL_001) 알림 대신 예약 창을 다시 연다 — 그 시각을 직접 고르기에 채우고 고르기 아래에 까닭 (리뷰 #11)', async () => {
|
||||
const preset = schedulePresets(NOW)[0]
|
||||
if (!preset) throw new Error('고르기가 없다')
|
||||
const { wrapper, router, requests } = await mountCompose(server([fail(400, 'VAL_001')]), true)
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
|
||||
await openSchedule(wrapper)
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sends(requests)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
// 사전 문구 알림은 없다 — 창이 안내한다
|
||||
expect(toasts()).toEqual([])
|
||||
const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
// 이 PC 의 검사로는 맞는 시각이다(시계가 어긋남) — 가까운 쪽(5분) 한도로 알린다
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
|
||||
expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
|
||||
const chosen = new Date(preset.at)
|
||||
expect((wrapper.get('[data-schedule-day]').element as HTMLInputElement).value).toBe(formatDate(chosen))
|
||||
expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(
|
||||
String(chosen.getHours() * 60 + chosen.getMinutes()),
|
||||
)
|
||||
expect(wrapper.get('[data-schedule-day]').attributes('aria-describedby')).toBe('compose-schedule-error')
|
||||
|
||||
// 닫으면 까닭도 지운다 — 다시 열면 처음 값
|
||||
await wrapper.get('[data-schedule-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
await openSchedule(wrapper)
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('')
|
||||
expect((wrapper.get(`[data-schedule-preset="${preset.id}"]`).element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('예약 창의 [취소] 는 보내지 않는다 · [보내기]는 sendAt 없이 보낸다', async () => {
|
||||
const { wrapper, requests } = await mountCompose(server([ok({ id: 'sent-1', rejectedRecipients: [] }, 201)]))
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
|
||||
await openSchedule(wrapper)
|
||||
await wrapper.get('[data-schedule-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sends(requests)).toEqual([])
|
||||
|
||||
await submit(wrapper)
|
||||
expect(sends(requests).map((request) => request.data)).toEqual([expect.not.objectContaining({ sendAt: expect.anything() })])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1244,8 +1244,11 @@ describe('ComposePage', () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { wrapper, router, requests } = await mountCompose('/mail/compose', savable(replyApi))
|
||||
expect(wrapper.find('.compose__cancel').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.compose__footer a, .compose__footer > button').map((item) => item.text())).toEqual([
|
||||
// [보내기 ▾](9단계) — 보내기 · 보내기 방법(▾) · 임시 저장뿐이다
|
||||
const footerButtons = wrapper.findAll('.compose__footer a, .compose__footer > button, .compose__send-group > button')
|
||||
expect(footerButtons.map((item) => item.text() || item.attributes('aria-label'))).toEqual([
|
||||
'보내기',
|
||||
'보내기 방법 더 보기',
|
||||
'임시 저장',
|
||||
])
|
||||
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
|
||||
// 보내는 중에 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 결과를 알리지도 저장본으로 남기지도 않는다 — 앞 사용자의 받는 사람이
|
||||
// 로그인 화면 · 다른 계정에 보이거나 그 계정의 임시보관함에 들어가지 않게. 임시 저장을 기다리는 사이 바뀌었으면 보내지도 않는다.
|
||||
// 보내기 취소 · 예약 발송 (9단계 — 계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계"):
|
||||
// - 엔진이 발송을 붙잡아 두면(응답의 pendingSend) 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — 설정한 초(받아 둔 보내기 · 읽기 설정 —
|
||||
// 이 PC 시계가 서버와 어긋나도 늘어나지 않게, 모르면 그 시각 sendAt)에 닫히고 붙잡아도 멈추지 않는다 (pending-send.ts 의 undoToastDeadline).
|
||||
// 일부가 받지 못했으면 그 실패 알림("…받지 못합니다")에 [보내기 취소]. [보내기 취소]는 쓰기 화면을 그 메일로 다시 연다
|
||||
// (useScheduledMessageActions — 새 양식이라 새 발송 키)
|
||||
// - [보내기 ▾] › 「예약 발송…」 — 받는 사람 · 제목을 먼저 보고(보내기와 같은 검사) 예약 창(ComposeScheduleDialog)을 연다.
|
||||
// [예약]은 보내기와 같은 흐름(임시 저장 기다리기 · 길이 검사 · 같은 발송 키)에 sendAt 을 더해 보낸다 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기].
|
||||
// 서버가 그 시각을 받지 않으면(VAL_001 — 그사이 5분 안이 됐거나 시계가 어긋남) 알림 대신 예약 창을 다시 열고 고르기 아래에 까닭
|
||||
// ("지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." — 이 화면의 검사로 고른다, schedule-send.ts 의 rejectedScheduleProblem)
|
||||
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
|
||||
// 휴지통 #compose-discard · 닫기 .compose__close
|
||||
import {
|
||||
@@ -52,6 +61,7 @@ import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue
|
||||
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 RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import {
|
||||
MAIL_EDITOR_RETRY,
|
||||
@@ -68,6 +78,7 @@ import { useDraftTrash } from '@/composables/useDraftTrash'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions'
|
||||
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
import {
|
||||
@@ -84,6 +95,8 @@ import {
|
||||
type ComposeMode,
|
||||
} from '@/shared/constants/mail'
|
||||
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 type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
|
||||
import {
|
||||
@@ -93,6 +106,7 @@ import {
|
||||
} from '@/shared/utils/compose-changes'
|
||||
import { composePrefillOf } from '@/shared/utils/compose-prefill'
|
||||
import { isImageFile } from '@/shared/utils/editor-files'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
shouldRenewSendKey,
|
||||
@@ -111,7 +125,11 @@ 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 { 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'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -136,13 +154,21 @@ type ComposeSource = ComposeMode | 'draft'
|
||||
/** 서버가 받는 사람을 거부한 코드 — 받는 사람 칸 아래 띠로 안내한다 */
|
||||
type RecipientRejectionCode = Extract<ErrorCode, 'MAIL_002' | 'MAIL_010'>
|
||||
|
||||
/**
|
||||
* [보내기 취소] 알림이 남아 있을 수 있는 가장 긴 시간 — 설정의 가장 긴 값(30초). 설정한 초를 모를 때만 쓴다(undoToastDeadline).
|
||||
* 이 PC 의 시계가 서버보다 늦어 sendAt 이 멀리 보여도 알림이 그보다 오래 남지 않게 한다 (늦게 누르면 MAIL_014 — 인터셉터)
|
||||
*/
|
||||
const UNDO_SEND_MAX_MS = Math.max(...UNDO_SEND_OPTIONS) * 1000
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
const signatureStore = useMailSignatureStore()
|
||||
const { getDraft, getMessage, sendMessage } = useMail()
|
||||
const draftTrash = useDraftTrash()
|
||||
const scheduledActions = useScheduledMessageActions()
|
||||
const toast = useToast()
|
||||
const clock = useClock()
|
||||
const {
|
||||
@@ -186,6 +212,10 @@ const composeKind = ref<ComposeMode>('new')
|
||||
/** 발송 · 임시 저장의 inReplyTo — 답장할 메일 id (이어 쓰는 저장본은 저장본의 값) */
|
||||
const inReplyTo = ref<string | null>(null)
|
||||
const sending = ref<boolean>(false)
|
||||
/** 예약 창 (9단계) — ▾ › 「예약 발송…」 */
|
||||
const scheduleOpen = ref<boolean>(false)
|
||||
/** 서버가 받지 않은 예약 시각과 까닭 — 예약 창을 다시 열 때 고르기 아래에 보인다 (창을 닫으면 지운다) */
|
||||
const scheduleRejection = ref<ScheduleRejection | null>(null)
|
||||
/** 머리의 휴지통 — 저장본을 버리는 중 · 버렸다 (버렸으면 떠날 때 묻지도 저장하지도 않는다) */
|
||||
const discarding = ref<boolean>(false)
|
||||
const discarded = ref<boolean>(false)
|
||||
@@ -226,6 +256,8 @@ let formGeneration = 0
|
||||
let resetCount = 0
|
||||
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
|
||||
/** 예약 발송에서 이 화면이 직접 안내하는 코드 — 위에 더해 VAL_001(서버가 그 시각을 받지 않음 — 예약 창을 다시 연다) */
|
||||
const SCHEDULE_INLINE_CODES: readonly ErrorCode[] = [...SEND_INLINE_CODES, 'VAL_001']
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
@@ -711,11 +743,41 @@ function settleSendKey(outcome: SendOutcome) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — onSubmit). 받는 사람 일부가 받지 못했으면
|
||||
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — send). 받는 사람 일부가 받지 못했으면
|
||||
* "보냈습니다" 대신 오류 알림 모양(흰 카드)으로 받지 못한 사람을 적는다 — 링크는 같은 보낸편지함 (계약 2절 "받는 사람별 결과").
|
||||
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS)
|
||||
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS).
|
||||
* 엔진이 붙잡아 둔 발송(9단계 — pendingSend):
|
||||
* - 보내기 취소를 기다리는 중(undo): 단추가 [보내기 취소]이고 알림은 마감에 닫힌다 · 남은 시간 막대. 마감은 받아 둔 설정의 초(− 1초)로 센다 —
|
||||
* 이 PC 시계가 서버와 어긋나도 설정보다 오래 남지 않게. 설정을 모르면 그 시각(sendAt — 이 PC 시계로 30초를 넘지 않게).
|
||||
* 마감이 이미 지났으면(이 PC 시계가 서버보다 빠르다) 취소할 수 없으니 지금처럼 알린다
|
||||
* - 예약(scheduled): "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기] — 일부가 받지 못했으면 "예약했지만 … 받지 못합니다" + [예약됨 보기]
|
||||
*/
|
||||
function notifySent(result: SentMessage) {
|
||||
const pending = result.pendingSend
|
||||
if (pending?.kind === 'scheduled') {
|
||||
const scheduledLink = { label: SEND_LATER_TEXT.viewScheduled, to: { name: MAIL_SCHEDULED_ROUTE } }
|
||||
const partial = partialSendMessage(result.rejectedRecipients, { pendingKind: 'scheduled' })
|
||||
if (partial) toast.error(partial, scheduledLink)
|
||||
else toast.success(SEND_LATER_TEXT.scheduled(formatScheduleTime(pending.sendAt)), scheduledLink)
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const expiresAt = pending
|
||||
? undoToastDeadline({
|
||||
sendAt: pending.sendAt,
|
||||
now,
|
||||
undoSendSeconds: settingsStore.knownUndoSendSeconds,
|
||||
marginMs: UNDO_SEND_DEADLINE_MARGIN_MS,
|
||||
maxMs: UNDO_SEND_MAX_MS,
|
||||
})
|
||||
: Number.NaN
|
||||
if (pending && expiresAt > now) {
|
||||
const undo = { label: SEND_LATER_TEXT.undo, run: () => void scheduledActions.undoSend(result.id) }
|
||||
const partial = partialSendMessage(result.rejectedRecipients, { pendingKind: 'undo' })
|
||||
if (partial) toast.error(partial, undo, { expiresAt })
|
||||
else toast.success(MAIL_SENT_MESSAGE, undo, { expiresAt })
|
||||
return
|
||||
}
|
||||
const sentMailbox = mailStore.mailboxByRole('sent')
|
||||
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
|
||||
const partial = partialSendMessage(result.rejectedRecipients)
|
||||
@@ -734,24 +796,61 @@ function endSending(generation: number) {
|
||||
syncDraftUrl(generation)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
|
||||
/**
|
||||
* 보내기 전 검사 — 받는 사람(비었음 · 형식 · 100명 초과)과 제목 길이. 걸리면 그 칸에 안내하고 초점을 옮긴다(false).
|
||||
* [보내기]와 ▾ › 「예약 발송…」(예약 창을 열기 전)이 함께 쓴다. 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
|
||||
*/
|
||||
function precheckSend(): boolean {
|
||||
commitRecipientInputs()
|
||||
attempted.value = true
|
||||
rejectedByServer.value = null
|
||||
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
|
||||
toField.value?.focus()
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!canSend.value || !editorRef.value) return
|
||||
if (!canSend.value || !editorRef.value) return false
|
||||
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
|
||||
const subjectText = subject.value.trim()
|
||||
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
if (subject.value.trim().length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
lengthError.value = 'subject'
|
||||
subjectInputRef.value?.focus()
|
||||
return
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
void send()
|
||||
}
|
||||
|
||||
/** ▾ › 「예약 발송…」 — 보내기와 같은 검사를 먼저 하고 예약 창을 연다 */
|
||||
function openSchedule() {
|
||||
if (!precheckSend()) return
|
||||
scheduleRejection.value = null
|
||||
scheduleOpen.value = true
|
||||
}
|
||||
|
||||
/** 예약 창의 [예약] — 고른 시각(ms)으로 보낸다 */
|
||||
function onScheduleConfirm(at: number) {
|
||||
closeSchedule()
|
||||
void send(sendAtOf(at))
|
||||
}
|
||||
|
||||
function closeSchedule() {
|
||||
scheduleOpen.value = false
|
||||
scheduleRejection.value = null
|
||||
}
|
||||
|
||||
/** 서버가 예약 시각을 받지 않았다(VAL_001) — 예약 창을 다시 열고 고르기 아래에 까닭을 보인다 (까닭은 이 화면의 검사로 고른다) */
|
||||
function reopenSchedule(at: number) {
|
||||
scheduleRejection.value = { at, problem: rejectedScheduleProblem(at, clock.now()) }
|
||||
scheduleOpen.value = true
|
||||
}
|
||||
|
||||
/** 보내기 — sendAt 이 있으면 예약 발송 (9단계). 흐름은 같다 */
|
||||
async function send(sendAt?: string) {
|
||||
if (!precheckSend() || !editorRef.value) return
|
||||
const subjectText = subject.value.trim()
|
||||
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
|
||||
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
|
||||
const content = editorRef.value.getContentForSend()
|
||||
@@ -801,6 +900,8 @@ async function onSubmit() {
|
||||
return
|
||||
}
|
||||
const draftId = drafts.draftId.value
|
||||
/** 이 화면이 직접 안내하는 코드 — 인터셉터 알림을 끈다 (예약이면 VAL_001 도 — 예약 창을 다시 연다) */
|
||||
const inlineCodes = sendAt ? SCHEDULE_INLINE_CODES : SEND_INLINE_CODES
|
||||
const payload: SendMessagePayload = {
|
||||
...recipients,
|
||||
subject: subjectText,
|
||||
@@ -814,6 +915,8 @@ async function onSubmit() {
|
||||
...(replyTo ? { inReplyTo: replyTo } : {}),
|
||||
// 이어 쓴 저장본 — 보낸 뒤 서버가 지운다
|
||||
...(draftId ? { draftId } : {}),
|
||||
// 예약 발송 — 지금부터 5분 뒤 ~ 7일 안 (예약 창이 검사했다 · 서버도 같은 한도)
|
||||
...(sendAt ? { sendAt } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -823,12 +926,13 @@ async function onSubmit() {
|
||||
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
|
||||
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애, MAIL_011 받는 사람 모두 지금은 받을 수 없음(보내지 않았다).
|
||||
// REQ_001 · MAIL_011 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
|
||||
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: inlineCodes })
|
||||
if (sameSession()) {
|
||||
// 받는 사람 일부가 받지 못했어도 보낸 것이다 — 아래는 모두 받았을 때와 같고, 알림만 받지 못한 사람을 적는다
|
||||
notifySent(result)
|
||||
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
|
||||
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다 — 예약했으면 패널의 「예약됨」 수도
|
||||
mailStore.syncCounts()
|
||||
if (result.pendingSend?.kind === 'scheduled') void mailStore.loadScheduledCount()
|
||||
}
|
||||
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
|
||||
if (generation !== formGeneration) return
|
||||
@@ -845,7 +949,7 @@ async function onSubmit() {
|
||||
// 그 사람의 임시보관함에 이 메일이 들어가지 않게)
|
||||
if (!sameSession()) return
|
||||
// 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
|
||||
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
if (inlineCodes.includes(apiError.code)) notifyApiError(apiError)
|
||||
// 마지막 자동 저장 뒤에 쓴 글을 잃지 않게 보내려던 내용을 조용히 저장본으로 남긴다 (코드와 상관없이 — 알림은 위의 실패 알림뿐)
|
||||
void draftLink.keepUnsent(payload, session)
|
||||
return
|
||||
@@ -865,6 +969,9 @@ async function onSubmit() {
|
||||
} else if (code === 'MAIL_004') {
|
||||
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
|
||||
await guideMissingAttachments(markMissing())
|
||||
} else if (code === 'VAL_001' && sendAt) {
|
||||
// 서버가 예약 시각을 받지 않았다(그사이 5분 안이 됨 · 시계가 어긋남) — 알림 대신 예약 창을 다시 열어 고르게 한다
|
||||
reopenSchedule(Date.parse(sendAt))
|
||||
}
|
||||
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
|
||||
}
|
||||
@@ -1004,6 +1111,11 @@ watch(hasMissing, (missing) => {
|
||||
watch(allRecipients, () => {
|
||||
rejectedByServer.value = null
|
||||
})
|
||||
|
||||
// 보낼 수 없게 되면(보내는 중 · 양식을 새로 채움 · 첨부를 올리기 시작함) 예약 창을 닫는다. 세션이 바뀌어도 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
|
||||
if (!can || authStore.generation !== previousSession) closeSchedule()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1166,9 +1278,18 @@ watch(allRecipients, () => {
|
||||
:draft-saved-at="draftSavedAt"
|
||||
:sender="sender"
|
||||
@save-draft="onSaveDraft"
|
||||
@schedule="openSchedule"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- 예약 발송 창 (9단계) — [예약]이면 보내기와 같은 흐름에 sendAt 을 더한다 -->
|
||||
<ComposeScheduleDialog
|
||||
:open="scheduleOpen"
|
||||
:rejection="scheduleRejection"
|
||||
@confirm="onScheduleConfirm"
|
||||
@cancel="closeSchedule"
|
||||
/>
|
||||
|
||||
<!-- 저장하지 못한 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<ComposeLeaveDialog
|
||||
:open="leaveConfirming"
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
|
||||
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
|
||||
// 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」의 창은 여기에 둔다 — 메일이 바뀌어도 사라지지 않게 (useMailContactDialog)
|
||||
// 들어올 때 예약한 메일 수(패널의 「예약됨」) · 차단 목록(읽기 화면 ⋯ 의 차단 · 스팸메일함의 안내 줄)을 받아 둔다 (9단계 — 못 받으면 알리지 않는다).
|
||||
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
|
||||
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
|
||||
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
|
||||
import { provide } from 'vue'
|
||||
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
@@ -15,11 +19,15 @@ import { useClock } from '@/composables/useClock'
|
||||
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const clock = useClock()
|
||||
provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
|
||||
|
||||
// 하위 화면(목록·본문)보다 먼저 요청을 보낸다 — 메일함 개수가 본문 읽음 처리보다 늦게 도착하는 일을 줄인다
|
||||
@@ -30,18 +38,27 @@ void mailStore.ensureQuota()
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
|
||||
void useMailVacationStore().load()
|
||||
// 패널의 「예약됨」 — 예약한 메일이 있을 때만 그린다
|
||||
void mailStore.loadScheduledCount()
|
||||
// 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람의 메일" 줄
|
||||
useMailBlockStore().ensureLoaded()
|
||||
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다)
|
||||
useMailSettingsStore().ensureLoaded()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
|
||||
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
|
||||
useMailStatePolling({
|
||||
clock: useClock(),
|
||||
clock,
|
||||
onChange: () => {
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
void mailStore.loadScheduledCount()
|
||||
},
|
||||
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
|
||||
})
|
||||
useMailTabTitle()
|
||||
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
|
||||
usePendingSendRefresh({ clock })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import { routes } from '@/router/routes'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailPrintPage from './MailPrintPage.vue'
|
||||
import printPageSource from './MailPrintPage.vue?raw'
|
||||
|
||||
// 인쇄 화면 (디자인 원본 MailPrint · README "9단계 › 인쇄 화면" · 계약 2절 "인쇄") — 새 탭, 앱 틀 없이 메일 한 통
|
||||
// 인쇄 창(window.print)은 가짜로 바꿔 부른 횟수만 센다. 화면은 다음 그리기 차례(requestAnimationFrame)에 인쇄 창을 띄운다 —
|
||||
// 테스트가 그 차례를 손으로 돌린다(runFrames — 진짜 시간을 기다리지 않는다)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const MESSAGE: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: 'Re: 메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
cc: [{ name: '인프라팀', email: 'infra@arasystem.it.kr' }],
|
||||
bcc: [{ name: '숨은 사람', email: 'hidden@arasystem.it.kr' }],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: '<p>김아라님, 안녕하세요.</p>',
|
||||
textBody: '김아라님, 안녕하세요.',
|
||||
hasBlockedImages: false,
|
||||
imagesShown: false,
|
||||
attachments: [
|
||||
{ id: 'a1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49_152 },
|
||||
{ id: 'a2', name: '서명.png', type: 'image/png', size: 2_000, isInline: true },
|
||||
],
|
||||
}
|
||||
|
||||
describe('MailPrintPage', () => {
|
||||
let pinia: Pinia
|
||||
let printed: number
|
||||
/** 다음 그리기 차례를 기다리는 일 — runFrames 가 돌린다 */
|
||||
let frames: FrameRequestCallback[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession(USER)
|
||||
printed = 0
|
||||
frames = []
|
||||
vi.spyOn(window, 'print').mockImplementation(() => {
|
||||
printed += 1
|
||||
})
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(
|
||||
path: string,
|
||||
reply: (request: MockRequest) => MockResult = () => ok(MESSAGE),
|
||||
options: { stubFrame?: boolean } = {},
|
||||
) {
|
||||
const requests = installMockApi(reply)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailPrintPage, {
|
||||
attachTo: document.body,
|
||||
// 본문 틀을 가짜로 — jsdom 은 srcdoc 를 열자마자 높이를 재므로, 잰 때를 테스트가 정한다
|
||||
global: { plugins: [pinia, router], stubs: options.stubFrame ? { MailBodyFrame: true } : {} },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router, requests }
|
||||
}
|
||||
|
||||
/** 본문 틀이 높이를 다 쟀다고 알린다 (jsdom 은 배치를 하지 않는다) */
|
||||
async function measure(wrapper: VueWrapper) {
|
||||
wrapper.findComponent(MailBodyFrame).vm.$emit('measured', 1200)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 기다리는 그리기 차례를 모두 돌린다 — 돌리는 동안 새로 잡은 차례도 */
|
||||
async function runFrames() {
|
||||
await flushPromises()
|
||||
for (let round = 0; round < 5 && frames.length > 0; round += 1) {
|
||||
for (const callback of frames.splice(0)) callback(0)
|
||||
await flushPromises()
|
||||
}
|
||||
}
|
||||
|
||||
it('메일 한 통만 — 머리 줄(ARA SYSTEM 메일 · 인쇄한 직원) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜 · 첨부(이름 · 크기) — 숨은참조는 없다', async () => {
|
||||
const { wrapper, requests } = await mountAt('/mail/print/m1')
|
||||
|
||||
expect(requests.map((request) => [request.url, request.params])).toEqual([['/messages/m1', {}]])
|
||||
expect(wrapper.get('.print-sheet__brand').text()).toBe('ARA SYSTEM 메일김아라 <ara.kim@arasystem.it.kr>')
|
||||
expect(wrapper.get('#print-subject').text()).toBe('Re: 메일 서버 이전 일정 공유')
|
||||
const meta = wrapper.findAll('.print-sheet__meta dt').map((dt) => [dt.text(), dt.element.nextElementSibling?.textContent?.trim()])
|
||||
expect(meta).toEqual([
|
||||
['보낸 사람', '이도윤 <doyun.lee@arasystem.it.kr>'],
|
||||
['받는 사람', '김아라 <ara.kim@arasystem.it.kr>'],
|
||||
['참조', '인프라팀 <infra@arasystem.it.kr>'],
|
||||
['날짜', expect.stringMatching(/^2026년 10월 26일 \(월\) (오전|오후) \d{1,2}:42$/)],
|
||||
['첨부', '메일서버_전환_체크리스트.xlsx (48 KB)'],
|
||||
])
|
||||
expect(wrapper.text()).not.toContain('hidden@arasystem.it.kr')
|
||||
expect(wrapper.find('iframe').attributes('srcdoc')).toContain('김아라님, 안녕하세요.')
|
||||
expect(document.title).toBe('Re: 메일 서버 이전 일정 공유 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('본문 높이를 다 잰 뒤에 한 번 인쇄 창을 띄운다 — 다시 재도 또 띄우지 않고, [인쇄]는 언제든 다시 띄운다', async () => {
|
||||
const { wrapper } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
|
||||
const printButton = wrapper.get('[data-print-now]')
|
||||
expect(printButton.attributes('disabled')).toBeDefined()
|
||||
// 재기 전에는 그리기 차례를 다 돌려도 띄우지 않는다
|
||||
await runFrames()
|
||||
expect(printed).toBe(0)
|
||||
|
||||
await measure(wrapper)
|
||||
// 잰 높이가 그려진 다음 차례에 띄운다
|
||||
expect(printed).toBe(0)
|
||||
await runFrames()
|
||||
expect(printed).toBe(1)
|
||||
expect(printButton.attributes('disabled')).toBeUndefined()
|
||||
|
||||
// 다시 재고(폭이 바뀜 등) 그다음 차례까지 돌려도 또 띄우지 않는다
|
||||
await measure(wrapper)
|
||||
await runFrames()
|
||||
expect(printed).toBe(1)
|
||||
|
||||
await printButton.trigger('click')
|
||||
await runFrames()
|
||||
expect(printed).toBe(2)
|
||||
})
|
||||
|
||||
it('읽기 화면에서 외부 이미지를 보이고 있었으면(?images=show) 그대로 받아 본문 틀이 그림을 그린다', async () => {
|
||||
const shown = { ...MESSAGE, htmlBody: '<p>본문</p><img src="/api/mail-images/t0k">', imagesShown: true }
|
||||
const { wrapper, requests } = await mountAt('/mail/print/m1?images=show', () => ok(shown))
|
||||
|
||||
expect(requests[0]?.params).toEqual({ images: 'show' })
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
|
||||
})
|
||||
|
||||
it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
|
||||
const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
|
||||
|
||||
expect(wrapper.text()).toContain('메일을 찾을 수 없습니다')
|
||||
expect(wrapper.find('.print-sheet').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-print-now]').attributes('disabled')).toBeDefined()
|
||||
// 화면이 직접 그린다 — 알림은 띄우지 않는다
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('.print-page__state-action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
// 스크립트가 연 탭이 아니어서 닫히지 않았으면 메일 화면으로
|
||||
expect(router.currentRoute.value.name).toBe('mail')
|
||||
expect(printed).toBe(0)
|
||||
})
|
||||
|
||||
it('받지 못하면 사전 문구와 [다시 시도] — 인쇄 창은 띄우지 않고, 다시 받아 본문을 다 재면 그때 한 번 띄운다', async () => {
|
||||
let reply: MockResult = fail(503, 'MAIL_001')
|
||||
const { wrapper } = await mountAt('/mail/print/m1', () => reply, { stubFrame: true })
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
await runFrames()
|
||||
expect(printed).toBe(0)
|
||||
|
||||
reply = ok(MESSAGE)
|
||||
await wrapper.get('[data-print-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#print-subject').text()).toBe('Re: 메일 서버 이전 일정 공유')
|
||||
await runFrames()
|
||||
expect(printed).toBe(0)
|
||||
|
||||
await measure(wrapper)
|
||||
await runFrames()
|
||||
expect(printed).toBe(1)
|
||||
})
|
||||
|
||||
it('인쇄 창을 기다리는 사이 화면을 내리면 띄우지 않는다', async () => {
|
||||
const { wrapper } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
|
||||
|
||||
await measure(wrapper)
|
||||
expect(frames).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
await runFrames()
|
||||
|
||||
expect(printed).toBe(0)
|
||||
})
|
||||
|
||||
it('인쇄 창을 기다리는 사이 다른 메일로 바뀌면(다시 받음) 옛 차례는 띄우지 않는다', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
|
||||
|
||||
await measure(wrapper)
|
||||
await router.push('/mail/print/m2')
|
||||
await flushPromises()
|
||||
await runFrames()
|
||||
|
||||
expect(printed).toBe(0)
|
||||
})
|
||||
|
||||
it('[닫기] 는 이 탭을 닫는다', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
|
||||
const { wrapper } = await mountAt('/mail/print/m1')
|
||||
|
||||
await wrapper.get('[data-print-close]').trigger('click')
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('인쇄 폭 — 화면의 종이 안쪽 폭(210mm − 좌우 여백)과 인쇄 쪽의 안쪽 폭(@page A4 − 여백)이 같다 (2026-10-29 빈 쪽 10장 회귀)', () => {
|
||||
const css = printPageSource.slice(printPageSource.indexOf('<style'))
|
||||
const page = /@page\s*\{\s*size:\s*A4;\s*margin:\s*(\d+)mm\s+(\d+)mm;\s*\}/.exec(css)
|
||||
const sheet = /\.print-sheet\s*\{[^}]*?width:\s*210mm;\s*padding:\s*(\d+)mm\s+(\d+)mm;/.exec(css)
|
||||
expect(page).not.toBeNull()
|
||||
expect(sheet).not.toBeNull()
|
||||
// 좌우 여백이 같다 → 화면에서 잰 본문 줄바꿈 · 높이가 인쇄에서도 같다
|
||||
expect(sheet?.[2]).toBe(page?.[2])
|
||||
expect(sheet?.[1]).toBe(page?.[1])
|
||||
// 인쇄에서는 종이 폭 · 여백을 쪽(@page)에 맡긴다 · 화면에만 보이는 줄은 숨긴다
|
||||
const print = css.slice(css.indexOf('@media print'))
|
||||
expect(print).toMatch(/\.print-sheet\s*\{[^}]*width:\s*auto;\s*padding:\s*0;/)
|
||||
expect(print).toMatch(/\.print-bar\s*\{\s*display:\s*none;/)
|
||||
})
|
||||
|
||||
it('앱의 라우트 표 — /mail/print/<id> 는 메일 레이아웃(앱 틀) 밖의 인쇄 화면 · 로그인 필요, /mail/scheduled 는 예약됨 (메일함 id 로 읽지 않는다)', () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
|
||||
const print = router.resolve('/mail/print/m1?images=show')
|
||||
expect(print.name).toBe('mail-print')
|
||||
expect(print.matched).toHaveLength(1)
|
||||
expect(print.meta).toMatchObject({ requiresAuth: true, title: '메일 인쇄' })
|
||||
expect(print.query).toEqual({ images: 'show' })
|
||||
|
||||
expect(router.resolve('/mail/scheduled').name).toBe('mail-scheduled')
|
||||
expect(router.resolve('/mail/scheduled/s1').name).toBe('mail-scheduled-message')
|
||||
expect(router.resolve('/mail/scheduled').meta.title).toBe('예약됨')
|
||||
// 다른 메일함 · 메일 주소는 그대로
|
||||
expect(router.resolve('/mail/inbox/m1').name).toBe('mail-message')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,462 @@
|
||||
<script setup lang="ts">
|
||||
// 인쇄 화면 (디자인 원본 MailPrint · README "9단계 › 인쇄 화면" · 계약 2절 "인쇄") — /mail/print/:messageId (?images=show), 새 탭
|
||||
// 앱 틀(레일 · 메일함 · 목록 · 도구 줄) 없이 메일 한 통만 그린다. 읽기 화면 ⋯ › 인쇄 · Ctrl+P 가 연다(useMailPrint).
|
||||
// - 화면에만 보이는 위 줄: "인쇄 화면" + [인쇄](다시 띄우기) [닫기](탭 닫기). 인쇄 창을 닫아도 탭은 남는다.
|
||||
// - 인쇄하는 것: 머리 줄(「ARA SYSTEM 메일」 · 인쇄한 직원의 이름 <주소>) → 제목 → 보낸 사람 · 받는 사람 · 참조 · 날짜(숨은참조는 넣지 않는다) →
|
||||
// 첨부(이름 · 크기만) → 가는 선 → 본문(읽기 화면과 같은 정화 — MailBodyFrame). 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따른다.
|
||||
// 탭 제목은 메일 제목 — 브라우저가 인쇄 머리글 · PDF 파일 이름에 쓴다.
|
||||
// - 본문은 인쇄 폭(A4 — @page)과 같은 폭의 종이 위에서 높이를 잰다 — 화면에서 잰 높이가 인쇄에서도 맞아야 긴 메일이 여러 쪽으로 이어지고
|
||||
// 끝에 빈 쪽이 생기지 않는다 (2026-10-29 확인: 앱 화면을 그대로 인쇄하면 앱 틀까지 찍히고, 화면 폭에서 잰 본문 높이 때문에 끝에 빈 쪽이 10장 생겼다.
|
||||
// Edge/Chromium 은 내용 높이로 키운 iframe 을 여러 쪽으로 이어 인쇄한다 — 58쪽 PDF 로 확인).
|
||||
// 종이 폭(210mm)과 인쇄 여백(@page margin)은 아래 CSS 가 함께 정한다 — 둘을 함께 고친다.
|
||||
// - 본문을 다 재면(그림까지 받은 뒤 — iframe 의 load) 글꼴을 기다려 한 번 인쇄 창을 띄운다. [인쇄]는 언제든 다시 띄운다.
|
||||
// - 못 열 때: 메일을 찾을 수 없으면(RES_001) "메일을 찾을 수 없습니다" + [닫기], 받지 못하면 사전 문구 + [다시 시도] — 인쇄 창은 띄우지 않는다.
|
||||
// 세션이 끝났으면 로그인 화면으로 (라우터 가드 · 세션 만료 대화상자)
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { MAIL_IMAGES_PARAM, MAIL_IMAGES_SHOW } from '@/shared/constants/mail'
|
||||
import { MAIL_PRINT_TEXT } from '@/shared/constants/mail-print'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { formatFileSize, formatPrintDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
type PrintStatus = 'loading' | 'ready' | 'notFound' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { getMessage } = useMail()
|
||||
|
||||
const message = ref<MessageDetail | null>(null)
|
||||
const status = ref<PrintStatus>('loading')
|
||||
const loadError = ref<ApiError | null>(null)
|
||||
/** 본문 높이를 다 쟀다 — 그 전에는 인쇄하지 않는다 (재기 전의 iframe 은 최소 높이뿐이다) */
|
||||
const measured = ref<boolean>(false)
|
||||
|
||||
/** 처음 다 그렸을 때 한 번만 저절로 인쇄 창을 띄운다 */
|
||||
let printedOnce = false
|
||||
let loadSeq = 0
|
||||
/** 화면을 내렸다 — 기다리던 인쇄 창을 띄우지 않는다 */
|
||||
let unmounted = false
|
||||
|
||||
const messageId = computed(() => {
|
||||
const value = route.params.messageId
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
})
|
||||
/** 읽기 화면에서 외부 이미지를 보이고 있었다 */
|
||||
const showImages = computed(() => route.query[MAIL_IMAGES_PARAM] === MAIL_IMAGES_SHOW)
|
||||
const printer = computed(() => (authStore.user ? formatAddress(authStore.user) : ''))
|
||||
const subject = computed(() => message.value?.subject || MAIL_PRINT_TEXT.noSubject)
|
||||
const from = computed(() => (message.value?.from ? formatAddress(message.value.from) : MAIL_PRINT_TEXT.noSender))
|
||||
const attachments = computed(() =>
|
||||
message.value
|
||||
? listedAttachments(message.value.attachments).map((item) =>
|
||||
MAIL_PRINT_TEXT.attachment(item.name, formatFileSize(item.size)),
|
||||
)
|
||||
: [],
|
||||
)
|
||||
|
||||
async function load() {
|
||||
const id = messageId.value
|
||||
loadSeq += 1
|
||||
const seq = loadSeq
|
||||
measured.value = false
|
||||
message.value = null
|
||||
loadError.value = null
|
||||
if (!id) {
|
||||
status.value = 'notFound'
|
||||
return
|
||||
}
|
||||
status.value = 'loading'
|
||||
try {
|
||||
// 화면이 상태를 직접 그린다 — 세션 만료 대화상자는 silent 와 상관없이 뜬다 (useApi)
|
||||
const detail = await getMessage(id, { silent: true, showImages: showImages.value })
|
||||
if (seq !== loadSeq) return
|
||||
message.value = detail
|
||||
status.value = 'ready'
|
||||
document.title = documentTitle(detail.subject || MAIL_PRINT_TEXT.noSubject)
|
||||
} catch (error) {
|
||||
if (seq !== loadSeq) return
|
||||
const apiError = toApiError(error)
|
||||
status.value = apiError.code === 'RES_001' ? 'notFound' : 'error'
|
||||
loadError.value = apiError
|
||||
}
|
||||
}
|
||||
|
||||
/** 인쇄할 수 있는 때 — 메일을 받았고 본문 높이를 다 쟀다 */
|
||||
function canPrint(): boolean {
|
||||
return !unmounted && status.value === 'ready' && measured.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 글꼴까지 다 받은 뒤 그린 대로 인쇄 창을 띄운다 — 높이를 잰 값이 그려진 다음(다음 프레임)에.
|
||||
* 기다리는 사이 화면을 내렸거나 다시 받기 시작했으면 띄우지 않는다
|
||||
*/
|
||||
async function printNow() {
|
||||
if (!canPrint()) return
|
||||
const seq = loadSeq
|
||||
await nextTick()
|
||||
await document.fonts?.ready
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
if (canPrint() && seq === loadSeq) window.print()
|
||||
}
|
||||
|
||||
function onMeasured() {
|
||||
measured.value = true
|
||||
if (printedOnce) return
|
||||
printedOnce = true
|
||||
void printNow()
|
||||
}
|
||||
|
||||
/** [닫기] — 이 탭을 닫는다. 스크립트가 연 탭이 아니어서 닫히지 않으면(주소로 바로 들어옴 등) 메일 화면으로 */
|
||||
function close() {
|
||||
window.close()
|
||||
if (!window.closed) void router.replace({ name: 'mail' })
|
||||
}
|
||||
|
||||
watch([messageId, showImages], () => void load(), { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="print-page">
|
||||
<div
|
||||
class="print-bar"
|
||||
role="toolbar"
|
||||
:aria-label="MAIL_PRINT_TEXT.bar"
|
||||
>
|
||||
<span class="print-bar__title">{{ MAIL_PRINT_TEXT.bar }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="print-bar__print"
|
||||
:disabled="status !== 'ready' || !measured"
|
||||
data-print-now
|
||||
@click="printNow"
|
||||
>
|
||||
<AppIcon
|
||||
name="printer"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ MAIL_PRINT_TEXT.print }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="print-bar__close"
|
||||
data-print-close
|
||||
@click="close"
|
||||
>
|
||||
{{ MAIL_PRINT_TEXT.close }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main
|
||||
class="print-page__main"
|
||||
:aria-busy="status === 'loading'"
|
||||
:aria-label="status === 'ready' ? subject : MAIL_PRINT_TEXT.bar"
|
||||
>
|
||||
<article
|
||||
v-if="status === 'ready' && message"
|
||||
class="print-sheet"
|
||||
aria-labelledby="print-subject"
|
||||
>
|
||||
<div class="print-sheet__brand">
|
||||
<span>{{ MAIL_PRINT_TEXT.brand }}</span>
|
||||
<span v-if="printer">{{ printer }}</span>
|
||||
</div>
|
||||
<h1
|
||||
id="print-subject"
|
||||
class="print-sheet__subject"
|
||||
>
|
||||
{{ subject }}
|
||||
</h1>
|
||||
<dl class="print-sheet__meta">
|
||||
<dt>{{ MAIL_PRINT_TEXT.from }}</dt>
|
||||
<dd>{{ from }}</dd>
|
||||
<template v-if="message.to.length > 0">
|
||||
<dt>{{ MAIL_PRINT_TEXT.to }}</dt>
|
||||
<dd>{{ formatAddressList(message.to) }}</dd>
|
||||
</template>
|
||||
<template v-if="message.cc.length > 0">
|
||||
<dt>{{ MAIL_PRINT_TEXT.cc }}</dt>
|
||||
<dd>{{ formatAddressList(message.cc) }}</dd>
|
||||
</template>
|
||||
<dt>{{ MAIL_PRINT_TEXT.date }}</dt>
|
||||
<dd>
|
||||
<time :datetime="message.receivedAt">{{ formatPrintDateTime(message.receivedAt) }}</time>
|
||||
</dd>
|
||||
<template v-if="attachments.length > 0">
|
||||
<dt>{{ MAIL_PRINT_TEXT.attachments }}</dt>
|
||||
<dd>
|
||||
<span
|
||||
v-for="(attachment, index) in attachments"
|
||||
:key="index"
|
||||
class="print-sheet__attachment"
|
||||
>{{ attachment }}</span>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<hr class="print-sheet__rule">
|
||||
<MailBodyFrame
|
||||
class="print-sheet__body"
|
||||
:html-body="message.htmlBody"
|
||||
:text-body="message.textBody"
|
||||
:images-shown="message.imagesShown === true"
|
||||
:title="`메일 본문: ${subject}`"
|
||||
@measured="onMeasured"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="status === 'notFound'"
|
||||
icon="mail"
|
||||
:title="MAIL_PRINT_TEXT.notFound"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="print-page__state-action"
|
||||
@click="close"
|
||||
>
|
||||
{{ MAIL_PRINT_TEXT.close }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="status === 'error' && loadError"
|
||||
tone="danger"
|
||||
icon="alert-circle"
|
||||
:title="loadError.message"
|
||||
:trace-id="loadError.traceId"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="print-page__state-action"
|
||||
data-print-retry
|
||||
@click="load"
|
||||
>
|
||||
{{ MAIL_PRINT_TEXT.retry }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="print-page__loading"
|
||||
>
|
||||
{{ MAIL_PRINT_TEXT.loading }}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 종이 — A4(210mm) · 인쇄 여백 16mm 15mm. 화면의 종이 폭(안쪽 180mm)과 인쇄의 쪽 폭(@page — 210mm 에서 여백을 뺀 180mm)이 같아야
|
||||
* 화면에서 잰 본문 높이가 인쇄에서도 맞는다 (위 머리말). 인쇄 단위라 rem 이 아니라 mm 로 적는다 — 두 값을 함께 고친다.
|
||||
*/
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 16mm 15mm;
|
||||
}
|
||||
|
||||
.print-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-skeleton);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 화면에만 보이는 위 줄 (디자인 원본 MailPrint — 흰 바탕 · 아래 선 · 높이 52) */
|
||||
.print-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.print-bar__title {
|
||||
flex: 1 1 auto;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.print-bar__print,
|
||||
.print-bar__close,
|
||||
.print-page__state-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.print-bar__print {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.print-bar__print:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.print-bar__print:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.print-bar__close {
|
||||
padding: 0 var(--space-12);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.print-bar__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.print-page__state-action {
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.print-page__state-action:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 종이가 화면보다 넓으면 가로로 넘긴다 — 폭을 줄이면 잰 높이가 인쇄와 달라진다 */
|
||||
.print-page__main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-28) var(--space-16) var(--space-36);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.print-page__loading {
|
||||
margin: var(--space-80) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.print-sheet {
|
||||
flex: 0 0 auto;
|
||||
width: 210mm;
|
||||
padding: 16mm 15mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-toast);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.print-sheet__brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4) var(--space-12);
|
||||
padding-bottom: var(--space-8);
|
||||
border-bottom: var(--border-width) solid var(--color-border-strong);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.print-sheet__subject {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-size: var(--font-size-heading);
|
||||
line-height: var(--line-height-heading);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 보낸 사람 · 받는 사람 · 참조 · 날짜 · 첨부 — 이름 칸 72 */
|
||||
.print-sheet__meta {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
gap: var(--space-3) var(--space-12);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.print-sheet__meta dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.print-sheet__meta dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.print-sheet__attachment {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.print-sheet__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: var(--space-2) 0;
|
||||
border: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 본문 — 종이 폭을 다 쓴다(읽기 화면의 글줄 최대 폭을 쓰지 않는다 — 인쇄 폭이 곧 글줄 폭이다) */
|
||||
.print-sheet .print-sheet__body {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-page {
|
||||
min-height: 0;
|
||||
display: block;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.print-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.print-page__main {
|
||||
display: block;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/*
|
||||
* 쪽의 안쪽 폭(@page 여백을 뺀 180mm)이 화면의 종이 안쪽 폭과 같다. 블록으로 쌓는다 — 긴 본문(iframe)이 쪽을 넘어 이어진다
|
||||
* (줄 사이는 화면의 간격 14 를 바깥 여백으로)
|
||||
*/
|
||||
.print-sheet {
|
||||
display: block;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.print-sheet > * + * {
|
||||
margin-top: var(--space-14);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatScheduleShortTime, formatScheduleTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
// 예약됨 (9단계 — 디자인 원본 MailSendLater · README "9단계 › 「예약됨」 · 보낸편지함") — /mail/scheduled · /mail/scheduled/<id>
|
||||
// 목록(받는 사람 · 제목 · 보낼 시각 — 고르는 칸 · 별 없음) · 메일함 패널의 「예약됨」 · 보낸편지함의 "예약 · …" · "보내는 중"
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const FIRST_SEND_AT = '2026-10-30T00:00:00.000Z'
|
||||
const SECOND_SEND_AT = '2026-11-02T00:00:00.000Z'
|
||||
|
||||
function scheduled(id: string, sendAt: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['sent'],
|
||||
from: USER,
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
|
||||
subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-29T05:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: { kind: 'scheduled', sendAt },
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const S1 = scheduled('s1', FIRST_SEND_AT)
|
||||
const S2 = scheduled('s2', SECOND_SEND_AT, {
|
||||
subject: '[공지] 메일 서버 전환 완료 안내',
|
||||
to: [
|
||||
{ name: '인프라팀', email: 'infra@arasystem.it.kr' },
|
||||
{ name: null, email: 'a@arasystem.it.kr' },
|
||||
{ name: null, email: 'b@arasystem.it.kr' },
|
||||
],
|
||||
})
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 5 },
|
||||
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 1, totalCount: 3, unreadCount: 0 },
|
||||
{ id: 'drafts', name: '임시보관함', role: 'drafts', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
|
||||
{ id: 'custom', name: '거래처', role: null, parentId: null, sortOrder: 3, totalCount: 0, unreadCount: 0 },
|
||||
]
|
||||
|
||||
function detailOf(summary: MessageSummary): MessageDetail {
|
||||
return {
|
||||
...summary,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('예약됨 · 보낸편지함 (9단계)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/scheduled-messages') {
|
||||
const items = request.params.limit === 1 ? [S1] : [S1, S2]
|
||||
return ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
}
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
|
||||
if (request.url === '/messages') {
|
||||
const sending = scheduled('u1', '2026-10-29T06:00:10.000Z', { pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } })
|
||||
const plain = scheduled('p1', FIRST_SEND_AT, { pendingSend: null })
|
||||
return ok({ items: [S1, sending, plain], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
}
|
||||
if (request.url.startsWith('/messages/')) return ok(detailOf(S1))
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
await useMailStore().loadMailboxes()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
function rows(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('.scheduled-row').map((row) => ({
|
||||
who: row.get('.scheduled-row__who').text(),
|
||||
subject: row.get('.scheduled-row__subject').text(),
|
||||
time: row.get('.scheduled-row__time').text(),
|
||||
href: row.get('a').attributes('href'),
|
||||
}))
|
||||
}
|
||||
|
||||
it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각 차례"', async () => {
|
||||
const { wrapper } = await mountAt('/mail/scheduled')
|
||||
|
||||
expect(requests.find((request) => request.url === '/scheduled-messages')?.params).toEqual({ limit: 20 })
|
||||
expect(wrapper.get('#scheduled-list-title').text()).toBe('예약됨')
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각 차례')
|
||||
expect(rows(wrapper)).toEqual([
|
||||
{
|
||||
who: '받는 사람: 이도윤',
|
||||
subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
|
||||
time: formatScheduleTime(FIRST_SEND_AT),
|
||||
href: '/mail/scheduled/s1',
|
||||
},
|
||||
{
|
||||
who: '받는 사람: 인프라팀 외 2명',
|
||||
subject: '[공지] 메일 서버 전환 완료 안내',
|
||||
time: formatScheduleTime(SECOND_SEND_AT),
|
||||
href: '/mail/scheduled/s2',
|
||||
},
|
||||
])
|
||||
// 고르는 칸 · 별 · 알림 권하기 줄은 없다
|
||||
expect(wrapper.find('.mail-checkbox').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="중요 표시"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.reader').exists()).toBe(true)
|
||||
// 패널의 「예약됨」 수도 이 목록의 total 로 맞춘다
|
||||
expect(useMailStore().scheduledCount).toBe(2)
|
||||
})
|
||||
|
||||
it('목록 보기 — 한 줄 세 칸 · 메일을 열면 본문을 넓게(예약한 메일 — 청록 안내 줄)', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
const { wrapper } = await mountAt('/mail/scheduled')
|
||||
expect(wrapper.findAll('.scheduled-row--list')).toHaveLength(2)
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
|
||||
const opened = await mountAt('/mail/scheduled/s1')
|
||||
expect(opened.wrapper.find('.scheduled-row').exists()).toBe(false)
|
||||
expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)} 에 보낼 메일입니다.`)
|
||||
expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
|
||||
})
|
||||
|
||||
it('보낸편지함 — 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · 보내기 취소를 기다리는 동안 "보내는 중"', async () => {
|
||||
const { wrapper } = await mountAt('/mail/sent')
|
||||
|
||||
const times = wrapper.findAll('.message-row__time')
|
||||
expect(times.map((time) => time.text())).toEqual([
|
||||
`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`,
|
||||
'보내는 중',
|
||||
expect.not.stringContaining('예약'),
|
||||
])
|
||||
expect(times.map((time) => time.classes().includes('is-pending'))).toEqual([true, true, false])
|
||||
})
|
||||
|
||||
it('보낸편지함(목록 보기) — 날짜 칸도 같은 글, 칸보다 길면 전체를 title 로', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
const { wrapper } = await mountAt('/mail/sent')
|
||||
|
||||
const dates = wrapper.findAll('td.mail-table__date')
|
||||
expect(dates[0]?.text()).toBe(`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`)
|
||||
expect(dates[0]?.attributes('title')).toBe(`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`)
|
||||
expect(dates[1]?.text()).toBe('보내는 중')
|
||||
expect(dates[2]?.attributes('title')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('메일함 패널의 「예약됨」 — 예약한 메일이 있을 때만 보낸편지함 아래 (시계 · 통 수)', () => {
|
||||
async function mountPanel(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
await useMailStore().loadMailboxes()
|
||||
const wrapper = mount(MailboxPanel, { global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const labels = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('.mailbox-tree__list').at(0)?.findAll('li').map((item) => item.text()) ?? []
|
||||
|
||||
it('없으면 그리지 않고, 생기면 보낸편지함 바로 아래 — 오른쪽에 통 수', async () => {
|
||||
const wrapper = await mountPanel('/mail/inbox')
|
||||
expect(wrapper.find('[data-scheduled-mailbox]').exists()).toBe(false)
|
||||
|
||||
await useMailStore().loadScheduledCount()
|
||||
await flushPromises()
|
||||
|
||||
expect(labels(wrapper)).toEqual(['받은편지함안 읽음 5', '보낸편지함', '예약됨2통', '임시보관함'])
|
||||
const link = wrapper.get('[data-scheduled-mailbox]')
|
||||
expect(link.attributes('href')).toBe('/mail/scheduled')
|
||||
expect(link.find('polyline').attributes('points')).toBe('12 6 12 12 16 14')
|
||||
expect(link.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('예약됨을 보고 있으면 지금 메일함으로 표시한다 — 다른 메일함은 표시하지 않는다', async () => {
|
||||
useMailStore().openScheduled()
|
||||
const wrapper = await mountPanel('/mail/scheduled')
|
||||
|
||||
expect(wrapper.get('[data-scheduled-mailbox]').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.findAll('[aria-current="page"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('예약됨을 보는 동안은 0통이 되어도 줄이 남는다(숫자 없이) — 떠나면 사라진다 (리뷰 #12)', async () => {
|
||||
const store = useMailStore()
|
||||
const wrapper = await mountPanel('/mail/scheduled')
|
||||
store.scheduledCount = 0
|
||||
await flushPromises()
|
||||
|
||||
const link = wrapper.get('[data-scheduled-mailbox]')
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
expect(link.find('.scheduled-mailbox__count').exists()).toBe(false)
|
||||
|
||||
const router = wrapper.vm.$router
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-scheduled-mailbox]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 · 검색 결과 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?…)
|
||||
// 메일함 · 검색 결과 · 예약됨 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?… ·
|
||||
// /mail/scheduled · /mail/scheduled/:messageId — 9단계)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·검색·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main · Search)
|
||||
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
|
||||
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
|
||||
// 예약됨(9단계)은 고르기 · 여러 통 처리가 없는 목록(ScheduledMessageList — 받는 사람 · 제목 · 보낼 시각)을 쓰고, 연 메일은 같은 본문이다
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -14,7 +16,9 @@ import MessageList from '@/components/mail/MessageList.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
@@ -28,11 +32,13 @@ function paramOf(value: string | string[] | undefined): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
const mailboxId = computed(() => (isSearching.value ? null : paramOf(route.params.mailboxId)))
|
||||
/** 예약됨 · 예약됨에서 연 메일 (9단계) */
|
||||
const isScheduled = computed(() => isScheduledRouteName(route.name))
|
||||
const mailboxId = computed(() => (isSearching.value || isScheduled.value ? null : paramOf(route.params.mailboxId)))
|
||||
const messageId = computed(() => paramOf(route.params.messageId))
|
||||
const isListView = computed(() => viewStore.mode === 'list')
|
||||
/** 목록이 있는 화면 — 메일함 또는 검색 결과 */
|
||||
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value)
|
||||
/** 목록이 있는 화면 — 메일함 · 검색 결과 · 예약됨 */
|
||||
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value || isScheduled.value)
|
||||
|
||||
watch(
|
||||
mailboxId,
|
||||
@@ -42,6 +48,15 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 예약됨에 들어올 때 — 목록에서 메일을 열고 닫는 것으로는 다시 받지 않는다 (같은 목록으로 돌아오면 보이던 채로 새로 고친다)
|
||||
watch(
|
||||
isScheduled,
|
||||
(scheduled) => {
|
||||
if (scheduled) void mailStore.openScheduled()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
@@ -98,11 +113,19 @@ watch(
|
||||
<ListReadNav :message-id="messageId" />
|
||||
</template>
|
||||
</MessageReader>
|
||||
<ScheduledMessageList
|
||||
v-else-if="isScheduled"
|
||||
variant="list"
|
||||
/>
|
||||
<MessageTable v-else />
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasList">
|
||||
<MessageList />
|
||||
<ScheduledMessageList
|
||||
v-if="isScheduled"
|
||||
variant="split"
|
||||
/>
|
||||
<MessageList v-else />
|
||||
<MessageReader />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
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 { BlockedSender } from '@/shared/types/mail'
|
||||
import { formatBlockedDay } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailBlocksPage from './MailBlocksPage.vue'
|
||||
import blocksPageSource from './MailBlocksPage.vue?raw'
|
||||
|
||||
// 설정 › 메일 › 차단한 주소 (디자인 원본 SettingsSendRead · 계약 2절 "보낸 사람 차단")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const BLOCKS: BlockedSender[] = [
|
||||
{ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T01:00:00.000Z' },
|
||||
{ address: '@promo-mail.example', blockedAt: '2026-10-21T01:00:00.000Z' },
|
||||
{ address: 'sales@unknown-shop.example', blockedAt: '2026-10-18T01:00:00.000Z' },
|
||||
]
|
||||
|
||||
interface Server {
|
||||
blocks?: BlockedSender[] | MockResult
|
||||
/** 엔진에서 우리 규칙이 켜져 있는가 (처음 true) */
|
||||
isActive?: boolean
|
||||
add?: (request: MockRequest) => MockResult
|
||||
remove?: (request: MockRequest) => MockResult
|
||||
}
|
||||
|
||||
describe('MailBlocksPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'GET') {
|
||||
const blocks = server.blocks ?? BLOCKS
|
||||
return Array.isArray(blocks) ? ok({ senders: blocks, isActive: server.isActive ?? true }) : blocks
|
||||
}
|
||||
if (request.method === 'POST') {
|
||||
if (server.add) return server.add(request)
|
||||
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-10-29T06:00:00.000Z' }, 201)
|
||||
}
|
||||
if (server.remove) return server.remove(request)
|
||||
return ok({ address: decodeURIComponent(request.url.slice('/mail-blocks/'.length)) })
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/blocks')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailBlocksPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const rows = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('.blocks-page__row').map((row) => [row.get('.blocks-page__address').text(), row.get('.blocks-page__day').text()])
|
||||
const input = (wrapper: VueWrapper) => wrapper.get('#mail-block-input')
|
||||
const message = (wrapper: VueWrapper) => wrapper.get('[data-block-message]').text()
|
||||
|
||||
async function add(wrapper: VueWrapper, value: string) {
|
||||
await input(wrapper).setValue(value)
|
||||
await wrapper.get('[data-block-add]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('머리 "차단한 주소" + "3 / 500" · 목록(주소 · "10월 29일 차단" | "도메인 전체 · …") · 칸 · [차단 풀기]', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-blocks'])
|
||||
expect(wrapper.get('#blocks-page-title').text()).toBe('차단한 주소')
|
||||
expect(wrapper.get('[data-block-count]').text()).toBe('3 / 500')
|
||||
expect(rows(wrapper)).toEqual([
|
||||
['news@cloudbrief.example', `${formatBlockedDay(BLOCKS[0]?.blockedAt ?? '')} 차단`],
|
||||
['@promo-mail.example', `도메인 전체 · ${formatBlockedDay(BLOCKS[1]?.blockedAt ?? '')} 차단`],
|
||||
['sales@unknown-shop.example', `${formatBlockedDay(BLOCKS[2]?.blockedAt ?? '')} 차단`],
|
||||
])
|
||||
expect(input(wrapper).attributes()).toMatchObject({
|
||||
placeholder: 'kim@example.com 또는 @example.com',
|
||||
inputmode: 'email',
|
||||
type: 'text',
|
||||
})
|
||||
expect(wrapper.get('label[for="mail-block-input"]').text()).toBe('차단할 주소')
|
||||
expect(wrapper.findAll('[data-block-remove]').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'news@cloudbrief.example 차단 풀기',
|
||||
'@promo-mail.example 차단 풀기',
|
||||
'sales@unknown-shop.example 차단 풀기',
|
||||
])
|
||||
})
|
||||
|
||||
it('칸 아래 안내(role=status)는 비어 있어도 그려 둔다 — 영역이 늘 있어야 차단 성공 글을 스크린리더가 읽는다 (리뷰 #8)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const region = wrapper.get('[data-block-message]')
|
||||
|
||||
expect(region.attributes('role')).toBe('status')
|
||||
expect(region.text()).toBe('')
|
||||
// 빈 동안 자리를 숨기는 규칙은 display:none 이 아니다 (영역째 접근성 트리에서 빠진다)
|
||||
const css = blocksPageSource.slice(blocksPageSource.indexOf('<style'))
|
||||
const empty = /\.blocks-page__add \.form-message:empty\s*\{([^}]*)\}/.exec(css)
|
||||
expect(empty?.[1]).toBeDefined()
|
||||
expect(empty?.[1]).not.toMatch(/display:\s*none/)
|
||||
})
|
||||
|
||||
it('비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."', async () => {
|
||||
const wrapper = await mountPage({ blocks: [] })
|
||||
|
||||
expect(wrapper.get('[data-block-empty]').text()).toBe(
|
||||
'차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-block-count]').text()).toBe('0 / 500')
|
||||
})
|
||||
|
||||
it('[차단] — 소문자로 맞춰 막고 목록 맨 위에 넣는다 · 칸을 비우고 칸 아래 "…을(를) 차단했습니다." — Enter 도 같다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await add(wrapper, ' Spam@Evil.Example ')
|
||||
|
||||
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
|
||||
{ address: 'spam@evil.example' },
|
||||
])
|
||||
expect(rows(wrapper)[0]?.[0]).toBe('spam@evil.example')
|
||||
expect(wrapper.get('[data-block-count]').text()).toBe('4 / 500')
|
||||
expect((input(wrapper).element as HTMLInputElement).value).toBe('')
|
||||
expect(message(wrapper)).toBe('‘spam@evil.example’을(를) 차단했습니다.')
|
||||
|
||||
await input(wrapper).setValue('@evil-ads.example')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
expect(rows(wrapper)[0]?.[0]).toBe('@evil-ads.example')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['형식', 'kim@', '메일 주소나 @도메인 형식으로 써 주세요.'],
|
||||
['이미 차단함', 'NEWS@cloudbrief.example', '이미 차단한 주소입니다.'],
|
||||
['내 주소', 'ara.kim@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
|
||||
['사내 도메인', '@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
|
||||
[
|
||||
'여러 사람이 쓰는 메일 서비스',
|
||||
'@gmail.com',
|
||||
'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
|
||||
],
|
||||
])('보내기 전 검사 — %s: 칸 아래 오류(빨강) · 잘못된 입력, 보내지 않는다', async (_label, value, text) => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await add(wrapper, value)
|
||||
|
||||
expect(message(wrapper)).toBe(text)
|
||||
expect(wrapper.get('[data-block-message]').classes()).toContain('form-message--error')
|
||||
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
|
||||
expect(input(wrapper).attributes('aria-describedby')).toBe('mail-block-message')
|
||||
expect(requests.some((request) => request.method === 'POST')).toBe(false)
|
||||
|
||||
// 칸을 고치면 내린다
|
||||
await input(wrapper).setValue(`${value}x`)
|
||||
expect(message(wrapper)).toBe('')
|
||||
expect(input(wrapper).attributes('aria-invalid')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('500개를 다 막았으면 "500개까지 차단할 수 있습니다."', async () => {
|
||||
const full = Array.from({ length: 500 }, (_, index) => ({ address: `s${index}@spam.example`, blockedAt: '2026-10-21T01:00:00Z' }))
|
||||
const wrapper = await mountPage({ blocks: full })
|
||||
|
||||
await add(wrapper, 'new@spam.example')
|
||||
|
||||
expect(message(wrapper)).toBe('500개까지 차단할 수 있습니다.')
|
||||
expect(requests.some((request) => request.method === 'POST')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['VAL_001', 'MAIL_013'] as const)('서버가 거절하면(%s) 칸 아래에 사전 문구 — 알림은 띄우지 않는다', async (code) => {
|
||||
const wrapper = await mountPage({ add: () => fail(400, code) })
|
||||
|
||||
await add(wrapper, 'spam@evil.example')
|
||||
|
||||
expect(message(wrapper)).toBe(ERROR_CODE_MESSAGES[code])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect((input(wrapper).element as HTMLInputElement).value).toBe('spam@evil.example')
|
||||
})
|
||||
|
||||
it('[차단 풀기] — 바로 풀고 "‘…’ 차단을 풀었습니다." + [되돌리기](다시 막는다) · 초점은 다음 줄로', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const second = wrapper.findAll('[data-block-remove]')[1]
|
||||
;(second?.element as HTMLButtonElement).focus()
|
||||
|
||||
await second?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/mail-blocks/%40promo-mail.example',
|
||||
])
|
||||
expect(rows(wrapper).map(([address]) => address)).toEqual(['news@cloudbrief.example', 'sales@unknown-shop.example'])
|
||||
expect(document.activeElement).toBe(wrapper.findAll('[data-block-remove]')[1]?.element)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('‘@promo-mail.example’ 차단을 풀었습니다.')
|
||||
|
||||
if (!toast?.action || !('run' in toast.action)) throw new Error('단추가 없다')
|
||||
toast.action.run()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
|
||||
{ address: '@promo-mail.example' },
|
||||
])
|
||||
expect(rows(wrapper)[0]?.[0]).toBe('@promo-mail.example')
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('‘@promo-mail.example’을(를) 다시 차단했습니다.')
|
||||
})
|
||||
|
||||
it('마지막 줄을 풀면 초점은 칸으로 · 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 — 오류 알림 없이', async () => {
|
||||
const wrapper = await mountPage({ blocks: [BLOCKS[0] as BlockedSender], remove: () => fail(404, 'RES_001') })
|
||||
const only = wrapper.get('[data-block-remove]')
|
||||
;(only.element as HTMLButtonElement).focus()
|
||||
|
||||
await only.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(rows(wrapper)).toEqual([])
|
||||
expect(wrapper.find('[data-block-empty]').exists()).toBe(true)
|
||||
expect(document.activeElement).toBe(input(wrapper).element)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘news@cloudbrief.example’ 차단을 풀었습니다.'],
|
||||
])
|
||||
})
|
||||
|
||||
it('풀지 못하면(메일 서버 장애) 목록 그대로 — 알림은 인터셉터', async () => {
|
||||
const wrapper = await mountPage({ remove: () => fail(503, 'MAIL_001') })
|
||||
|
||||
await wrapper.findAll('[data-block-remove]')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(rows(wrapper)).toHaveLength(3)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
|
||||
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
|
||||
const INACTIVE = '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.'
|
||||
|
||||
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
|
||||
const off = await mountPage({ isActive: false })
|
||||
expect(off.get('[data-blocks-inactive]').text()).toBe(INACTIVE)
|
||||
expect(off.get('[data-blocks-inactive]').attributes('role')).toBe('status')
|
||||
expect(off.get('[data-blocks-inactive]').find('button').exists()).toBe(false)
|
||||
off.unmount()
|
||||
|
||||
const empty = await mountPage({ isActive: false, blocks: [] })
|
||||
expect(empty.find('[data-blocks-inactive]').exists()).toBe(false)
|
||||
empty.unmount()
|
||||
|
||||
const on = await mountPage()
|
||||
expect(on.find('[data-blocks-inactive]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('차단하거나 풀면 서버가 규칙을 다시 저장하므로 줄을 내린다 — 이미 풀려 있던 풀기(404)는 저장하지 않았으니 그대로', async () => {
|
||||
const wrapper = await mountPage({ isActive: false, remove: () => fail(404, 'RES_001') })
|
||||
|
||||
await wrapper.findAll('[data-block-remove]')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-blocks-inactive]').exists()).toBe(true)
|
||||
|
||||
await add(wrapper, 'spam@evil.example')
|
||||
expect(wrapper.find('[data-blocks-inactive]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('풀어서 저장했으면 줄을 내린다', async () => {
|
||||
const wrapper = await mountPage({ isActive: false })
|
||||
|
||||
await wrapper.findAll('[data-block-remove]')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-blocks-inactive]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('못 받으면 사전 문구와 "다시 시도" — 다시 받으면 그린다', async () => {
|
||||
let reply: MockResult = fail(503, 'MAIL_001')
|
||||
const wrapper = await mountPage({
|
||||
get blocks() {
|
||||
return reply
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
reply = ok({ senders: BLOCKS, isActive: true })
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(rows(wrapper)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,390 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 차단한 주소 (디자인 원본 SettingsSendRead · README "9단계 › 설정 · 차단한 주소" · 계약 2절 "보낸 사람 차단")
|
||||
// — /settings/mail/blocks · 탭 제목 "차단한 주소"
|
||||
// 머리 "차단한 주소" + "3 / 500" + 설명 → 더하기 줄(칸 "차단할 주소" + [차단]) → 목록(줄마다 주소 · "10월 29일 차단" | "도메인 전체 · …" + [차단 풀기] 36)
|
||||
// 또는 빈 상태 글.
|
||||
// - 들어올 때마다 새로 받는다(다른 탭 · PC 에서 바꿨을 수 있다 — mail-block.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
|
||||
// - [차단]: 서버와 같은 규칙을 먼저 본다(mail-block.ts — 형식 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · 이미 차단 · 500개) —
|
||||
// 걸리면 칸 아래 오류(보내지 않는다). 서버의 거절(VAL_001 · MAIL_013 — 엔진 규칙 한도)도 칸 아래에 사전 문구로(알림 없음).
|
||||
// 막으면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다." — 목록 맨 위에 생긴다. Enter 도 [차단]과 같다(한글 조합 중의 Enter 는 글자 확정)
|
||||
// - [차단 풀기]: 바로 풀고 알림 "‘…’ 차단을 풀었습니다." + [되돌리기](다시 차단 — useSenderBlock). 이미 받은 메일은 옮기지 않는다.
|
||||
// 누른 줄이 사라지면 초점을 다음 줄(없으면 앞 줄 · 칸)로 옮긴다
|
||||
// - 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 부재중 화면과 같은 결(단추 없음, 차단하거나 풀면 다시 켜진다)
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { getErrorMessage, toApiError } from '@/composables/useApi'
|
||||
import { useSenderBlock } from '@/composables/useSenderBlock'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BLOCK_MAX, MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import type { BlockedSender } from '@/shared/types/mail'
|
||||
import { formatBlockedDay, formatCount } from '@/shared/utils/format'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { isDomainBlock, mailBlockInputOf, type MailBlockInputProblem } from '@/shared/utils/mail-block'
|
||||
import { objectParticle } from '@/shared/utils/korean'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
|
||||
/** 칸 아래에 알리는 서버 거절 — 형식(VAL_001) · 엔진 규칙 한도(MAIL_013). 인터셉터 알림을 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['VAL_001', 'MAIL_013']
|
||||
const INPUT_ID = 'mail-block-input'
|
||||
const MESSAGE_ID = 'mail-block-message'
|
||||
|
||||
const PROBLEM_TEXT: Readonly<Record<MailBlockInputProblem, string>> = {
|
||||
invalid: MAIL_BLOCK_TEXT.invalid,
|
||||
duplicate: MAIL_BLOCK_TEXT.duplicate,
|
||||
own: MAIL_BLOCK_TEXT.own,
|
||||
publicDomain: MAIL_BLOCK_TEXT.publicDomain,
|
||||
full: MAIL_BLOCK_TEXT.full,
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const blockStore = useMailBlockStore()
|
||||
const senderBlock = useSenderBlock()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const input = ref<string>('')
|
||||
/** 칸 아래 오류 — 보내기 전 검사 · 서버 거절 */
|
||||
const error = ref<string | null>(null)
|
||||
/** 칸 아래 안내 — 막은 뒤 */
|
||||
const notice = ref<string | null>(null)
|
||||
const adding = ref<boolean>(false)
|
||||
/** [차단 풀기]를 누르고 기다리는 줄 */
|
||||
const removing = ref<string | null>(null)
|
||||
|
||||
const senders = computed<readonly BlockedSender[]>(() => blockStore.senders)
|
||||
const countText = computed(() => MAIL_BLOCK_TEXT.count(formatCount(senders.value.length), formatCount(MAIL_BLOCK_MAX)))
|
||||
const ready = computed(() => blockStore.status === 'ready' && blockStore.blockList !== null)
|
||||
/** 차단한 주소가 있는데 지금은 동작하지 않는다 (엔진의 규칙이 꺼짐) */
|
||||
const inactive = computed(() => blockStore.blockList?.isActive === false && senders.value.length > 0)
|
||||
|
||||
function dayText(sender: BlockedSender): string {
|
||||
const day = formatBlockedDay(sender.blockedAt)
|
||||
return isDomainBlock(sender.address) ? MAIL_BLOCK_TEXT.domainBlockedOn(day) : MAIL_BLOCK_TEXT.blockedOn(day)
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
error.value = null
|
||||
notice.value = null
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (adding.value || !ready.value) return
|
||||
notice.value = null
|
||||
const checked = mailBlockInputOf(input.value, authStore.user?.email ?? '', senders.value)
|
||||
if ('problem' in checked) {
|
||||
error.value = PROBLEM_TEXT[checked.problem]
|
||||
inputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
adding.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const sender = await blockStore.add(checked.address, { silent: INLINE_CODES })
|
||||
input.value = ''
|
||||
notice.value = MAIL_BLOCK_TEXT.blocked(sender.address, objectParticle(sender.address))
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
// 칸 아래에 — 그 밖의 실패(메일 서버 장애 등)는 인터셉터가 알렸다
|
||||
if (INLINE_CODES.includes(apiError.code)) {
|
||||
error.value = getErrorMessage(apiError.code)
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
void add()
|
||||
}
|
||||
|
||||
/** 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 칸 */
|
||||
async function focusAfterRemoval(index: number) {
|
||||
await nextTick()
|
||||
const active = document.activeElement
|
||||
if (active !== null && active !== document.body) return
|
||||
const buttons = Array.from(listRef.value?.querySelectorAll<HTMLButtonElement>('[data-block-remove]') ?? [])
|
||||
const target = buttons[index] ?? buttons[index - 1]
|
||||
if (target) target.focus()
|
||||
else inputRef.value?.focus()
|
||||
}
|
||||
|
||||
async function remove(sender: BlockedSender) {
|
||||
if (removing.value) return
|
||||
const index = senders.value.findIndex((item) => item.address === sender.address)
|
||||
removing.value = sender.address
|
||||
try {
|
||||
if (await senderBlock.unblock(sender.address)) await focusAfterRemoval(index)
|
||||
} finally {
|
||||
removing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
|
||||
void blockStore.load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="blocks-page-title"
|
||||
:title="MAIL_BLOCK_TEXT.title"
|
||||
:description="MAIL_BLOCK_TEXT.description"
|
||||
>
|
||||
<template
|
||||
v-if="ready"
|
||||
#title-aside
|
||||
>
|
||||
<span
|
||||
class="blocks-page__count"
|
||||
data-block-count
|
||||
>{{ countText }}</span>
|
||||
</template>
|
||||
|
||||
<MailLoadError
|
||||
v-if="blockStore.status === 'error' && blockStore.error"
|
||||
:error="blockStore.error"
|
||||
leave-label=""
|
||||
@retry="blockStore.load()"
|
||||
/>
|
||||
|
||||
<template v-else-if="ready">
|
||||
<MailFilterNotice
|
||||
v-if="inactive"
|
||||
icon="alert-circle"
|
||||
:message="MAIL_BLOCK_TEXT.inactive"
|
||||
data-blocks-inactive
|
||||
/>
|
||||
<div class="blocks-page__add">
|
||||
<label
|
||||
:for="INPUT_ID"
|
||||
class="blocks-page__label"
|
||||
>{{ MAIL_BLOCK_TEXT.addLabel }}</label>
|
||||
<div class="blocks-page__add-row">
|
||||
<input
|
||||
:id="INPUT_ID"
|
||||
ref="inputRef"
|
||||
v-model="input"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="form-input blocks-page__input"
|
||||
:placeholder="MAIL_BLOCK_TEXT.addPlaceholder"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="error || notice ? MESSAGE_ID : undefined"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="blocks-page__add-button"
|
||||
:aria-disabled="adding ? 'true' : undefined"
|
||||
:aria-busy="adding ? 'true' : undefined"
|
||||
data-block-add
|
||||
@click="add"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.add }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
:id="MESSAGE_ID"
|
||||
:class="['form-message', { 'form-message--error': error }]"
|
||||
role="status"
|
||||
data-block-message
|
||||
>
|
||||
<template v-if="error">
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</template>
|
||||
<span v-else-if="notice">{{ notice }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="senders.length > 0"
|
||||
ref="listRef"
|
||||
class="blocks-page__list"
|
||||
:aria-label="MAIL_BLOCK_TEXT.title"
|
||||
>
|
||||
<li
|
||||
v-for="sender in senders"
|
||||
:key="sender.address"
|
||||
class="blocks-page__row"
|
||||
:data-block-row="sender.address"
|
||||
>
|
||||
<span class="blocks-page__text">
|
||||
<span class="blocks-page__address">{{ sender.address }}</span>
|
||||
<span class="blocks-page__day">{{ dayText(sender) }}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="blocks-page__remove"
|
||||
:aria-label="`${sender.address} ${MAIL_BLOCK_TEXT.unblock}`"
|
||||
:aria-disabled="removing ? 'true' : undefined"
|
||||
:aria-busy="removing === sender.address ? 'true' : undefined"
|
||||
data-block-remove
|
||||
@click="remove(sender)"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.unblock }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="blocks-page__empty"
|
||||
data-block-empty
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.empty }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="blocks-page__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blocks-page__count {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.blocks-page__add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
max-width: var(--form-wide-max-width);
|
||||
}
|
||||
|
||||
.blocks-page__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blocks-page__add-row {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.blocks-page__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.blocks-page__add-button {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-18);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blocks-page__add-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.blocks-page__add-button[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/*
|
||||
* 비어 있어도 그린다 — role="status" 영역은 늘 배치에 남아 있어야 글이 생길 때 스크린리더가 읽는다(display:none 이면 영역째 사라진다 — 리뷰 #8).
|
||||
* 빈 동안은 높이가 0 이고, 묶음의 칸 사이(gap)만큼 위로 당겨 자리를 차지하지 않는다
|
||||
*/
|
||||
.blocks-page__add .form-message:empty {
|
||||
margin-top: calc(var(--space-6) * -1);
|
||||
}
|
||||
|
||||
/* 목록 — 테두리 상자 안의 줄 52 (디자인 원본 SettingsSendRead) */
|
||||
.blocks-page__list {
|
||||
max-width: var(--form-wide-max-width);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.blocks-page__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
min-height: var(--size-header-min);
|
||||
padding: 0 var(--space-8) 0 var(--space-14);
|
||||
}
|
||||
|
||||
.blocks-page__row + .blocks-page__row {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.blocks-page__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.blocks-page__address {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.blocks-page__day {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* [차단 풀기] 36 — 밀도 예외 (--size-notice-action, 노란 줄 안 단추와 같다) */
|
||||
.blocks-page__remove {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blocks-page__remove:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.blocks-page__remove[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.blocks-page__empty {
|
||||
max-width: var(--note-max-width);
|
||||
margin: 0;
|
||||
padding: var(--space-14) var(--space-16);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blocks-page__placeholder {
|
||||
min-height: 16rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
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 { MailSettings } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailSendReadPage from './MailSendReadPage.vue'
|
||||
|
||||
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
|
||||
|
||||
interface Server {
|
||||
settings?: MailSettings
|
||||
/** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
|
||||
load?: MockResult
|
||||
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('MailSendReadPage', () => {
|
||||
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 stored: MailSettings = { ...DEFAULTS, ...server.settings }
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'GET') return server.load ?? ok(stored)
|
||||
if (server.patch) return server.patch(request)
|
||||
stored = { ...stored, ...(request.data as Partial<MailSettings>) }
|
||||
return ok(stored)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/send-read')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailSendReadPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const pressed = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-undo-seconds]').map((button) => [button.text(), button.attributes('aria-pressed')])
|
||||
const checkedImages = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-external-images]').filter((input) => (input.element as HTMLInputElement).checked).map((input) => input.attributes('value'))
|
||||
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
|
||||
|
||||
it('들어올 때 받은 값으로 그린다 — 보내기 취소(끔 · 5초 · 10초 · 20초 · 30초) · 외부 이미지 라디오 셋 · 안내 상자', async () => {
|
||||
const wrapper = await mountPage({ settings: { undoSendSeconds: 20, externalImages: 'trusted' } })
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-settings'])
|
||||
expect(wrapper.get('#send-read-page-title').text()).toBe('보내기 · 읽기')
|
||||
expect(pressed(wrapper)).toEqual([
|
||||
['끔', 'false'],
|
||||
['5초', 'false'],
|
||||
['10초', 'false'],
|
||||
['20초', 'true'],
|
||||
['30초', 'false'],
|
||||
])
|
||||
expect(wrapper.get('#send-read-undo-hint').text()).toBe(
|
||||
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
|
||||
)
|
||||
expect(wrapper.findAll('.send-read__option-label').map((label) => label.text())).toEqual([
|
||||
'늘 묻기',
|
||||
'주소록 · 사내 직원의 메일은 늘 보기',
|
||||
'늘 보기',
|
||||
])
|
||||
expect(checkedImages(wrapper)).toEqual(['trusted'])
|
||||
expect(wrapper.get('[data-images-note]').text()).toContain('사내 메일 서버가 대신 받아 보여 줍니다')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('고르는 대로 그 칸만 PATCH 하고 "저장했습니다"(role=status) — 다시 고르면 내렸다가 저장되면 다시', async () => {
|
||||
let release: () => void = () => undefined
|
||||
const wrapper = await mountPage({
|
||||
patch: (request) =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = () => resolve(ok({ ...DEFAULTS, ...(request.data as Partial<MailSettings>) }))
|
||||
}),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
|
||||
// 화면을 먼저 바꾼다
|
||||
expect(pressed(wrapper).find(([, value]) => value === 'true')?.[0]).toBe('30초')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
release()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-settings-status]').attributes('role')).toBe('status')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
|
||||
await wrapper.get('[data-external-images="always"]').setValue(true)
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
release()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ undoSendSeconds: 30 }, { externalImages: 'always' }])
|
||||
expect(checkedImages(wrapper)).toEqual(['always'])
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('지금 값을 다시 누르면 보내지 않는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await wrapper.get('[data-undo-seconds="10"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([])
|
||||
})
|
||||
|
||||
it('저장하지 못하면 고르기 전 값으로 되돌린다 — 알림은 인터셉터', async () => {
|
||||
const wrapper = await mountPage({ patch: () => fail(400, 'VAL_001') })
|
||||
|
||||
await wrapper.get('[data-undo-seconds="0"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-external-images="always"]').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(pressed(wrapper).find(([, value]) => value === 'true')?.[0]).toBe('10초')
|
||||
expect(checkedImages(wrapper)).toEqual(['ask'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
})
|
||||
|
||||
describe('연달아 고르면 — 칸마다 저장은 한 번에 하나, 앞 저장이 끝난 뒤 마지막으로 고른 것만 보낸다 (2026-10-29 리뷰 #3)', () => {
|
||||
/** 보낸 PATCH 를 차례로 잡아 둔다 — 테스트가 응답을 정한다. 서버는 받은 차례대로 저장한다(stored) */
|
||||
async function mountHeld() {
|
||||
const held: { data: Partial<MailSettings>; resolve: (result: MockResult) => void }[] = []
|
||||
let stored: MailSettings = { ...DEFAULTS }
|
||||
const wrapper = await mountPage({
|
||||
patch: (request) =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
const data = request.data as Partial<MailSettings>
|
||||
held.push({ data, resolve })
|
||||
}),
|
||||
})
|
||||
/** n 번째 PATCH 를 서버가 저장하고 답한다 */
|
||||
function save(index: number) {
|
||||
const request = held[index]
|
||||
if (!request) throw new Error(`PATCH ${index} 가 없다`)
|
||||
stored = { ...stored, ...request.data }
|
||||
request.resolve(ok(stored))
|
||||
}
|
||||
function reject(index: number, result: MockResult) {
|
||||
const request = held[index]
|
||||
if (!request) throw new Error(`PATCH ${index} 가 없다`)
|
||||
request.resolve(result)
|
||||
}
|
||||
return { wrapper, held, save, reject, stored: () => stored }
|
||||
}
|
||||
const pressedLabel = (wrapper: VueWrapper) => pressed(wrapper).find(([, value]) => value === 'true')?.[0]
|
||||
|
||||
it('기다리는 동안 고른 것은 보내지 않고 기억했다가, 앞 저장이 끝나면 마지막 것 하나만 보낸다 — 화면 · 서버가 같다', async () => {
|
||||
const { wrapper, save, stored } = await mountHeld()
|
||||
|
||||
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
|
||||
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
|
||||
await wrapper.get('[data-undo-seconds="20"]').trigger('click')
|
||||
// 화면은 마지막으로 고른 값 · 요청은 하나
|
||||
expect(pressedLabel(wrapper)).toBe('20초')
|
||||
expect(patches()).toEqual([{ undoSendSeconds: 5 }])
|
||||
|
||||
save(0)
|
||||
await flushPromises()
|
||||
expect(patches()).toEqual([{ undoSendSeconds: 5 }, { undoSendSeconds: 20 }])
|
||||
expect(pressedLabel(wrapper)).toBe('20초')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
|
||||
save(1)
|
||||
await flushPromises()
|
||||
expect(stored().undoSendSeconds).toBe(20)
|
||||
expect(pressedLabel(wrapper)).toBe('20초')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('외부 이미지 「늘 보기」 → 「늘 묻기」를 빠르게 — 늦게 끝난 앞 저장 뒤에 「늘 묻기」를 보내 DB 도 늘 묻기다 (화면 늘 묻기 · DB 늘 보기로 갈리지 않는다)', async () => {
|
||||
const { wrapper, save, stored } = await mountHeld()
|
||||
|
||||
await wrapper.get('[data-external-images="always"]').setValue(true)
|
||||
await wrapper.get('[data-external-images="ask"]').setValue(true)
|
||||
expect(checkedImages(wrapper)).toEqual(['ask'])
|
||||
expect(patches()).toEqual([{ externalImages: 'always' }])
|
||||
|
||||
save(0)
|
||||
await flushPromises()
|
||||
save(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ externalImages: 'always' }, { externalImages: 'ask' }])
|
||||
expect(stored().externalImages).toBe('ask')
|
||||
expect(checkedImages(wrapper)).toEqual(['ask'])
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('기다리는 동안 처음 값으로 되돌려 고르면 — 앞 저장이 끝난 뒤 처음 값을 다시 보낸다 (서버는 앞 값을 저장했으므로)', async () => {
|
||||
const { wrapper, save, stored } = await mountHeld()
|
||||
|
||||
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
|
||||
await wrapper.get('[data-undo-seconds="10"]').trigger('click')
|
||||
save(0)
|
||||
await flushPromises()
|
||||
save(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ undoSendSeconds: 30 }, { undoSendSeconds: 10 }])
|
||||
expect(stored().undoSendSeconds).toBe(10)
|
||||
expect(pressedLabel(wrapper)).toBe('10초')
|
||||
})
|
||||
|
||||
it('앞 저장이 실패해도 그사이 고른 것을 보낸다 — 마지막이 성공하면 그 값 · "저장했습니다"', async () => {
|
||||
const { wrapper, save, reject } = await mountHeld()
|
||||
|
||||
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
|
||||
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
|
||||
reject(0, fail(503, 'MAIL_001'))
|
||||
await flushPromises()
|
||||
expect(patches()).toEqual([{ undoSendSeconds: 5 }, { undoSendSeconds: 30 }])
|
||||
expect(pressedLabel(wrapper)).toBe('30초')
|
||||
|
||||
save(1)
|
||||
await flushPromises()
|
||||
expect(pressedLabel(wrapper)).toBe('30초')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('마지막으로 고른 것의 저장이 실패하면 서버가 마지막으로 확인해 준 값으로 되돌린다 — "저장했습니다"는 없다', async () => {
|
||||
const { wrapper, save, reject } = await mountHeld()
|
||||
|
||||
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
|
||||
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
|
||||
save(0)
|
||||
await flushPromises()
|
||||
// 같은 코드의 알림은 3초 동안 한 번이다(인터셉터) — 이 파일에서 아직 쓰지 않은 코드로 실패시킨다
|
||||
reject(1, fail(500, 'SYS_001'))
|
||||
await flushPromises()
|
||||
|
||||
// 서버가 확인한 값은 5초 (앞 저장)
|
||||
expect(pressedLabel(wrapper)).toBe('5초')
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('두 칸은 따로 저장한다 — 둘 다 끝나야 "저장했습니다"', async () => {
|
||||
const { wrapper, save } = await mountHeld()
|
||||
|
||||
await wrapper.get('[data-undo-seconds="0"]').trigger('click')
|
||||
await wrapper.get('[data-external-images="trusted"]').setValue(true)
|
||||
expect(patches()).toEqual([{ undoSendSeconds: 0 }, { externalImages: 'trusted' }])
|
||||
|
||||
save(0)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
save(1)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('저장하는 중에 떠났다가 돌아오면(다시 받음) 남은 저장을 세지 않는다 — 그 뒤 저장하면 "저장했습니다" (리뷰 #7)', async () => {
|
||||
const { wrapper, save } = await mountHeld()
|
||||
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
|
||||
wrapper.unmount()
|
||||
|
||||
// 다시 들어온다 — 화면이 설정을 새로 받는다
|
||||
const again = mount(MailSendReadPage, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } })
|
||||
mounted.push(again)
|
||||
await flushPromises()
|
||||
save(0)
|
||||
await flushPromises()
|
||||
|
||||
await again.get('[data-undo-seconds="20"]').trigger('click')
|
||||
save(1)
|
||||
await flushPromises()
|
||||
expect(again.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
})
|
||||
|
||||
it('못 받으면 사전 문구와 "다시 시도"', async () => {
|
||||
const wrapper = await mountPage({ load: fail(503, 'MAIL_001') })
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(wrapper.find('[data-undo-seconds]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · README "9단계 › 설정 · 보내기 · 읽기" · 계약 2절 "보내기 · 읽기 설정")
|
||||
// — /settings/mail/send-read · 탭 제목 "보내기 · 읽기"
|
||||
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
|
||||
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
|
||||
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자.
|
||||
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
|
||||
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
|
||||
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { EXTERNAL_IMAGES_OPTIONS, MAIL_SETTINGS_TEXT, UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
|
||||
import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
function undoLabel(seconds: UndoSendSeconds): string {
|
||||
return seconds === 0 ? MAIL_SETTINGS_TEXT.undoOff : MAIL_SETTINGS_TEXT.undoSeconds(seconds)
|
||||
}
|
||||
|
||||
function chooseUndo(seconds: UndoSendSeconds) {
|
||||
void settingsStore.update('undoSendSeconds', seconds)
|
||||
}
|
||||
|
||||
function chooseImages(option: ExternalImagesSetting) {
|
||||
void settingsStore.update('externalImages', option)
|
||||
}
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 값을 잠깐 그렸다 바꾸지 않게
|
||||
void settingsStore.load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="send-read-page-title"
|
||||
:title="MAIL_SETTINGS_TEXT.title"
|
||||
:description="MAIL_SETTINGS_TEXT.description"
|
||||
>
|
||||
<template #actions>
|
||||
<p
|
||||
class="send-read__status"
|
||||
role="status"
|
||||
data-settings-status
|
||||
>
|
||||
<template v-if="settingsStore.saved">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="14"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ MAIL_SETTINGS_TEXT.saved }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<MailLoadError
|
||||
v-if="settingsStore.status === 'error' && settingsStore.error"
|
||||
:error="settingsStore.error"
|
||||
leave-label=""
|
||||
@retry="settingsStore.load()"
|
||||
/>
|
||||
|
||||
<template v-else-if="settingsStore.status === 'ready' && settingsStore.settings">
|
||||
<section
|
||||
class="send-read__group"
|
||||
aria-labelledby="send-read-undo-title"
|
||||
>
|
||||
<h2
|
||||
id="send-read-undo-title"
|
||||
class="send-read__title"
|
||||
>
|
||||
{{ MAIL_SETTINGS_TEXT.undoTitle }}
|
||||
</h2>
|
||||
<div
|
||||
role="group"
|
||||
class="segmented segmented--outline send-read__undo"
|
||||
aria-labelledby="send-read-undo-title"
|
||||
aria-describedby="send-read-undo-hint"
|
||||
>
|
||||
<button
|
||||
v-for="seconds in UNDO_SEND_OPTIONS"
|
||||
:key="seconds"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="settingsStore.settings.undoSendSeconds === seconds"
|
||||
:data-undo-seconds="seconds"
|
||||
@click="chooseUndo(seconds)"
|
||||
>
|
||||
{{ undoLabel(seconds) }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="send-read-undo-hint"
|
||||
class="form-message"
|
||||
>
|
||||
{{ MAIL_SETTINGS_TEXT.undoHint }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<hr class="send-read__rule">
|
||||
|
||||
<fieldset class="send-read__group send-read__images">
|
||||
<legend class="send-read__title">
|
||||
{{ MAIL_SETTINGS_TEXT.imagesTitle }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="option in EXTERNAL_IMAGES_OPTIONS"
|
||||
:key="option"
|
||||
class="send-read__option"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="send-read-images"
|
||||
class="send-read__radio"
|
||||
:value="option"
|
||||
:checked="settingsStore.settings.externalImages === option"
|
||||
:aria-describedby="`send-read-images-${option}-hint`"
|
||||
:data-external-images="option"
|
||||
@change="chooseImages(option)"
|
||||
>
|
||||
<span class="send-read__option-text">
|
||||
<span class="send-read__option-label">{{ MAIL_SETTINGS_TEXT.images[option].label }}</span>
|
||||
<span
|
||||
:id="`send-read-images-${option}-hint`"
|
||||
class="send-read__option-hint"
|
||||
>{{ MAIL_SETTINGS_TEXT.images[option].hint }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
class="send-read__note"
|
||||
data-images-note
|
||||
>
|
||||
{{ MAIL_SETTINGS_TEXT.imagesNote }}
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="send-read__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* "저장했습니다" — 초록 12.5px + ✓ (디자인 원본 SettingsSendRead · 서명 화면과 같은 색) */
|
||||
.send-read__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.send-read__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.send-read__title {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.send-read__undo {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.send-read__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 외부 이미지 — 줄마다 라디오 18 · 이름(굵게) + 아래 설명 (디자인 원본 SettingsSendRead) */
|
||||
.send-read__images {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.send-read__images .send-read__title {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.send-read__option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
padding: var(--space-8) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-read__radio {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: var(--space-1) 0 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.send-read__option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.send-read__option-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.send-read__option-hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 회색 안내 상자 (디자인 원본 SettingsSendRead — 바탕 #F6F7F9) */
|
||||
.send-read__note {
|
||||
max-width: var(--note-max-width);
|
||||
margin: var(--space-4) 0 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 받는 동안의 자리 */
|
||||
.send-read__placeholder {
|
||||
min-height: 16rem;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -34,7 +35,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -47,6 +48,8 @@ describe('SettingsLayout', () => {
|
||||
['서명', '/settings/mail/signature'],
|
||||
['부재중 자동 응답', '/settings/mail/vacation'],
|
||||
['필터', '/settings/mail/filters'],
|
||||
['보내기 · 읽기', '/settings/mail/send-read'],
|
||||
['차단한 주소', '/settings/mail/blocks'],
|
||||
['알림', '/settings/mail/notify'],
|
||||
],
|
||||
])
|
||||
@@ -82,6 +85,27 @@ describe('SettingsLayout', () => {
|
||||
expect(router.resolve({ name: 'settings-mail-filters' }).fullPath).toBe('/settings/mail/filters')
|
||||
})
|
||||
|
||||
it('설정 › 메일 › 보내기 · 읽기 · 차단한 주소 (9단계) — 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
|
||||
await router.push('/settings/mail/send-read')
|
||||
expect(router.currentRoute.value.name).toBe('settings-mail-send-read')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '보내기 · 읽기', requiresAuth: true })
|
||||
|
||||
await router.push('/settings/mail/blocks')
|
||||
expect(router.currentRoute.value.name).toBe('settings-mail-blocks')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '차단한 주소', requiresAuth: true })
|
||||
})
|
||||
|
||||
it('차단한 주소 메뉴는 원 + 빗금 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/blocks')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
expect(current.text()).toBe('차단한 주소')
|
||||
expect(current.find('circle').attributes()).toMatchObject({ cx: '12', cy: '12', r: '10' })
|
||||
expect(current.find('line').attributes()).toMatchObject({ x1: '4.93', y1: '4.93', x2: '19.07', y2: '19.07' })
|
||||
})
|
||||
|
||||
it('필터 메뉴는 깔때기 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/filters')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
Reference in New Issue
Block a user