generated from ttipo/project-base
chore: 14단계 검색 색인을 앱 로그인 브랜치에 합친다
앱 새 메일 알림(푸시)의 서버 일을 앱 세션과 14단계 위에서 하려고 합친다. 겹친 곳은 두 곳이다. auth.module.ts 는 import 둘을 함께 두고, AuthService.login() 은 enrollAfterLogin(14단계)을 남긴 뒤 recordSignIn(앱 로그인)으로 기록한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -262,7 +262,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
|
||||
it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
|
||||
const settings = useMailSettingsStore()
|
||||
settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
|
||||
settings.settings = { undoSendSeconds: 5, externalImages: 'ask', groupByConversation: false }
|
||||
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]))
|
||||
@@ -322,7 +322,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
|
||||
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
|
||||
const preset = schedulePresets(NOW)[0]
|
||||
if (!preset) throw new Error('고르기가 없다')
|
||||
const sendAt = sendAtOf(preset.at)
|
||||
@@ -344,7 +344,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'success',
|
||||
message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
|
||||
message: `${formatScheduleTime(sendAt)}에 보냅니다.`,
|
||||
action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
|
||||
expiresAt: null,
|
||||
}),
|
||||
|
||||
@@ -181,6 +181,24 @@ describe('ComposePage', () => {
|
||||
expect(quote.text()).not.toContain('받는 사람')
|
||||
})
|
||||
|
||||
it('검색 결과에서 강조해 연 메일이 스토어에 있어도 답장의 원문은 검색 쿼리 없이 다시 받는다 — 인용에 <mark> 가 없다 (14단계)', async () => {
|
||||
const store = useMailStore()
|
||||
store.selectedId = 'm1'
|
||||
store.selected = {
|
||||
...ORIGINAL,
|
||||
htmlBody: '<p><mark>계약</mark>서를 보냅니다</p>',
|
||||
searchHighlight: { subject: [[0, 2]], attachments: {}, textBody: [] },
|
||||
}
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
|
||||
const originals = requests.filter((request) => request.method === 'GET' && request.url === '/messages/m1')
|
||||
expect(originals).toHaveLength(1)
|
||||
expect(originals[0]?.params).toEqual({})
|
||||
expect(wrapper.get('#compose-body').html()).not.toContain('<mark')
|
||||
expect(wrapper.get('#compose-body').text()).toContain('김아라님, 안녕하세요.')
|
||||
})
|
||||
|
||||
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
@@ -618,7 +636,7 @@ describe('ComposePage', () => {
|
||||
])
|
||||
expect(wrapper.get('[data-large-upload]').text()).toContain('도면.pdf')
|
||||
expect(wrapper.get('[data-large-switch]').text()).toBe(
|
||||
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB 를 넘어 링크로 보냅니다.',
|
||||
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB를 넘어 링크로 보냅니다.',
|
||||
)
|
||||
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { routes } from '@/router/routes'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
|
||||
// 메일 화면의 바뀜 소식(1분 확인 · 실시간 — MailLayout 의 onChange) → 메일함 개수 맞추기 → 같은 대화 칸 다시 받기 (14단계).
|
||||
// 읽음 · 중요 표시만 바뀐 소식(메일함들의 통 수가 그대로)으로는 대화를 다시 받지 않는다 — 대화 경로의 요청 수 한도를 아끼려고(리뷰 중간 2).
|
||||
// 실제 메일 화면(MailLayout → MailboxPage → 읽기 화면 → 같은 대화 칸)을 띄우고 손으로 돌리는 시계로 1분 확인을 넘긴다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '회신: 유지보수 계약 갱신 건',
|
||||
preview: `미리 보기 ${id}`,
|
||||
receivedAt: new Date(2026, 10, 5, 9, 30).toISOString(),
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(base: MessageSummary): MessageDetail {
|
||||
return {
|
||||
...base,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: `${base.id} 본문`,
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
const page = (items: unknown[], total = items.length) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
|
||||
|
||||
describe('MailLayout — 바뀜 소식으로 같은 대화 칸을 다시 받는다 (메일함들의 통 수가 바뀔 때만)', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
let state: string
|
||||
let inboxTotal: number
|
||||
let inboxUnread: number
|
||||
let thread: MessageSummary[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
|
||||
state = 's1'
|
||||
inboxTotal = 2
|
||||
inboxUnread = 1
|
||||
thread = [summary('m1', { mailboxIds: ['sent'], from: USER }), summary('m3')]
|
||||
requests = installMockApi(({ url, method }) => {
|
||||
if (url === '/mail-state') return ok({ state })
|
||||
if (url === '/mailboxes') {
|
||||
return page([mailbox('inbox', 'inbox', '받은편지함', inboxTotal, inboxUnread), mailbox('sent', 'sent', '보낸편지함', 1, 0)])
|
||||
}
|
||||
if (url === '/mail-quota') {
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
}
|
||||
if (url === '/mail-signature') {
|
||||
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
|
||||
}
|
||||
if (url === '/mail-vacation') {
|
||||
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
|
||||
}
|
||||
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
|
||||
if (url === '/mail-settings') return ok({ undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false })
|
||||
if (url === '/scheduled-messages') return page([])
|
||||
if (url === '/messages' && method === 'GET') return page([summary('m3'), summary('m2', { threadId: 't2' })])
|
||||
if (url.endsWith('/conversation')) return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
|
||||
if (url === '/messages/m3') return ok(detail(summary('m3')))
|
||||
return ok(null)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
for (let round = 0; round < 4; round += 1) await flushPromises()
|
||||
}
|
||||
|
||||
async function mountMail() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
await router.push('/mail/inbox/m3')
|
||||
await router.isReady()
|
||||
const wrapper = mount(
|
||||
{ render: () => h(RouterView) },
|
||||
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
|
||||
)
|
||||
mounted.push(wrapper)
|
||||
await settle()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
|
||||
const mailboxRequests = () => requests.filter((request) => request.url === '/mailboxes')
|
||||
|
||||
/** 1분 확인을 넘긴다 — 바뀐 state 면 메일 화면이 메일함 개수 · 보이는 목록을 맞춘다(onChange) */
|
||||
async function nextPoll(nextState: string) {
|
||||
state = nextState
|
||||
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await settle()
|
||||
}
|
||||
|
||||
it('읽음 표시만 바뀐 소식(통 수 그대로)으로는 다시 받지 않고, 새 메일로 통 수가 바뀌면 다시 받는다', async () => {
|
||||
const wrapper = await mountMail()
|
||||
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 다른 곳에서 메일 한 통을 읽었다 — 안 읽은 수만 바뀐다
|
||||
const mailboxLoads = mailboxRequests().length
|
||||
inboxUnread = 0
|
||||
await nextPoll('s2')
|
||||
expect(mailboxRequests().length).toBeGreaterThan(mailboxLoads)
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
await settle()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 같은 대화에 새 메일이 왔다 — 받은편지함의 통 수가 바뀐다
|
||||
inboxTotal = 3
|
||||
thread = [...thread, summary('m4', { receivedAt: new Date(2026, 10, 6, 15, 2).toISOString() })]
|
||||
await nextPoll('s3')
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
expect(conversationRequests()[1]?.config.silent).toBe(true)
|
||||
expect(wrapper.get('[data-conversation] h3').text()).toBe('같은 대화 3통')
|
||||
})
|
||||
|
||||
it(`받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안에 온 소식은 그때까지 모았다가 한 번 받는다`, async () => {
|
||||
await mountMail()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
// 처음 받기가 끝난 바로 뒤 — 실시간 소식처럼 곧바로 메일함 개수를 맞춘 것으로 본다
|
||||
inboxTotal = 3
|
||||
useMailStore().syncCounts()
|
||||
await settle()
|
||||
expect(conversationRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
|
||||
await settle()
|
||||
expect(conversationRequests()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,8 @@
|
||||
// 들어올 때 예약한 메일 수(패널의 「예약됨」) · 차단 목록(읽기 화면 ⋯ 의 차단 · 스팸메일함의 안내 줄)을 받아 둔다 (9단계 — 못 받으면 알리지 않는다).
|
||||
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
|
||||
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
|
||||
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
|
||||
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
|
||||
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
|
||||
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
|
||||
import { provide } from 'vue'
|
||||
|
||||
@@ -19,6 +20,7 @@ import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
|
||||
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
|
||||
@@ -44,8 +46,10 @@ void useMailVacationStore().load()
|
||||
void mailStore.loadScheduledCount()
|
||||
// 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람의 메일" 줄
|
||||
useMailBlockStore().ensureLoaded()
|
||||
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다)
|
||||
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다). 메일함 목록의 묶기도 이 설정이다
|
||||
useMailSettingsStore().ensureLoaded()
|
||||
// 탭이 다시 보일 때 — 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다 (다른 탭 · PC 에서 바꾼 값)
|
||||
useMailSettingsRefresh({ clock })
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
|
||||
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
|
||||
@@ -57,6 +61,7 @@ useMailStatePolling({
|
||||
void mailStore.loadScheduledCount()
|
||||
// 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다)
|
||||
void mailStore.refreshOpenedLargeFiles()
|
||||
// 읽기 화면의 "같은 대화" 칸은 위 syncCounts 로 받은 메일함들의 통 수가 바뀌었을 때만 다시 받는다 (14단계 — mail.store.ts 의 mailboxTotalsKey)
|
||||
},
|
||||
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { useMailStore } from '@/stores/mail.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'
|
||||
@@ -168,6 +169,16 @@ describe('MailPrintPage', () => {
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
|
||||
})
|
||||
|
||||
it('검색 결과에서 강조해 연 메일도 인쇄는 검색 쿼리 없이 다시 받는다 — 인쇄 주소에도 싣지 않는다 (14단계)', async () => {
|
||||
const store = useMailStore()
|
||||
store.selected = { ...MESSAGE, htmlBody: '<p><mark>계약</mark></p>', searchHighlight: { subject: [], attachments: {}, textBody: [] } }
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
const { wrapper, requests } = await mountAt('/mail/print/m1')
|
||||
|
||||
expect(requests[0]?.params).toEqual({})
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).not.toContain('<mark>')
|
||||
})
|
||||
|
||||
it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
|
||||
const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('예약됨 · 보낸편지함 (9단계)', () => {
|
||||
|
||||
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('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)}에 보낼 메일입니다.`)
|
||||
expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
|
||||
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 { createManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import highlightedTextSource from '@/components/mail/HighlightedText.vue?raw'
|
||||
import messageListItemSource from '@/components/mail/MessageListItem.vue?raw'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
|
||||
@@ -60,6 +72,8 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let results: MessageSummary[]
|
||||
/** 지난 메일 색인의 상태 (GET /search-index — 14단계) */
|
||||
let indexStatus: 'building' | 'ready'
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
@@ -68,8 +82,10 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
results = [summary('r1'), summary('r2')]
|
||||
indexStatus = 'ready'
|
||||
requests = installMockApi(({ url }) => {
|
||||
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
|
||||
if (url === '/search-index') return ok({ status: indexStatus, done: 10, total: indexStatus === 'ready' ? 10 : 40 })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
@@ -107,15 +123,18 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
it('결과가 없으면 "다른 말로 찾아 보세요."(걸린 조건이 없다)와 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
results = []
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
|
||||
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
|
||||
expect(wrapper.text()).not.toContain('조건을 줄여')
|
||||
// 색인이 준비됐으면 낱말 단위의 한계 안내 · 검색 준비 중 줄은 없다 (14단계)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(false)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -206,3 +225,441 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 · 검색 준비 중)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let handler: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** 2026년 11월 6일 오전 9시(브라우저 시간대) */
|
||||
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
|
||||
|
||||
const HIGHLIGHTED: MessageListItem = {
|
||||
...summary('r1', { subject: 'Re: 유지보수 계약 갱신 건', hasAttachment: true }),
|
||||
preview: '지난번 말씀하신 계약서 초안을 첨부했습니다.',
|
||||
searchHighlight: {
|
||||
subject: [[9, 11]],
|
||||
preview: { text: '…말씀하신 계약서 초안을 첨부했습니다.', ranges: [[6, 8]] },
|
||||
},
|
||||
}
|
||||
|
||||
function indexState(status: 'building' | 'ready'): MockResult {
|
||||
return ok({ status, done: 10, total: 40 })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
}
|
||||
requests = installMockApi((request) => handler(request))
|
||||
})
|
||||
|
||||
/** 그린 화면 — 테스트마다 떼어 낸다 (초점을 보는 테스트는 문서에 붙여 그린다) */
|
||||
let mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string, options: { attach?: boolean } = {}) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, {
|
||||
attachTo: options.attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
function heldResult() {
|
||||
let resolve: (value: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
const CONDITIONS_QUERY = `from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`
|
||||
|
||||
it('주소의 조건으로 찾고 찾을 곳 줄 아래에 "조건: …" 줄 — [조건 지우기]는 조건만 빼고 다시 찾는다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(listRequests()[0]?.params).toEqual({
|
||||
q: '회의',
|
||||
mailboxId: 'inbox',
|
||||
from: '이도윤',
|
||||
after: new Date(2026, 9, 6).toISOString(),
|
||||
before: new Date(2026, 10, 7).toISOString(),
|
||||
hasAttachment: 'true',
|
||||
limit: 20,
|
||||
})
|
||||
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
|
||||
'조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
})
|
||||
|
||||
it('조건만으로 찾으면 머리는 검색어 없이 수만 — 결과 없음은 "조건에 맞는 메일이 없습니다"', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('0통')
|
||||
expect(wrapper.text()).toContain('조건에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain('조건을 바꾸거나 줄여 보세요.')
|
||||
})
|
||||
|
||||
it('검색어와 조건으로 찾아 결과가 없으면 "다른 말로 찾거나 조건을 줄여 보세요."', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
|
||||
})
|
||||
|
||||
it('조건 줄 — 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일", 하루만 고른 기간은 "… 하루"', async () => {
|
||||
const mine = `from=${encodeURIComponent('김아라')}&to=${encodeURIComponent(USER.email.toUpperCase())}&since=2026-11-06&until=2026-11-06`
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${mine}`)
|
||||
|
||||
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
|
||||
'조건: 내가 보낸 메일, 내가 받은 메일, 2026. 11. 6. 하루',
|
||||
)
|
||||
})
|
||||
|
||||
it('total 이 null 이면 통 수를 보이지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
? indexState('ready')
|
||||
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’')
|
||||
})
|
||||
|
||||
it('결과 줄은 세 줄 — 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기, 맞은 말은 <mark> (v-html 없이)', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const row = wrapper.get('.message-row')
|
||||
expect(row.classes()).toContain('message-row--search')
|
||||
expect(row.get('.message-row__subject').text()).toBe('Re: 유지보수 계약 갱신 건')
|
||||
expect(row.get('.message-row__subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
const preview = row.get('[data-row-preview]')
|
||||
expect(preview.text()).toBe('…말씀하신 계약서 초안을 첨부했습니다.')
|
||||
expect(preview.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(row.find('[aria-label="첨부 있음"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('미리 보기 줄의 맞은 말만 글자색을 진하게(--color-text-secondary) — 흐린 글자 위 노랑은 대비가 모자라다, 제목 줄의 강조는 글자색 그대로 (2026-11-07)', () => {
|
||||
// jsdom 은 컴포넌트의 style 을 적용하지 않는다 — 규칙을 원본에서 읽는다 (주석은 뺀다)
|
||||
const rulesOf = (source: string) =>
|
||||
[...source.slice(source.indexOf('<style')).replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^}]*)\}/g)].map(
|
||||
([, selector = '', body = '']) => ({ selector: selector.replace('<style scoped>', '').trim(), body }),
|
||||
)
|
||||
|
||||
// 강조 자체는 글자색을 물려받는다 — 제목 줄 · 읽기 화면 · 첨부 이름은 진한 글자 위다
|
||||
const mark = rulesOf(highlightedTextSource).find((rule) => rule.selector === '.search-mark')
|
||||
expect(mark?.body).toMatch(/color:\s*inherit;/)
|
||||
// 목록 줄은 미리 보기 줄 안의 강조만 바꾼다 (제목 줄의 강조를 바꾸는 규칙은 없다)
|
||||
const overrides = rulesOf(messageListItemSource).filter((rule) => rule.selector.includes('search-mark'))
|
||||
expect(overrides.map((rule) => rule.selector)).toEqual(['.message-row__preview :deep(.search-mark)'])
|
||||
expect(overrides[0]?.body).toMatch(/color:\s*var\(--color-text-secondary\);/)
|
||||
})
|
||||
|
||||
it('강조가 없으면(엔진 방식 — 색인을 준비하는 중) 글 그대로 — 미리 보기는 메일의 미리 보기, 결과 머리 아래 "검색 준비 중" 줄', async () => {
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('building')
|
||||
return ok({
|
||||
items: [{ ...HIGHLIGHTED, searchHighlight: null }],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
|
||||
})
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const row = wrapper.get('.message-row')
|
||||
expect(row.findAll('mark')).toHaveLength(0)
|
||||
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
|
||||
const notice = wrapper.get('[data-search-notice="indexing"]')
|
||||
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
|
||||
expect(notice.text()).toBe(
|
||||
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
)
|
||||
expect(notice.element.closest('[role="status"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중의 결과 없음에는 찾지 못하는 예를 더한다 — 위 "검색 준비 중" 줄과 같은 말을 되풀이하지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('building') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
|
||||
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
|
||||
})
|
||||
|
||||
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? fail(503, 'SYS_001') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('[data-search-notice]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('한 글자 낱말뿐이면(색인 방식) 결과 머리에 한 줄 — 본문은 찾지 않았다', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-notice="single-char"]').text()).toBe(
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
|
||||
)
|
||||
|
||||
const longer = await mountAt(`/mail/search?q=${encodeURIComponent('김 계약')}&in=all`)
|
||||
expect(longer.wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중에는 한 글자 안내를 보이지 않는다 — 낱말 단위로 찾는 동안은 "검색 준비 중" 줄만', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
? indexState('building')
|
||||
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
|
||||
|
||||
expect(wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('[더 찾기]는 찾는 동안 aria-busy · "찾는 중…"(다시 눌러도 묻지 않는다) — 찾아서 사라지면 초점을 목록 칸으로', async () => {
|
||||
let next = 0
|
||||
let hold: ReturnType<typeof heldResult> | null = null
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url !== '/messages') return ok(detail('r1'))
|
||||
next += 1
|
||||
if (hold) return hold.promise
|
||||
return ok({ items: [], pageInfo: { hasNext: true, nextCursor: `c${next}`, total: null } })
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`, { attach: true })
|
||||
const more = wrapper.get('[data-search-more]')
|
||||
;(more.element as HTMLButtonElement).focus()
|
||||
|
||||
hold = heldResult()
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(more.attributes('aria-busy')).toBe('true')
|
||||
expect(more.attributes('aria-disabled')).toBe('true')
|
||||
expect(more.text()).toBe('찾는 중…')
|
||||
const sent = listRequests().length
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listRequests().length).toBe(sent)
|
||||
|
||||
hold.resolve(ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(1)
|
||||
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
|
||||
})
|
||||
|
||||
it('조건만 걸린 검색에서 조건 줄의 [조건 지우기]는 검색을 닫는다 — 검색 전 메일함으로 (패널의 [조건 지우기]는 칸만 비운다 — 2026-11-07)', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('조건 줄의 [조건 지우기]를 누르면 줄이 사라진다 — 초점은 목록 칸으로', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`, { attach: true })
|
||||
const clear = wrapper.get('[data-search-condition-line-clear]')
|
||||
;(clear.element as HTMLButtonElement).focus()
|
||||
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
|
||||
})
|
||||
|
||||
it('검색어와 조건의 낱말이 8개를 넘는 주소(손으로 고친 주소)는 찾지 않고 "찾을 말이 너무 많습니다" — 연 메일은 강조 없이 받는다', async () => {
|
||||
const words = encodeURIComponent('하나 둘 셋 넷 다섯')
|
||||
const body = encodeURIComponent('여섯 일곱 여덟 아홉')
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${words}&in=inbox&body=${body}`)
|
||||
|
||||
expect(listRequests()).toEqual([])
|
||||
const empty = wrapper.get('[data-search-too-many-words]')
|
||||
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
|
||||
expect(empty.text()).toContain(
|
||||
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
|
||||
)
|
||||
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
wrapper.unmount()
|
||||
mounted = []
|
||||
|
||||
// 그 주소로 연 메일은 강조 쿼리 없이 받는다 — 서버가 400 으로 거절해 메일을 열지 못하는 일이 없게
|
||||
const opened = await mountAt(`/mail/search/r1?q=${words}&in=inbox&body=${body}`)
|
||||
expect(requests.filter((request) => request.url === '/messages/r1').map((request) => request.params)).toEqual([{}])
|
||||
expect(opened.wrapper.find('#reader-subject').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('주소의 날짜가 거꾸로면 기간을 버리고, 고를 수 없는 날짜(1970-01-01 ~ 9999-12-30 밖)는 그 날짜만 버린다 — 나머지 조건으로 찾는다', async () => {
|
||||
const reversed = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-06&until=2026-10-01&attachment=1`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', hasAttachment: 'true', limit: 20 })
|
||||
expect(reversed.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 첨부 있음')
|
||||
reversed.wrapper.unmount()
|
||||
mounted = []
|
||||
requests.length = 0
|
||||
|
||||
await mountAt(`/mail/search?q=${Q}&in=all&since=1969-12-31&until=2026-11-01`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', before: new Date(2026, 10, 2).toISOString(), limit: 20 })
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
requests.length = 0
|
||||
|
||||
const outside = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-01&until=9999-12-31`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', after: new Date(2026, 10, 1).toISOString(), limit: 20 })
|
||||
expect(outside.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 2026. 11. 1.부터')
|
||||
})
|
||||
|
||||
it('빈 쪽이 이어져 아직 못 찾았으면 결과 없음 대신 [더 찾기] — 누르면 이어 찾고, 더 없으면 결과 없음', async () => {
|
||||
let next = 0
|
||||
let exhausted = false
|
||||
handler = ({ url, params }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url !== '/messages') return ok(detail('r1'))
|
||||
next += 1
|
||||
const done = exhausted && params.cursor !== undefined
|
||||
return ok({ items: [], pageInfo: { hasNext: !done, nextCursor: done ? null : `c${next}`, total: null } })
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
|
||||
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
|
||||
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
|
||||
const sent = listRequests().length
|
||||
|
||||
exhausted = true
|
||||
await wrapper.get('[data-search-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests().length).toBe(sent + 1)
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
|
||||
handler = ({ url, params }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
const highlighted = params.q !== undefined
|
||||
return ok({
|
||||
...detail('r1'),
|
||||
subject: 'Re: 유지보수 계약 갱신 건',
|
||||
htmlBody: null,
|
||||
textBody: '계약서 초안을 첨부했습니다.',
|
||||
attachments: [{ id: 'a1', name: '유지보수_계약서.docx', type: 'application/msword', size: 100, isInline: false }],
|
||||
searchHighlight: highlighted ? { subject: [[9, 11]], attachments: { a1: [[5, 7]] }, textBody: [[0, 2]] } : null,
|
||||
})
|
||||
}
|
||||
const { wrapper, router } = await mountAt(`/mail/search/r1?q=${Q}&in=inbox&${CONDITIONS_QUERY}&subject=${encodeURIComponent('갱신')}`)
|
||||
|
||||
const detailRequest = requests.find((request) => request.url === '/messages/r1')
|
||||
expect(detailRequest?.params).toEqual({ q: '회의', from: '이도윤', subject: '갱신' })
|
||||
expect(wrapper.get('#reader-subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.get('.attachments__name').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<mark>계약</mark>서 초안을 첨부했습니다.')
|
||||
|
||||
// 같은 메일을 메일함에서 열면 강조가 든 본문을 쓰지 않고 쿼리 없이 다시 받는다
|
||||
await router.push('/mail/inbox/r1')
|
||||
await flushPromises()
|
||||
const plain = requests.filter((request) => request.url === '/messages/r1').at(-1)
|
||||
expect(plain?.params).toEqual({})
|
||||
expect(wrapper.get('#reader-subject').findAll('mark')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailboxPage — 14단계 검색 · 목록 보기', () => {
|
||||
let pinia: Pinia
|
||||
let total: number | null
|
||||
|
||||
const HIGHLIGHTED: MessageListItem = {
|
||||
...summary('r1', { subject: '견적서와 계약 조건 정리' }),
|
||||
searchHighlight: { subject: [[5, 7]], preview: { text: '', ranges: [] } },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
total = 1
|
||||
installMockApi(({ url }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
|
||||
if (url === '/messages') {
|
||||
return ok({ items: [HIGHLIGHTED, summary('r2')], pageInfo: { hasNext: false, nextCursor: null, total } })
|
||||
}
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('표의 제목에도 맞은 말을 <mark> 로 — 행 높이는 그대로(미리 보기 줄은 분할 보기만)', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const link = wrapper.get('tbody .mail-table__link')
|
||||
expect(link.text()).toBe('견적서와 계약 조건 정리')
|
||||
expect(link.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.find('[data-row-preview]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('total 이 null 이면 연 메일의 위치 "n / 전체"를 보이지 않는다 — 이전 · 다음은 그대로', async () => {
|
||||
total = null
|
||||
const { wrapper } = await mountAt(`/mail/search/r1?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('.nav-position').exists()).toBe(false)
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { routes } from '@/router/routes'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type {
|
||||
ConversationSummary,
|
||||
Mailbox,
|
||||
MailSettings,
|
||||
MessageDetail,
|
||||
MessagesPatch,
|
||||
MessageSummary,
|
||||
} from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { groupByConversationHintKey } from '@/stores/mail-settings.store'
|
||||
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
|
||||
// 14단계 3차 — 목록의 "대화로 묶어 보기" (디자인 원본 ListThreads14 · 계약 2절 "대화로 묶어 보기")
|
||||
// 실제 메일 화면(MailLayout → MailboxPage → 목록 · 읽기 화면)을 띄운다 — 설정(groupByConversation)이 목록 출처를 정한다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const ME = { name: '김아라', email: USER.email }
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
|
||||
const HR = { name: '인사팀', email: 'hr@arasystem.test' }
|
||||
const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: DOYUN,
|
||||
to: [ME],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
// 한국 시간 · UTC 의 날짜가 같은 시각 (시간대와 상관없이 같은 글)
|
||||
receivedAt: '2026-10-20T03:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(base: MessageSummary): MessageDetail {
|
||||
return {
|
||||
...base,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: `${base.id} 본문`,
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
|
||||
return {
|
||||
id,
|
||||
latest: summary(messageIds[0] ?? id),
|
||||
participants: [DOYUN],
|
||||
messageCount: messageIds.length,
|
||||
messageIds,
|
||||
pendingSendIds: [],
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const ROWS: ConversationSummary[] = [
|
||||
conversation('c1', ['m6', 'm5'], {
|
||||
latest: summary('m6', { subject: 'Re: 전사 회의 참석 안내', isRead: false, hasAttachment: true }),
|
||||
participants: [HR, ME, MINSU],
|
||||
messageCount: 6,
|
||||
isRead: false,
|
||||
hasAttachment: true,
|
||||
}),
|
||||
conversation('c2', ['m4'], { latest: summary('m4', { subject: '11월 비용 정산 안내' }) }),
|
||||
conversation('c3', ['m3', 'm2'], { latest: summary('m3', { subject: 'Re: 유지보수 계약 갱신 건' }), participants: [ME, DOYUN] }),
|
||||
]
|
||||
|
||||
const page = (items: unknown[], total: number | null = items.length) =>
|
||||
ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
|
||||
|
||||
describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
let settings: MailSettings
|
||||
let rows: ConversationSummary[]
|
||||
let state: string
|
||||
let patchSettings: ((request: MockRequest) => MockResult) | null
|
||||
/** GET /mail-settings 의 응답을 바꾼다 (받지 못함 등) */
|
||||
let loadSettings: (() => MockResult) | null
|
||||
/** GET /messages 의 응답을 바꾼다 (붙잡아 두기 등) */
|
||||
let listMessages: (() => MockResult | Promise<MockResult>) | null
|
||||
/** 읽기 화면이 여는 메일(GET /messages/{id})의 메일함 — 처음은 받은편지함 */
|
||||
let openedMailboxIds: string[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
|
||||
settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
|
||||
rows = ROWS
|
||||
state = 's1'
|
||||
patchSettings = null
|
||||
loadSettings = null
|
||||
listMessages = null
|
||||
openedMailboxIds = ['inbox']
|
||||
requests = installMockApi((request) => {
|
||||
const { url, method } = request
|
||||
if (url === '/mail-state') return ok({ state })
|
||||
if (url === '/mailboxes') {
|
||||
return page([
|
||||
mailbox('inbox', 'inbox', '받은편지함', 9, 2),
|
||||
mailbox('drafts', 'drafts', '임시보관함', 1, 0),
|
||||
mailbox('trash', 'trash', '휴지통', 0, 0),
|
||||
])
|
||||
}
|
||||
if (url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
if (url === '/mail-signature') {
|
||||
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
|
||||
}
|
||||
if (url === '/mail-vacation') {
|
||||
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
|
||||
}
|
||||
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
|
||||
if (url === '/mail-settings' && method === 'GET') return loadSettings ? loadSettings() : ok(settings)
|
||||
if (url === '/mail-settings' && method === 'PATCH') {
|
||||
if (patchSettings) return patchSettings(request)
|
||||
settings = { ...settings, ...(request.data as Partial<MailSettings>) }
|
||||
return ok(settings)
|
||||
}
|
||||
if (url === '/scheduled-messages') return page([])
|
||||
if (url === '/messages' && method === 'PATCH') {
|
||||
const data = request.data as MessagesPatch
|
||||
// 옮긴 메일은 서버의 묶은 목록에서도 빠진다 — 다시 맞춘 목록이 옛 줄을 되살리지 않게
|
||||
if (data.mailboxId) {
|
||||
rows = rows
|
||||
.map((row) => {
|
||||
const messageIds = row.messageIds.filter((id) => !data.ids.includes(id))
|
||||
const latestId = messageIds[0]
|
||||
return latestId && latestId !== row.latest.id ? { ...row, messageIds, latest: summary(latestId) } : { ...row, messageIds }
|
||||
})
|
||||
.filter((row) => row.messageIds.length > 0)
|
||||
}
|
||||
return ok({ updatedIds: data.ids, failedIds: [] })
|
||||
}
|
||||
if (url === '/message-deletions') return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
if (url === '/conversations') return ok({ items: rows, pageInfo: { hasNext: false, nextCursor: null, total: 6 }, basis: 'index' })
|
||||
if (url === '/messages' && method === 'GET') {
|
||||
return listMessages ? listMessages() : page([summary('m6', { isRead: false }), summary('m4')], 9)
|
||||
}
|
||||
if (url.endsWith('/conversation')) return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 1 }, basis: 'index' })
|
||||
const opened = /^\/messages\/([^/]+)$/.exec(url)?.[1]
|
||||
if (opened && method === 'GET') return ok(detail(summary(opened, { mailboxIds: openedMailboxIds })))
|
||||
return ok(null)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
for (let round = 0; round < 4; round += 1) await flushPromises()
|
||||
}
|
||||
|
||||
async function mountMail(path = '/mail/inbox'): Promise<{ wrapper: VueWrapper; router: Router }> {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(
|
||||
{ render: () => h(RouterView) },
|
||||
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
|
||||
)
|
||||
mounted.push(wrapper)
|
||||
await settle()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
/** 이 브라우저가 마지막으로 안 묶어 보기 값 — 설정을 받기 전의 짐작 (mail-settings.store) */
|
||||
function rememberGrouped(grouped = true) {
|
||||
window.localStorage.setItem(groupByConversationHintKey(USER.email), String(grouped))
|
||||
}
|
||||
|
||||
const threadRequests = () => requests.filter((request) => request.url === '/conversations')
|
||||
const messageListRequests = () => requests.filter((request) => request.url === '/messages' && request.method === 'GET')
|
||||
/** 목록 머리의 개수 칸 — 보이는 글 · 화면 낭독 글 · title (분할 보기는 줄인 글을 보인다) */
|
||||
const headCount = (wrapper: VueWrapper) => {
|
||||
const count = wrapper.get('.list-header__count')
|
||||
const shown = count.find('[aria-hidden="true"]')
|
||||
const spoken = count.find('.visually-hidden')
|
||||
return {
|
||||
shown: shown.exists() ? shown.text() : count.text(),
|
||||
spoken: spoken.exists() ? spoken.text() : count.text(),
|
||||
title: count.attributes('title'),
|
||||
}
|
||||
}
|
||||
const rowTexts = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('.thread-row').map((row) => ({
|
||||
who: row.get('.thread-row__who').text(),
|
||||
count: row.find('[data-thread-count]').exists() ? row.get('[data-thread-count]').text() : null,
|
||||
subject: row.get('.thread-row__subject').text(),
|
||||
unread: row.classes('is-unread'),
|
||||
}))
|
||||
|
||||
it('켜져 있으면 묶은 목록 — 참여자(나는 끝에) · 통 수(한 통이면 없음) · 마지막 메일 제목 · 하나라도 안 읽었으면 굵게, 머리는 대화 수', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
expect(threadRequests().map((request) => request.params)).toEqual([{ mailboxId: 'inbox', limit: 20 }])
|
||||
// 짐작(이 브라우저가 마지막으로 안 값)이 있으면 메일 목록을 받았다 버리지 않는다
|
||||
expect(messageListRequests()).toHaveLength(0)
|
||||
expect(rowTexts(wrapper)).toEqual([
|
||||
{ who: '안 읽음, 인사팀, 김민수, 나', count: '6통', subject: 'Re: 전사 회의 참석 안내', unread: true },
|
||||
{ who: '이도윤', count: null, subject: '11월 비용 정산 안내', unread: false },
|
||||
{ who: '이도윤, 나', count: '2통', subject: 'Re: 유지보수 계약 갱신 건', unread: false },
|
||||
])
|
||||
expect(wrapper.findAll('.thread-row')[0]?.find('[aria-label="첨부 있음"]').exists()).toBe(true)
|
||||
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 그 수만 보이고 전체 글은 title · 화면 낭독에 (2026-11-06 실사용 시험)
|
||||
expect(headCount(wrapper)).toEqual({
|
||||
shown: '안 읽은 메일 2통',
|
||||
spoken: '대화 6개, 안 읽은 메일 2통',
|
||||
title: '대화 6개, 안 읽은 메일 2통',
|
||||
})
|
||||
const toggle = wrapper.get('[data-group-toggle]')
|
||||
expect(toggle.attributes('aria-pressed')).toBe('true')
|
||||
// 분할 보기의 목록 칸은 아이콘만 — 이름은 title · 화면 낭독
|
||||
expect(toggle.attributes('title')).toBe('대화로 묶기')
|
||||
expect(toggle.text()).toBe('대화로 묶기')
|
||||
})
|
||||
|
||||
it('처음 이 브라우저(짐작 없음)면 메일 목록으로 열었다가 설정을 받으면 묶은 목록으로 — 받은 값을 짐작으로 기억한다', async () => {
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
expect(messageListRequests()).toHaveLength(1)
|
||||
expect(threadRequests()).toHaveLength(1)
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
|
||||
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('true')
|
||||
})
|
||||
|
||||
it('짐작과 서버 값이 다르면 서버 값이 이긴다 — 다른 PC 에서 껐다', async () => {
|
||||
rememberGrouped()
|
||||
settings = { ...settings, groupByConversation: false }
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('false')
|
||||
})
|
||||
|
||||
it('[대화로 묶기]를 누르면 저장하고 곧바로 바꾼다 — 끄면 메일 목록과 "메일 9통, 안 읽은 메일 2통", 다시 켜면 처음 쪽부터', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
await wrapper.get('[data-group-toggle]').trigger('click')
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.data)).toEqual([
|
||||
{ groupByConversation: false },
|
||||
])
|
||||
expect(messageListRequests()).toHaveLength(1)
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
expect(headCount(wrapper)).toEqual({
|
||||
shown: '안 읽은 메일 2통',
|
||||
spoken: '메일 9통, 안 읽은 메일 2통',
|
||||
title: '메일 9통, 안 읽은 메일 2통',
|
||||
})
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
|
||||
|
||||
await wrapper.get('[data-group-toggle]').trigger('click')
|
||||
await settle()
|
||||
expect(threadRequests()).toHaveLength(2)
|
||||
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('저장하지 못하면 묶은 목록으로 되돌린다 — 알림은 인터셉터가', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
patchSettings = () => fail(400, 'VAL_001')
|
||||
|
||||
await wrapper.get('[data-group-toggle]').trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
})
|
||||
|
||||
it('줄을 누르면 그 메일함의 마지막 메일을 연다 — 연 메일이 든 줄이 골라진 모양', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper, router } = await mountMail()
|
||||
|
||||
await wrapper.findAll('.thread-row__main')[2]?.trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
|
||||
expect(wrapper.get('#reader-subject').text()).toBe('제목 m3')
|
||||
expect(wrapper.findAll('.thread-row')[2]?.classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('메일이 모두 보내기를 기다리는 줄은 고를 수 없다 — 고르기 칸 · 별이 aria-disabled 이고 누르면 아무것도 하지 않는다', async () => {
|
||||
rememberGrouped()
|
||||
rows = [conversation('held', ['m9'], { pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
|
||||
const { wrapper } = await mountMail()
|
||||
const heldRow = wrapper.findAll('.thread-row')[0]
|
||||
|
||||
expect(heldRow?.get('[role="checkbox"]').attributes('aria-disabled')).toBe('true')
|
||||
expect(heldRow?.get('[data-thread-star]').attributes('aria-disabled')).toBe('true')
|
||||
await heldRow?.get('[role="checkbox"]').trigger('click')
|
||||
await heldRow?.get('[data-thread-star]').trigger('click')
|
||||
await settle()
|
||||
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
|
||||
expect(requests.filter((request) => request.method === 'PATCH')).toEqual([])
|
||||
})
|
||||
|
||||
it('고르면 도구 줄 "대화 1개 선택됨" — 분할 보기는 "1개 선택됨"만 보이고 화면 낭독은 전체, 하는 일은 그 줄의 메일 모두', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
|
||||
// 단추 일곱과 한 줄에 들게 보이는 글만 줄인다 (2026-11-06 실사용 시험 — "대화 1개 선택됨"이면 두 줄로 넘쳤다)
|
||||
const count = wrapper.get('.bulk-bar__count')
|
||||
expect(count.get('[aria-hidden="true"]').text()).toBe('1개 선택됨')
|
||||
expect(count.get('.visually-hidden').text()).toBe('대화 1개 선택됨')
|
||||
expect(count.attributes('id')).toBe(wrapper.get('.mail-list-frame').attributes('aria-labelledby'))
|
||||
// 단추 차례는 그대로
|
||||
expect(wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))).toEqual([
|
||||
'markRead',
|
||||
'markUnread',
|
||||
'flag',
|
||||
'move',
|
||||
'delete',
|
||||
'clear',
|
||||
])
|
||||
|
||||
await wrapper.get('[data-tool="markRead"]').trigger('click')
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m6', 'm5'], isRead: true },
|
||||
])
|
||||
expect(wrapper.findAll('.thread-row')[0]?.classes()).not.toContain('is-unread')
|
||||
})
|
||||
|
||||
it('줄에 한 삭제의 알림은 "받은편지함에 있는 메일 N통을 휴지통으로 …" — 줄의 통 수는 대화 전체라서, 되돌리면 "메일 N통을 받은편지함으로 되돌렸습니다."', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m6', 'm5'], mailboxId: 'trash' },
|
||||
])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').at(-1)?.data).toEqual({
|
||||
ids: ['m6', 'm5'],
|
||||
mailboxId: 'inbox',
|
||||
})
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창 · 알림은 "휴지통에 있는 메일 N통을 …"(줄의 통 수는 대화 전체라서)이고 그 줄의 메일을 POST /message-deletions', async () => {
|
||||
rememberGrouped()
|
||||
rows = [
|
||||
conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }), messageCount: 5 }),
|
||||
conversation('t2', ['m1']),
|
||||
]
|
||||
const { wrapper } = await mountMail('/mail/trash')
|
||||
// 줄은 대화 전체의 "5통" — 지우는 것은 휴지통의 2통이다 (2026-11-07)
|
||||
expect(wrapper.findAll('.thread-row')[0]?.get('[data-thread-count]').text()).toBe('5통')
|
||||
|
||||
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await settle()
|
||||
const dialog = wrapper.get('#bulk-purge-title')
|
||||
expect(dialog.text()).toBe('휴지통에 있는 메일 2통을 영구 삭제할까요?')
|
||||
expect(wrapper.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
|
||||
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m8', 'm7'] },
|
||||
])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('휴지통에 있는 메일 2통을 영구 삭제했습니다.')
|
||||
})
|
||||
|
||||
it('묶은 휴지통이어도 읽기 화면의 영구 삭제(한 통)는 지금 글 그대로 — "메일 1통을 영구 삭제할까요?" · "메일 1통을 영구 삭제했습니다."', async () => {
|
||||
rememberGrouped()
|
||||
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }), messageCount: 5 })]
|
||||
openedMailboxIds = ['trash']
|
||||
const { wrapper } = await mountMail('/mail/trash/m8')
|
||||
|
||||
const remove = wrapper.get('[data-reader-delete]')
|
||||
expect(remove.text()).toBe('영구 삭제')
|
||||
await remove.trigger('click')
|
||||
await settle()
|
||||
expect(wrapper.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
|
||||
|
||||
// 고른 줄이 없어 도구 줄(과 그 확인 창)은 없다 — 확인 단추는 읽기 화면의 것 하나
|
||||
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m8'] },
|
||||
])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('메일 1통을 영구 삭제했습니다.')
|
||||
})
|
||||
|
||||
it('목록 보기 — 크기 칸이 없고 머리 고르기는 "불러온 대화 모두 고르기". 연 메일의 위치는 대화 기준이고 [다음]은 다음 대화의 마지막 메일', async () => {
|
||||
rememberGrouped()
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
const { wrapper, router } = await mountMail()
|
||||
|
||||
const heads = wrapper.findAll('table.mail-table thead th').map((th) => th.text())
|
||||
expect(heads).not.toContain('크기')
|
||||
// 사람 칸 머리는 어느 메일함이든 "참여자" — 줄이 대화의 참여자다 (2026-11-06 실사용 시험)
|
||||
expect(heads).toContain('참여자')
|
||||
expect(heads).not.toContain('보낸 사람')
|
||||
// 목록 보기(넓은 머리)는 개수 글을 다 보인다
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
|
||||
expect(wrapper.find('.list-header__count .visually-hidden').exists()).toBe(false)
|
||||
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 대화 모두 고르기')
|
||||
expect(wrapper.get('table.mail-table caption').text()).toBe('받은편지함 대화 목록')
|
||||
expect(wrapper.findAll('tbody tr').map((row) => row.get('[data-message-id]').attributes('data-message-id'))).toEqual([
|
||||
'm6',
|
||||
'm4',
|
||||
'm3',
|
||||
])
|
||||
// 목록 보기에서는 이름까지
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('title')).toBeUndefined()
|
||||
|
||||
await router.push('/mail/inbox/m4')
|
||||
await settle()
|
||||
expect(wrapper.get('.nav-position').text()).toBe('2 / 6')
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
|
||||
})
|
||||
|
||||
it('새 메일 확인 — 바뀌었으면 묶은 목록을 불러온 줄 수만큼 조용히 다시 받는다', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
expect(threadRequests()).toHaveLength(1)
|
||||
|
||||
rows = [conversation('c9', ['m9'], { latest: summary('m9', { subject: '새 대화' }) }), ...ROWS]
|
||||
state = 's2'
|
||||
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await settle()
|
||||
|
||||
expect(threadRequests()).toHaveLength(2)
|
||||
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
|
||||
expect(threadRequests()[1]?.config.silent).toBe(true)
|
||||
expect(messageListRequests()).toHaveLength(0)
|
||||
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c9', 'c1', 'c2', 'c3'])
|
||||
})
|
||||
|
||||
it('다른 탭에서 끈 묶기 — 60초 뒤 탭으로 돌아오면 설정을 조용히 다시 받아 메일 목록으로 다시 연다 (2026-11-06 실사용 시험)', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
|
||||
const settingsRequests = () => requests.filter((request) => request.url === '/mail-settings' && request.method === 'GET')
|
||||
expect(settingsRequests()).toHaveLength(1)
|
||||
|
||||
// 다른 탭이 껐다
|
||||
settings = { ...settings, groupByConversation: false }
|
||||
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await settle()
|
||||
expect(settingsRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(1)
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await settle()
|
||||
expect(settingsRequests()).toHaveLength(2)
|
||||
expect(settingsRequests()[1]?.config.silent).toBe(true)
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
describe('세션이 끝나면 목록을 부르지 않는다 (리뷰 M1)', () => {
|
||||
it.each([
|
||||
['로그아웃', () => useAuthStore().clearAuth()],
|
||||
['세션 만료', () => useAuthStore().expireSession()],
|
||||
])('묶기를 켠 채 %s — 메일 목록 · 묶은 목록 요청이 0건이고 비운 목록이 다시 차지 않는다', async (_name, end) => {
|
||||
rememberGrouped()
|
||||
await mountMail()
|
||||
const before = { messages: messageListRequests().length, threads: threadRequests().length }
|
||||
|
||||
end()
|
||||
await settle()
|
||||
|
||||
expect({ messages: messageListRequests().length, threads: threadRequests().length }).toEqual(before)
|
||||
expect(useMailStore().conversations).toEqual([])
|
||||
expect(useMailStore().listSource).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('읽기 화면 [삭제] 뒤 갈 곳 — 분할 보기 (리뷰 L3)', () => {
|
||||
it('그 줄에 메일이 남으면 그 줄의 다음 칸(더 오래된 메일), 줄이 비면 다음 줄의 마지막 메일', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper, router } = await mountMail('/mail/inbox/m6')
|
||||
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m6'], mailboxId: 'trash' },
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m5')
|
||||
expect(wrapper.findAll('.thread-row')[0]?.classes()).toContain('is-selected')
|
||||
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
|
||||
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c2', 'c3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('목록 보기의 읽기 화면 — 대화 사이를 오간다', () => {
|
||||
beforeEach(() => {
|
||||
rememberGrouped()
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
})
|
||||
|
||||
it('줄의 마지막 메일이 아닌 메일도 그 메일이 든 줄의 위치 — [다음]은 다음 대화, [이전]은 앞 대화의 마지막 메일', async () => {
|
||||
const { wrapper, router } = await mountMail('/mail/inbox/m5')
|
||||
|
||||
expect(wrapper.get('.nav-position').text()).toBe('1 / 6')
|
||||
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
|
||||
|
||||
await router.push('/mail/inbox/m2')
|
||||
await settle()
|
||||
expect(wrapper.get('.nav-position').text()).toBe('3 / 6')
|
||||
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
|
||||
})
|
||||
|
||||
it('[목록으로] — 표로 돌아가 그 메일이 든 줄의 링크(마지막 메일)로 초점을 돌려준다', async () => {
|
||||
const { wrapper, router } = await mountMail('/mail/inbox/m5')
|
||||
|
||||
const back = wrapper.findAll('a').find((link) => link.text() === '목록으로')
|
||||
await back?.trigger('click', { button: 0 })
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(document.activeElement?.getAttribute('data-message-id')).toBe('m6')
|
||||
})
|
||||
|
||||
it('메일이 모두 보내기를 기다리는 줄 — Ctrl · Shift + 클릭으로도 고르지 않고, 날짜 칸은 예약 표시', async () => {
|
||||
rows = [
|
||||
conversation('held', ['m9'], {
|
||||
latest: summary('m9', { pendingSend: { sendAt: '2026-11-10T01:00:00Z', kind: 'scheduled' } }),
|
||||
pendingSendIds: ['m9'],
|
||||
}),
|
||||
...ROWS,
|
||||
]
|
||||
const { wrapper } = await mountMail()
|
||||
const heldRow = wrapper.get('tr[data-row-id="held"]')
|
||||
|
||||
await heldRow.get('td.mail-table__subject').trigger('click', { ctrlKey: true })
|
||||
await heldRow.get('td.mail-table__subject').trigger('click', { shiftKey: true })
|
||||
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
|
||||
expect(heldRow.get('td.mail-table__date').text()).toMatch(/^예약 · /)
|
||||
|
||||
// 다른 줄은 Ctrl + 클릭으로 고른다
|
||||
await wrapper.get('tr[data-row-id="c2"] td.mail-table__subject').trigger('click', { ctrlKey: true })
|
||||
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
|
||||
})
|
||||
})
|
||||
|
||||
it('빠르게 켬 → 끔 → 켬 — 마지막 목록만 남고 버린 메일 목록 요청은 끊는다 (리뷰 L7)', async () => {
|
||||
rememberGrouped()
|
||||
let releaseMessages: (result: MockResult) => void = () => undefined
|
||||
listMessages = () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
releaseMessages = resolve
|
||||
})
|
||||
const { wrapper } = await mountMail()
|
||||
const toggle = () => wrapper.get('[data-group-toggle]')
|
||||
|
||||
await toggle().trigger('click')
|
||||
await settle()
|
||||
expect(messageListRequests()).toHaveLength(1)
|
||||
await toggle().trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(messageListRequests()[0]?.config.signal?.aborted).toBe(true)
|
||||
releaseMessages(page([summary('late')], 1))
|
||||
await settle()
|
||||
expect(toggle().attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c1', 'c2', 'c3'])
|
||||
expect(settings.groupByConversation).toBe(true)
|
||||
})
|
||||
|
||||
it('설정을 받지 못했으면 짐작대로 묶고 [대화로 묶기]도 쓸 수 있다 — 저장 응답을 받은 설정으로 (리뷰 L4)', async () => {
|
||||
rememberGrouped()
|
||||
loadSettings = () => fail(503, 'SYS_001')
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await wrapper.get('[data-group-toggle]').trigger('click')
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/mail-settings').map((request) => request.data)).toEqual([
|
||||
{ groupByConversation: false },
|
||||
])
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('줄의 중요 표시가 보내기를 기다리는 메일에만 있으면 별로 풀리지 않는다 — 다시 맞춰 보고 그 까닭을 알린다', async () => {
|
||||
rememberGrouped()
|
||||
rows = [conversation('hf', ['m9', 'm8'], { isFlagged: true, pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
|
||||
const { wrapper } = await mountMail()
|
||||
const syncs = threadRequests().length
|
||||
|
||||
await wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m8'], isFlagged: false },
|
||||
])
|
||||
expect(threadRequests().length).toBe(syncs + 1)
|
||||
expect(wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').attributes('aria-pressed')).toBe('true')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
|
||||
])
|
||||
})
|
||||
|
||||
it('임시보관함의 제목 없는 저장본 — 흐린 "(제목 없음)", 굵게 하지 않고 누르면 쓰기 화면에서 이어 쓴다 (리뷰 L5)', async () => {
|
||||
rememberGrouped()
|
||||
rows = [
|
||||
conversation('d1', ['d1'], {
|
||||
latest: summary('d1', { subject: '', mailboxIds: ['drafts'], isRead: false }),
|
||||
participants: [ME],
|
||||
isRead: false,
|
||||
}),
|
||||
]
|
||||
const { wrapper } = await mountMail('/mail/drafts')
|
||||
const row = wrapper.get('.thread-row')
|
||||
|
||||
expect(row.get('.thread-row__subject').text()).toBe('(제목 없음)')
|
||||
expect(row.get('.thread-row__subject').classes()).toContain('is-placeholder')
|
||||
expect(row.classes()).not.toContain('is-unread')
|
||||
expect(row.get('.thread-row__who').text()).toBe('받는 사람: 김아라')
|
||||
expect(row.get('.thread-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
|
||||
})
|
||||
|
||||
it('검색 결과는 묶지 않는다 — 켜져 있어도 GET /messages 이고 [대화로 묶기]가 없다', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail('/mail/search?q=%ED%9A%8C%EC%9D%98&in=inbox')
|
||||
|
||||
expect(threadRequests()).toHaveLength(0)
|
||||
expect(messageListRequests()[0]?.params).toMatchObject({ q: '회의', mailboxId: 'inbox' })
|
||||
expect(wrapper.find('[data-group-toggle]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@
|
||||
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
|
||||
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
|
||||
// 예약됨(9단계)은 고르기 · 여러 통 처리가 없는 목록(ScheduledMessageList — 받는 사람 · 제목 · 보낼 시각)을 쓰고, 연 메일은 같은 본문이다
|
||||
// 대화로 묶어 보기(14단계 — 설정 groupByConversation)를 켰으면 메일함 목록의 출처가 GET /conversations 다 — 주소는 같고 한 줄이 대화 하나다.
|
||||
// 설정을 받기 전 · 받지 못했으면 이 브라우저의 짐작(mail-settings.store — 처음이면 끔)으로 연다(기다리게 하지 않는다) — 받아 보니 다르면 그때 바꾼다.
|
||||
// 켜고 끄면(목록 머리의 [대화로 묶기] · 설정 화면) 앞 목록의 요청을 끊고 처음 쪽부터 받는다. 검색 결과 · 예약됨은 묶지 않는다.
|
||||
// 로그아웃 · 세션 만료 · 제한된 세션(canUseApp false)에는 목록을 부르지 않는다 — 떠나는 화면이 막 비운 목록을 다시 채우지 않게 (리뷰 M1)
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -18,13 +22,18 @@ 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 { isScheduledRouteName, isSearchTooLong, listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { messageHighlightKey, messageHighlightQueryOf } from '@/shared/utils/mail-search'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
@@ -40,10 +49,13 @@ const isListView = computed(() => viewStore.mode === 'list')
|
||||
/** 목록이 있는 화면 — 메일함 · 검색 결과 · 예약됨 */
|
||||
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value || isScheduled.value)
|
||||
|
||||
// 메일함 · 묶어 보기가 바뀔 때 — 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 스토어가 보이던 채로 새로 고친다.
|
||||
// 세션이 끝났으면 부르지 않는다 (머리말 — 로그아웃은 스토어를 비우고 로그인 화면으로 간다)
|
||||
watch(
|
||||
mailboxId,
|
||||
(id) => {
|
||||
if (id) void mailStore.openMailbox(id)
|
||||
[mailboxId, () => settingsStore.groupByConversation, () => authStore.canUseApp],
|
||||
([id, grouped, usable]) => {
|
||||
if (!id || !usable) return
|
||||
void (grouped ? mailStore.openConversations(id) : mailStore.openMailbox(id))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -57,25 +69,37 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
// 검색어 · 범위 · 조건(14단계)이 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어도 조건도 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
|
||||
([searching, q, scope]) => {
|
||||
[isSearching, () => (routeSearch.value ? listKeyOf(routeSearch.value) : null)],
|
||||
([searching, key]) => {
|
||||
if (!searching) return
|
||||
if (q === null || scope === null) {
|
||||
const search = routeSearch.value
|
||||
if (key === null || !search) {
|
||||
void closeSearch()
|
||||
return
|
||||
}
|
||||
void mailStore.openSearch(q, scope)
|
||||
void mailStore.openSearch(search.q, search.scope, { conditions: search.conditions })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — 검색어와 글 조건 넷만). 메일함에서 연 메일은 null —
|
||||
* 쿼리가 바뀌면 같은 메일이어도 다시 받는다(검색 결과의 강조 본문이 메일함 화면에 남지 않게).
|
||||
* 낱말이 한도를 넘는 주소(손으로 고친 주소)면 싣지 않는다 — 서버가 400 으로 거절해 메일을 열지 못한다
|
||||
*/
|
||||
const highlightQuery = computed(() =>
|
||||
isSearching.value && routeSearch.value && !isSearchTooLong(routeSearch.value)
|
||||
? messageHighlightQueryOf(routeSearch.value.q, routeSearch.value.conditions)
|
||||
: null,
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
if (id) void mailStore.openMessage(id)
|
||||
[messageId, () => messageHighlightKey(highlightQuery.value)],
|
||||
([id]) => {
|
||||
if (id) void mailStore.openMessage(id, highlightQuery.value)
|
||||
else mailStore.closeMessage()
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -276,7 +276,7 @@ describe('MailLargeFilesPage', () => {
|
||||
|
||||
expect(view.get('[data-large-usage]').text()).toBe('0 / 10 GB')
|
||||
expect(view.get('[data-large-empty]').text()).toContain('링크로 보낸 파일이 없습니다')
|
||||
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다.')
|
||||
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB가 넘는 파일을 고르면 링크로 보냅니다.')
|
||||
expect(view.find('#large-unsent-title').exists()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import MailSendReadPage from './MailSendReadPage.vue'
|
||||
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
|
||||
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
|
||||
|
||||
interface Server {
|
||||
settings?: MailSettings
|
||||
settings?: Partial<MailSettings>
|
||||
/** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
|
||||
load?: MockResult
|
||||
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
@@ -118,6 +118,37 @@ describe('MailSendReadPage', () => {
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('대화로 묶기(14단계 — 목록 위 단추와 같은 이름) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const grouping = () =>
|
||||
wrapper.findAll('[data-group-by-conversation]').map((button) => [button.text(), button.attributes('aria-pressed')])
|
||||
|
||||
expect(wrapper.get('#send-read-group-title').text()).toBe('대화로 묶기')
|
||||
expect(grouping()).toEqual([
|
||||
['끔', 'true'],
|
||||
['켬', 'false'],
|
||||
])
|
||||
expect(wrapper.get('#send-read-group-hint').text()).toBe(
|
||||
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ groupByConversation: true }])
|
||||
expect(grouping()).toEqual([
|
||||
['끔', 'false'],
|
||||
['켬', 'true'],
|
||||
])
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('묶어 보기 필드가 없는 서버(14단계 앞)면 끔으로 그린다', async () => {
|
||||
const wrapper = await mountPage({ load: ok({ undoSendSeconds: 10, externalImages: 'ask' }) })
|
||||
|
||||
expect(wrapper.get('[data-group-by-conversation="false"]').attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('지금 값을 다시 누르면 보내지 않는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
// — /settings/mail/send-read · 탭 제목 "보내기 · 읽기"
|
||||
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
|
||||
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
|
||||
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자.
|
||||
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자 → 가는 선 →
|
||||
// 「대화로 묶기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정 · 같은 이름) + 설명.
|
||||
// - 들어올 때마다 새로 받는다(다른 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 {
|
||||
EXTERNAL_IMAGES_OPTIONS,
|
||||
GROUP_BY_CONVERSATION_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'
|
||||
|
||||
@@ -28,6 +34,10 @@ function chooseImages(option: ExternalImagesSetting) {
|
||||
void settingsStore.update('externalImages', option)
|
||||
}
|
||||
|
||||
function chooseGrouping(grouped: boolean) {
|
||||
void settingsStore.update('groupByConversation', grouped)
|
||||
}
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 값을 잠깐 그렸다 바꾸지 않게
|
||||
void settingsStore.load()
|
||||
</script>
|
||||
@@ -135,6 +145,44 @@ void settingsStore.load()
|
||||
{{ MAIL_SETTINGS_TEXT.imagesNote }}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<hr class="send-read__rule">
|
||||
|
||||
<section
|
||||
class="send-read__group"
|
||||
aria-labelledby="send-read-group-title"
|
||||
>
|
||||
<h2
|
||||
id="send-read-group-title"
|
||||
class="send-read__title"
|
||||
>
|
||||
{{ MAIL_SETTINGS_TEXT.groupTitle }}
|
||||
</h2>
|
||||
<div
|
||||
role="group"
|
||||
class="segmented segmented--outline send-read__undo"
|
||||
aria-labelledby="send-read-group-title"
|
||||
aria-describedby="send-read-group-hint"
|
||||
>
|
||||
<button
|
||||
v-for="grouped in GROUP_BY_CONVERSATION_OPTIONS"
|
||||
:key="String(grouped)"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="settingsStore.settings.groupByConversation === grouped"
|
||||
:data-group-by-conversation="String(grouped)"
|
||||
@click="chooseGrouping(grouped)"
|
||||
>
|
||||
{{ grouped ? MAIL_SETTINGS_TEXT.groupOn : MAIL_SETTINGS_TEXT.groupOff }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="send-read-group-hint"
|
||||
class="form-message"
|
||||
>
|
||||
{{ MAIL_SETTINGS_TEXT.groupHint }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('SharePage', () => {
|
||||
expect(view.get('.share__safety').text()).toBe(
|
||||
'이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
|
||||
)
|
||||
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.')
|
||||
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr(으)로 알려 주세요.')
|
||||
expect(view.get('.share__english').text()).toBe(
|
||||
'Files from 김아라 (ara.kim@arasystem.it.kr). Download by Nov 29, 2026 (KST). This page will never ask you to log in or enter a password.',
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { SHARE_PAGE_TEXT, SHARE_SCAN_POLL_DELAYS_MS, SHARE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
|
||||
import type { PublicLink, PublicLinkFile } from '@/shared/types/large-file'
|
||||
import { formatCount, formatFileSize } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
import {
|
||||
formatKstEnglishDate,
|
||||
formatKstFullDate,
|
||||
@@ -71,6 +72,10 @@ const view = computed(() => {
|
||||
return 'active'
|
||||
})
|
||||
|
||||
/** 신고 주소 뒤의 글 — 조사는 주소에 붙이고 받침에 맞춘다 (영문으로 끝나면 "(으)로") */
|
||||
const reportAfter = computed(() =>
|
||||
link.value ? SHARE_PAGE_TEXT.reportAfter(directionParticle(link.value.reportAddress)) : '',
|
||||
)
|
||||
const untilText = computed(() => (link.value ? SHARE_PAGE_TEXT.until(formatKstFullDate(link.value.expiresAt)) : ''))
|
||||
const untilTail = computed(() => {
|
||||
if (!link.value) return ''
|
||||
@@ -395,7 +400,7 @@ watch(
|
||||
v-if="link && view !== 'loading'"
|
||||
class="share__report"
|
||||
>
|
||||
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ SHARE_PAGE_TEXT.reportAfter }}
|
||||
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ reportAfter }}
|
||||
</p>
|
||||
<p
|
||||
v-if="english"
|
||||
|
||||
Reference in New Issue
Block a user