|
|
|
@@ -0,0 +1,585 @@
|
|
|
|
|
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 { 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
|
|
|
|
|
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
|
|
|
|
|
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)))
|
|
|
|
|
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')
|
|
|
|
|
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)
|
|
|
|
|
expect(wrapper.get('.list-header__count').text()).toBe('대화 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(wrapper.get('.list-header__count').text()).toBe('메일 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개 선택됨" — 하는 일은 그 줄의 메일 모두', async () => {
|
|
|
|
|
rememberGrouped()
|
|
|
|
|
const { wrapper } = await mountMail()
|
|
|
|
|
|
|
|
|
|
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
|
|
|
|
|
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
|
|
|
|
|
|
|
|
|
|
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('휴지통에서 고른 대화를 영구 삭제 — 확인 창의 수는 메일 수이고 그 줄의 메일을 POST /message-deletions', async () => {
|
|
|
|
|
rememberGrouped()
|
|
|
|
|
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }) }), conversation('t2', ['m1'])]
|
|
|
|
|
const { wrapper } = await mountMail('/mail/trash')
|
|
|
|
|
|
|
|
|
|
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통을 영구 삭제할까요?')
|
|
|
|
|
|
|
|
|
|
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('목록 보기 — 크기 칸이 없고 머리 고르기는 "불러온 대화 모두 고르기". 연 메일의 위치는 대화 기준이고 [다음]은 다음 대화의 마지막 메일', 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('크기')
|
|
|
|
|
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'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|