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:
2026-11-07 21:44:45 +09:00
227 changed files with 31229 additions and 742 deletions
@@ -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,
}),
+19 -1
View File
@@ -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)
})
})
+7 -2
View File
@@ -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)
})
})
+37 -13
View File
@@ -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
+1 -1
View File
@@ -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.',
)
+6 -1
View File
@@ -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"