generated from ttipo/project-base
feat(frontend): 전역 셸·로그인·세션 처리와 웹메일 화면
확정된 디자인(docs/design/webmail)을 기준으로 만든 웹메일 화면이다. - 앞으로 붙일 사내 모듈(캘린더·파일·ERP 등)을 위한 전역 앱 레일과 셸, Pretendard 자체 호스팅, 바다 청록 브랜드 토큰과 콘텐츠 열 토큰 - 로그인과 세션 처리: 401 이면 한 번 갱신한 뒤 다시 보내고, 실패하면 세션 만료 대화상자를 띄운다. 로그아웃은 다른 탭에도 알리고, 로그아웃 뒤에 늦게 온 갱신 결과는 버린다 - 메일함(분할·목록 보기), 읽기(외부 HTML 은 sandbox iframe 으로만 그린다), 쓰기(발송 키 규칙, 첨부 형식·크기·합계·개수 사전 검사, 답장·전달 초안 길이 맞춤) - 오류는 코드로만 분기하고, 문구는 생성된 에러 사전에서 가져온다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// 최상위 레이아웃 — 라우터 뷰만 마운트, 도메인 별 레이아웃은 라우트 단위로 분리
|
||||
// 최상위 레이아웃 — 라우터 뷰 + 전역 피드백(알림 · 세션 만료 대화상자)
|
||||
// 모듈별 레이아웃(전역 셸 등)은 라우트 단위로 둔다 (pages/mail/MailLayout.vue)
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
|
||||
import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { useLogoutSync } from '@/composables/useLogoutBroadcast'
|
||||
|
||||
// 다른 탭에서 로그아웃하면 이 탭도 로그인 화면으로 간다 (계약 1절 "클라이언트 처리 규칙")
|
||||
useLogoutSync()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastHost />
|
||||
<SessionExpiredDialog />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// 선 아이콘 — 도형 목록은 shared/constants/icons.ts 에 있다.
|
||||
// 장식용이면 스크린리더에서 숨기고, 의미가 있으면 label 을 넘겨 이름을 읽게 한다.
|
||||
// 사용 예: <AppIcon name="paperclip" :size="13" label="첨부 있음" />
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ICONS, REM_BASE_PX, type IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
name: IconName
|
||||
/** 한 변의 크기 (디자인 원본의 px 값) — rem 으로 바꿔 그린다 */
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
/** 안을 채운다 (중요 표시 별) */
|
||||
filled?: boolean
|
||||
/** 의미가 있는 아이콘이면 읽어 줄 이름. 비우면 장식으로 숨긴다 */
|
||||
label?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 16,
|
||||
strokeWidth: 1.75,
|
||||
filled: false,
|
||||
label: '',
|
||||
})
|
||||
|
||||
const dimension = computed(() => `${props.size / REM_BASE_PX}rem`)
|
||||
const shapes = computed(() => ICONS[props.name])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="app-icon"
|
||||
:width="dimension"
|
||||
:height="dimension"
|
||||
viewBox="0 0 24 24"
|
||||
:fill="filled ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:role="label ? 'img' : undefined"
|
||||
:aria-label="label || undefined"
|
||||
:aria-hidden="label ? undefined : 'true'"
|
||||
>
|
||||
<component
|
||||
:is="shape.tag"
|
||||
v-for="(shape, index) in shapes"
|
||||
:key="index"
|
||||
v-bind="shape.attrs"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-icon {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -50,12 +50,14 @@ function handleClick(event: MouseEvent) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 모든 치수는 rem 기준 (16px = 1rem) */
|
||||
/* 색·간격·글자 크기는 styles/tokens.css 의 변수만 쓴다. 다른 치수는 rem 기준 (16px = 1rem) */
|
||||
.base-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -65,12 +67,16 @@ function handleClick(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.base-button--primary {
|
||||
background-color: #4f46e5;
|
||||
color: #ffffff;
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.base-button--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.base-button--secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #111827;
|
||||
background-color: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SessionExpiredDialog from './SessionExpiredDialog.vue'
|
||||
|
||||
describe('SessionExpiredDialog', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
it('세션이 만료되면 사전 문구(AUTH_002)로 열리고, 확인하면 로그인 화면으로 보낸다 (돌아올 주소 포함)', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
|
||||
authStore.expireSession()
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.text()).toContain(ERROR_CODE_MESSAGES.AUTH_002)
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/mail/inbox/m1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
|
||||
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
|
||||
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const message = getErrorMessage('AUTH_002')
|
||||
|
||||
function open() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog || dialog.open) return
|
||||
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal()
|
||||
else dialog.setAttribute('open', '')
|
||||
}
|
||||
|
||||
function close() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog?.open) return
|
||||
if (typeof dialog.close === 'function') dialog.close()
|
||||
else dialog.removeAttribute('open')
|
||||
}
|
||||
|
||||
async function goToLogin() {
|
||||
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
|
||||
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
|
||||
authStore.acknowledgeSessionExpired()
|
||||
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
event.preventDefault()
|
||||
void goToLogin()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.sessionExpired,
|
||||
(expired) => (expired ? open() : close()),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.sessionExpired) open()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="session-dialog"
|
||||
aria-labelledby="session-expired-title"
|
||||
aria-describedby="session-expired-message"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<h2
|
||||
id="session-expired-title"
|
||||
class="session-dialog__title"
|
||||
>
|
||||
다시 로그인해 주세요
|
||||
</h2>
|
||||
<p
|
||||
id="session-expired-message"
|
||||
class="session-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="session-dialog__action"
|
||||
@click="goToLogin"
|
||||
>
|
||||
로그인 화면으로
|
||||
</button>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-dialog {
|
||||
width: min(22.5rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.session-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.session-dialog::backdrop {
|
||||
background: var(--color-backdrop);
|
||||
}
|
||||
|
||||
.session-dialog__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-dialog__action {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-4);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-dialog__action:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 영역 안의 상태 안내 — 빈 메일함 · 메일 미선택 · 불러오기 실패 (디자인 원본 States)
|
||||
// 오류 문구는 호출부가 ApiError.message(에러 코드 사전)를 그대로 넘긴다. 여기서 문구를 만들지 않는다.
|
||||
// 사용 예: <StateMessage tone="danger" icon="alert-circle" :title="error.message" :trace-id="error.traceId">
|
||||
// <button …>다시 시도</button>
|
||||
// </StateMessage>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
title: string
|
||||
hint?: string
|
||||
/** danger 면 빨간 아이콘 + 스크린리더에 바로 알린다 */
|
||||
tone?: 'neutral' | 'danger'
|
||||
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
|
||||
traceId?: string | null
|
||||
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택하세요") */
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hint: '',
|
||||
tone: 'neutral',
|
||||
traceId: null,
|
||||
fill: false,
|
||||
})
|
||||
|
||||
const traceCode = computed(() => formatTraceCode(props.traceId))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['state-message', `state-message--${tone}`, { 'state-message--fill': fill }]"
|
||||
:role="tone === 'danger' ? 'alert' : undefined"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="36"
|
||||
:stroke-width="1.5"
|
||||
class="state-message__icon"
|
||||
/>
|
||||
<p class="state-message__title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hint"
|
||||
class="state-message__hint"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
<slot />
|
||||
<span
|
||||
v-if="traceCode"
|
||||
class="state-message__trace"
|
||||
>문의 코드 {{ traceCode }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-80) var(--space-24);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-message--fill {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-height: 32.5rem;
|
||||
padding: var(--space-24);
|
||||
}
|
||||
|
||||
.state-message__icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.state-message--danger .state-message__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.state-message__title {
|
||||
margin: var(--space-6) 0 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-message__hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.state-message__trace {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="toast-host"
|
||||
aria-label="알림"
|
||||
>
|
||||
<TransitionGroup
|
||||
name="toast"
|
||||
tag="div"
|
||||
class="toast-host__stack"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toastStore.toasts"
|
||||
:key="toast.id"
|
||||
:class="['toast', `toast--${toast.kind}`]"
|
||||
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
<AppIcon
|
||||
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
|
||||
:size="toast.kind === 'error' ? 18 : 16"
|
||||
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
|
||||
class="toast__icon"
|
||||
/>
|
||||
<div class="toast__body">
|
||||
<span class="toast__message">{{ toast.message }}</span>
|
||||
<span
|
||||
v-if="formatTraceCode(toast.traceId)"
|
||||
class="toast__trace"
|
||||
>문의 코드 {{ formatTraceCode(toast.traceId) }}</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="toast.action"
|
||||
:to="toast.action.to"
|
||||
class="toast__action"
|
||||
@click="toastStore.dismiss(toast.id)"
|
||||
>
|
||||
{{ toast.action.label }}
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="toast.kind === 'error'"
|
||||
type="button"
|
||||
class="toast__close"
|
||||
aria-label="알림 닫기"
|
||||
@click="toastStore.dismiss(toast.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
right: var(--space-20);
|
||||
bottom: var(--space-20);
|
||||
z-index: 20;
|
||||
width: min(25rem, calc(100vw - var(--space-40)));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-host__stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
padding: var(--space-12) var(--space-6) var(--space-12) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
align-items: center;
|
||||
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-surface);
|
||||
color: var(--color-on-brand);
|
||||
box-shadow: var(--shadow-toast-strong);
|
||||
}
|
||||
|
||||
.toast__icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.toast--error .toast__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.toast--success .toast__icon {
|
||||
margin-top: 0;
|
||||
color: var(--color-on-brand-success);
|
||||
}
|
||||
|
||||
.toast__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toast__trace {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toast__action {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-on-brand-link);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toast__action:hover {
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.toast__action:focus-visible {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-36);
|
||||
height: var(--space-36);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toast__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--space-8));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면의 첨부 목록 — 파일마다 진행률, 실패한 파일은 사유와 함께 빨갛게 (디자인 원본 States "첨부 업로드")
|
||||
// 어느 첨부든 할 수 있는 일은 "빼기"(✕) 하나다 — 올린 파일은 빼고 다시 고르면 되고, 전달 원본은 다시 올릴 수 없다.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { UploadItem } from '@/composables/useAttachmentUploads'
|
||||
import { formatFileSize } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
items: UploadItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'remove', key: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const listRef = ref<HTMLUListElement | null>(null)
|
||||
|
||||
/** 그 첨부의 빼기 버튼으로 초점을 옮긴다 — 발송이 찾지 못한 전달 원본을 빼게 할 때 쓴다 */
|
||||
function focusRemove(key: number) {
|
||||
listRef.value
|
||||
?.querySelector<HTMLButtonElement>(`[data-key="${key}"] .upload-item__remove`)
|
||||
?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focusRemove })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
ref="listRef"
|
||||
class="upload-list"
|
||||
>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:class="['upload-item', `upload-item--${item.status}`]"
|
||||
:data-key="item.key"
|
||||
>
|
||||
<div class="upload-item__row">
|
||||
<AppIcon
|
||||
name="file"
|
||||
class="upload-item__icon"
|
||||
/>
|
||||
<span class="upload-item__name">{{ item.name }}</span>
|
||||
<span class="upload-item__meta">
|
||||
<template v-if="item.status === 'uploading'">올리는 중 {{ item.progress }}%</template>
|
||||
<template v-else>{{ formatFileSize(item.size) }}</template>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="upload-item__remove"
|
||||
:aria-label="`${item.name} 첨부 취소`"
|
||||
@click="emit('remove', item.key)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<progress
|
||||
v-if="item.status === 'uploading'"
|
||||
class="upload-item__progress"
|
||||
max="100"
|
||||
:value="item.progress"
|
||||
:aria-label="`${item.name} 올리는 중`"
|
||||
/>
|
||||
<p
|
||||
v-if="item.status === 'failed' && item.errorMessage"
|
||||
class="upload-item__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ item.errorMessage }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(17.5rem, 100%), 1fr));
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.upload-item__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.upload-item__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.upload-item__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-item__meta {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upload-item__remove {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-40);
|
||||
height: var(--space-40);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.upload-item__remove:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 진행률 — 3px 막대 (인라인 스타일 대신 <progress> 를 꾸민다) */
|
||||
.upload-item__progress {
|
||||
appearance: none;
|
||||
width: calc(100% - var(--space-8));
|
||||
height: var(--space-3);
|
||||
margin: 0 0 var(--space-6);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-skeleton);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-item__progress::-webkit-progress-bar {
|
||||
background: var(--color-skeleton);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-item__progress::-webkit-progress-value {
|
||||
background: var(--color-brand);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-item__progress::-moz-progress-bar {
|
||||
background: var(--color-brand);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-item--failed {
|
||||
border-color: var(--color-danger-border);
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.upload-item--failed .upload-item__name,
|
||||
.upload-item--failed .upload-item__meta,
|
||||
.upload-item--failed .upload-item__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.upload-item__error {
|
||||
margin: 0 0 var(--space-6);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ListReadNav from './ListReadNav.vue'
|
||||
|
||||
function summary(id: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
const store = useMailStore()
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.messages = [summary('m1'), summary('m2'), summary('m3')]
|
||||
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 }
|
||||
store.listStatus = 'ready'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountNav(messageId: string) {
|
||||
const router = createTestRouter()
|
||||
// 목록에서 메일을 연 것과 같은 기록: /mail/inbox → /mail/inbox/<id>
|
||||
await router.push('/mail/inbox')
|
||||
await router.push(`/mail/inbox/${messageId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('위치를 "n / 전체" 로 보인다 (전체는 서버가 준 total)', async () => {
|
||||
const { wrapper } = await mountNav('m2')
|
||||
|
||||
expect(wrapper.get('.nav-position').text()).toBe('2 / 5')
|
||||
})
|
||||
|
||||
it('첫 메일이면 이전 버튼이 꺼진다', async () => {
|
||||
const { wrapper } = await mountNav('m1')
|
||||
|
||||
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('이전 메일로 넘어가도 기록을 늘리지 않는다 — 뒤로 가기는 목록으로 간다', async () => {
|
||||
const { wrapper, router } = await mountNav('m2')
|
||||
|
||||
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('불러온 범위의 끝에서 다음을 누르면 다음 페이지를 불러온 뒤 넘어간다', async () => {
|
||||
const requests = installMockApi(() =>
|
||||
ok({ items: [summary('m4'), summary('m5')], pageInfo: { hasNext: false, nextCursor: null, total: 5 } }),
|
||||
)
|
||||
const { wrapper, router } = await mountNav('m3')
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
|
||||
})
|
||||
|
||||
it('마지막 메일이고 서버에도 더 없으면 다음 버튼이 꺼진다', async () => {
|
||||
useMailStore().pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
const { wrapper } = await mountNav('m3')
|
||||
|
||||
expect(wrapper.get('.nav-position').text()).toBe('3 / 3')
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('목록으로 — 바로 앞 기록이 그 목록이면 뒤로 가기를 쓴다 (브라우저 기록을 쌓지 않는다)', async () => {
|
||||
// 메모리 history 는 history.state 를 쓰지 않으므로 이 경우만 실제 브라우저 history 로 확인한다
|
||||
const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes })
|
||||
await router.push('/mail/inbox')
|
||||
await router.push('/mail/inbox/m2')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId: 'm2' },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/mail/inbox'))
|
||||
|
||||
// 뒤로 간 것이라 앞으로 갈 기록(보던 메일)이 남아 있다 — 바꿔 끼웠다면 없다
|
||||
expect(router.options.history.state.forward).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('목록으로 — 목록으로 돌아가고, 보던 메일로 초점을 돌려주게 한다', async () => {
|
||||
const { wrapper, router } = await mountNav('m2')
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
|
||||
|
||||
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
|
||||
const loadingNext = ref<boolean>(false)
|
||||
|
||||
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
const total = computed(
|
||||
() =>
|
||||
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
|
||||
)
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
)
|
||||
const hasPrev = computed(() => index.value > 0)
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */
|
||||
const hasNext = computed(
|
||||
() =>
|
||||
index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore),
|
||||
)
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
if (previous) await showSibling(props.mailboxId, previous.id)
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (loadingNext.value || index.value === -1) return
|
||||
const current = index.value
|
||||
if (!mailStore.messages[current + 1] && mailStore.hasMore) {
|
||||
loadingNext.value = true
|
||||
try {
|
||||
await mailStore.loadMore()
|
||||
} finally {
|
||||
loadingNext.value = false
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
if (next) await showSibling(props.mailboxId, next.id)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
function onBackToList(event: MouseEvent) {
|
||||
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
void backToList(props.mailboxId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
icon="arrow-left"
|
||||
label="목록으로"
|
||||
tone="accent"
|
||||
:href="listHref"
|
||||
:icon-stroke-width="1.9"
|
||||
@click="onBackToList"
|
||||
/>
|
||||
<MailToolbarDivider />
|
||||
<button
|
||||
type="button"
|
||||
class="nav-button"
|
||||
aria-label="이전 메일"
|
||||
:disabled="!hasPrev"
|
||||
@click="goPrev"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-left"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
v-if="position"
|
||||
class="nav-position"
|
||||
>{{ position }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-button"
|
||||
aria-label="다음 메일"
|
||||
:disabled="!hasNext || loadingNext"
|
||||
:aria-busy="loadingNext"
|
||||
@click="goNext"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-right"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<MailToolbarDivider />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-button {
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.nav-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-position {
|
||||
min-width: var(--space-48);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { MAIL_FRAME_CSP } from '@/shared/utils/mail-html'
|
||||
import MailBodyFrame from './MailBodyFrame.vue'
|
||||
|
||||
// 보안 핵심 — 본문 iframe 의 격리 조건이 바뀌면 이 테스트가 깨져야 한다.
|
||||
describe('MailBodyFrame', () => {
|
||||
function frameOf(props: { htmlBody?: string | null; textBody?: string | null }) {
|
||||
const wrapper = mount(MailBodyFrame, { props })
|
||||
return wrapper.get('iframe').element as HTMLIFrameElement
|
||||
}
|
||||
|
||||
it('sandbox 는 allow-same-origin · allow-popups · allow-popups-to-escape-sandbox 뿐이다', () => {
|
||||
const frame = frameOf({ htmlBody: '<p>안녕하세요</p>' })
|
||||
const tokens = (frame.getAttribute('sandbox') ?? '').split(/\s+/).filter(Boolean).sort()
|
||||
|
||||
expect(tokens).toEqual(['allow-popups', 'allow-popups-to-escape-sandbox', 'allow-same-origin'])
|
||||
// 스크립트·폼은 절대 허용하지 않는다 (allow-same-origin 과 allow-scripts 를 같이 쓰면 격리가 무너진다)
|
||||
expect(tokens).not.toContain('allow-scripts')
|
||||
expect(tokens).not.toContain('allow-forms')
|
||||
expect(tokens).not.toContain('allow-top-navigation')
|
||||
})
|
||||
|
||||
it('본문은 srcdoc 으로만 넣고 외부 주소(src)를 쓰지 않는다', () => {
|
||||
const frame = frameOf({ htmlBody: '<p>안녕하세요</p>' })
|
||||
|
||||
expect(frame.hasAttribute('src')).toBe(false)
|
||||
expect(frame.getAttribute('srcdoc')).toContain('<p>안녕하세요</p>')
|
||||
})
|
||||
|
||||
it('srcdoc 문서에 내부 CSP 와 <base target="_blank"> 가 들어간다', () => {
|
||||
const srcdoc = frameOf({ htmlBody: '<p>본문</p>' }).getAttribute('srcdoc') ?? ''
|
||||
|
||||
expect(srcdoc).toContain(`content="${MAIL_FRAME_CSP}"`)
|
||||
expect(srcdoc).toContain('<base target="_blank">')
|
||||
})
|
||||
|
||||
it('서버가 놓친 스크립트도 그리기 전에 지운다', () => {
|
||||
const srcdoc =
|
||||
frameOf({ htmlBody: '<p>본문</p><script>alert(1)</script><img src=x onerror=alert(2)>' }).getAttribute(
|
||||
'srcdoc',
|
||||
) ?? ''
|
||||
|
||||
expect(srcdoc).not.toContain('alert(')
|
||||
expect(srcdoc).not.toContain('<script')
|
||||
})
|
||||
|
||||
it('HTML 이 없으면 텍스트를 이스케이프해 보여 준다', () => {
|
||||
const srcdoc = frameOf({ htmlBody: null, textBody: '<b>굵게</b> 아님' }).getAttribute('srcdoc') ?? ''
|
||||
|
||||
expect(srcdoc).toContain('<b>굵게</b> 아님')
|
||||
expect(srcdoc).not.toContain('<b>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 본문을 격리된 iframe(srcdoc)에만 그린다 — 보안 핵심 (shared/utils/mail-html.ts 머리말 참고)
|
||||
//
|
||||
// sandbox 에 넣는 것과 넣지 않는 것:
|
||||
// - allow-scripts · allow-forms 는 절대 넣지 않는다. 본문 안 스크립트·폼 제출이 모두 막힌다.
|
||||
// - allow-same-origin: 부모(우리 앱)가 contentDocument 에 접근해 내용 높이를 재기 위해서만 넣는다.
|
||||
// 이 iframe 은 스크립트가 꺼져 있어(allow-scripts 없음) 같은 출처 권한을 쓸 코드가 실행될 수 없다.
|
||||
// ⚠️ allow-scripts 와 함께 넣으면 iframe 안 스크립트가 sandbox 속성을 지우고 빠져나올 수 있으므로
|
||||
// 둘을 같이 넣는 변경은 금지한다.
|
||||
// - allow-popups · allow-popups-to-escape-sandbox: 링크를 새 창으로 열고, 새 창은 sandbox 없이 정상 동작하게 한다.
|
||||
// 링크는 정화 단계에서 http·https·mailto 만 남기고 rel="noopener noreferrer" 를 붙인다.
|
||||
// 문서 안에는 CSP(default-src 'none'; img-src data:; style-src 'unsafe-inline')와 <base target="_blank"> 가 들어간다.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { buildMailDocument } from '@/shared/utils/mail-html'
|
||||
|
||||
interface Props {
|
||||
/** 서버가 정화한 HTML — 여기서 한 번 더 정화한다 */
|
||||
htmlBody?: string | null
|
||||
/** HTML 이 없을 때 이스케이프해서 보여 줄 텍스트 */
|
||||
textBody?: string | null
|
||||
/** iframe 이름 (스크린리더) */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
htmlBody: null,
|
||||
textBody: null,
|
||||
title: '메일 본문',
|
||||
})
|
||||
|
||||
/** sandbox 값 — 위 설명 외의 권한을 추가하지 않는다 */
|
||||
const SANDBOX = 'allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
|
||||
const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||
/** 내용 높이(px) — iframe 의 height 속성으로 준다 (인라인 스타일을 쓰지 않는다) */
|
||||
const contentHeight = ref<number | null>(null)
|
||||
|
||||
const srcdoc = computed(() =>
|
||||
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody }),
|
||||
)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let lastWidth = 0
|
||||
let measureFrame = 0
|
||||
/** 지금 srcdoc 문서가 다 열렸는지 — 열리기 전(빈 about:blank)에 재면 높이가 0 이 된다 */
|
||||
let documentLoaded = false
|
||||
|
||||
/** 문서 높이를 재서 iframe 높이로 맞춘다 (같은 출처라 접근할 수 있다 — 위 sandbox 설명) */
|
||||
function measure() {
|
||||
const frame = frameRef.value
|
||||
const doc = frame?.contentDocument
|
||||
if (!documentLoaded || !frame || !doc?.body) return
|
||||
|
||||
// 넓은 표 때문에 가로 스크롤 막대가 생기면 그 두께만큼 더한다
|
||||
const viewportHeight = frame.contentWindow?.innerHeight ?? 0
|
||||
const scrollbar = Math.max(0, viewportHeight - doc.documentElement.clientHeight)
|
||||
contentHeight.value = Math.ceil(doc.body.scrollHeight) + scrollbar
|
||||
}
|
||||
|
||||
function scheduleMeasure() {
|
||||
cancelAnimationFrame(measureFrame)
|
||||
measureFrame = requestAnimationFrame(measure)
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
documentLoaded = true
|
||||
measure()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver === 'undefined' || !frameRef.value) return
|
||||
// 폭이 바뀌면 줄바꿈이 달라져 높이가 바뀐다 — 높이 변화(우리가 바꾼 것)에는 반응하지 않는다
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 0
|
||||
if (width === lastWidth) return
|
||||
lastWidth = width
|
||||
scheduleMeasure()
|
||||
})
|
||||
resizeObserver.observe(frameRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
cancelAnimationFrame(measureFrame)
|
||||
})
|
||||
|
||||
// 다른 메일로 바뀌면 새 문서가 load 될 때 다시 잰다 — 그 전까지 이전 높이가 남지 않게 비운다
|
||||
watch(srcdoc, () => {
|
||||
documentLoaded = false
|
||||
contentHeight.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- sandbox 를 srcdoc 보다 먼저 둔다 — 문서가 sandbox 없이 먼저 열리는 순서를 만들지 않기 위해서다 -->
|
||||
<iframe
|
||||
ref="frameRef"
|
||||
:sandbox="SANDBOX"
|
||||
class="mail-body-frame"
|
||||
:class="{ 'is-measured': contentHeight !== null }"
|
||||
:title="title"
|
||||
referrerpolicy="no-referrer"
|
||||
:height="contentHeight ?? undefined"
|
||||
:srcdoc="srcdoc"
|
||||
@load="onLoad"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-body-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 본문 글줄 최대 폭 (콘텐츠 열 공통값) */
|
||||
max-width: var(--content-measure);
|
||||
border: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 높이를 재기 전(또는 잴 수 없을 때)에도 본문이 보이게 최소 높이를 둔다 */
|
||||
.mail-body-frame:not(.is-measured) {
|
||||
min-height: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — 목록 영역이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const title = computed(() => mailStore.currentMailbox?.name ?? '메일함')
|
||||
const countText = computed(() => {
|
||||
const box = mailStore.currentMailbox
|
||||
if (!box) return ''
|
||||
const total = `전체 ${formatCount(box.totalCount)}`
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
})
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['list-header', `list-header--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="list-header__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="list-header__switch" />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 표의 첫 칸과 같은 선(20px)에서 시작한다 */
|
||||
.list-header--list {
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.list-header__count {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.list-header__switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.list-header__refresh {
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list-header__refresh:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.list-header__refresh:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 목록·본문을 못 불러왔을 때의 상태 (디자인 원본 States "목록 불러오기 실패")
|
||||
// - 다시 하면 나아질 수 있는 오류(메일 서버 장애·네트워크 등): "다시 시도"
|
||||
// - 없는 주소(404 RES_001 — 주소창에 남은 옛 메일함·메일 id 등): 다시 해도 같으므로 빠져나갈 버튼을 준다
|
||||
// (목록은 "받은편지함으로", 본문은 "목록으로" — 이동은 부모가 useMailNavigation 규칙으로 바꿔 끼운다)
|
||||
// 문구는 에러 코드 사전에서만 온다 (error.message).
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import type { ApiError } from '@/composables/useApi'
|
||||
|
||||
interface Props {
|
||||
error: ApiError
|
||||
/** 없는 주소일 때 버튼 이름 */
|
||||
leaveLabel: string
|
||||
/** 영역을 세로로 가득 채운다 (본문 영역) */
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry'): void
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fill: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 없는 주소 — 다시 시도해도 같은 결과다 */
|
||||
const notFound = computed(() => props.error.code === 'RES_001')
|
||||
|
||||
function onAction() {
|
||||
if (notFound.value) emit('leave')
|
||||
else emit('retry')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
tone="danger"
|
||||
icon="alert-circle"
|
||||
:title="error.message"
|
||||
:trace-id="error.traceId"
|
||||
:fill="fill"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mail-load-error__action"
|
||||
@click="onAction"
|
||||
>
|
||||
{{ notFound ? leaveLabel : '다시 시도' }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-load-error__action {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-load-error__action:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import MailQuotaMeter from './MailQuotaMeter.vue'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
|
||||
describe('MailQuotaMeter', () => {
|
||||
it('디자인 원본처럼 "메일함 사용량 0.9 / 5 GB" 와 가는 막대를 보인다 (뱃지 없이)', () => {
|
||||
const wrapper = mount(MailQuotaMeter, {
|
||||
props: { quota: { usedBytes: 0.9 * GB, limitBytes: 5 * GB, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
|
||||
})
|
||||
const meter = wrapper.get('meter')
|
||||
|
||||
expect(wrapper.text()).toContain('메일함 사용량')
|
||||
expect(wrapper.get('.quota__value').text()).toBe('0.9 / 5 GB')
|
||||
expect(meter.attributes('max')).toBe(String(5 * GB))
|
||||
expect(meter.attributes('aria-valuetext')).toBe('18%')
|
||||
})
|
||||
|
||||
it('한도가 없으면(null) 사용량만 보이고 막대는 그리지 않는다', () => {
|
||||
const wrapper = mount(MailQuotaMeter, {
|
||||
props: { quota: { usedBytes: 0.9 * GB, limitBytes: null, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
|
||||
})
|
||||
|
||||
expect(wrapper.get('.quota__value').text()).toBe('0.9 GB')
|
||||
expect(wrapper.find('meter').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('한도를 넘은 사용량은 막대를 꽉 채운 값으로 그린다', () => {
|
||||
const wrapper = mount(MailQuotaMeter, {
|
||||
props: { quota: { usedBytes: 6 * GB, limitBytes: 5 * GB, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
|
||||
})
|
||||
|
||||
expect(wrapper.get('meter').attributes('value')).toBe(String(5 * GB))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 사용량 — 메일함 패널 하단 (디자인 원본 Main: "메일함 사용량 0.9 / 5 GB" + 가는 막대)
|
||||
// 뱃지 없이 숫자와 막대만. 한도가 없으면(limitBytes null) 사용량만 보이고 막대는 그리지 않는다.
|
||||
// 막대는 인라인 스타일 대신 <meter> 를 꾸민다 — 값·범위가 스크린리더에도 그대로 전달된다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_QUOTA_WARNING_RATIO } from '@/shared/constants/mail'
|
||||
import type { MailQuota } from '@/shared/types/mail'
|
||||
import { formatQuotaUsage } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
quota: MailQuota
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const PERCENT = 100
|
||||
|
||||
const usage = computed(() => formatQuotaUsage(props.quota.usedBytes, props.quota.limitBytes))
|
||||
/** 한도가 있을 때만 막대를 그린다 */
|
||||
const limit = computed(() => {
|
||||
const value = props.quota.limitBytes
|
||||
return value !== null && value > 0 ? value : null
|
||||
})
|
||||
const used = computed(() => Math.min(Math.max(props.quota.usedBytes, 0), limit.value ?? 0))
|
||||
/** 이 값을 넘으면 경고색 (meter 의 low=high 구간 경계) */
|
||||
const warnAt = computed(() => (limit.value ?? 0) * MAIL_QUOTA_WARNING_RATIO)
|
||||
const percentText = computed(() =>
|
||||
limit.value ? `${Math.round((used.value / limit.value) * PERCENT)}%` : '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quota">
|
||||
<div class="quota__row">
|
||||
<span id="mail-quota-label">메일함 사용량</span>
|
||||
<span class="quota__value">{{ usage }}</span>
|
||||
</div>
|
||||
<meter
|
||||
v-if="limit"
|
||||
class="quota__meter"
|
||||
min="0"
|
||||
:max="limit"
|
||||
:low="warnAt"
|
||||
:high="warnAt"
|
||||
optimum="0"
|
||||
:value="used"
|
||||
aria-labelledby="mail-quota-label"
|
||||
:aria-valuetext="percentText"
|
||||
:title="usage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quota {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-12) var(--space-10) var(--space-2);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.quota__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-8);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.quota__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 4px 막대 — 브라우저 기본 모양을 지우고 토큰 색으로 그린다 */
|
||||
.quota__meter {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-skeleton);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quota__meter::-webkit-meter-bar {
|
||||
height: var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.quota__meter::-webkit-meter-optimum-value {
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
/* 한도의 90% 를 넘으면 경고색 — 용량 부족(MAIL_003)으로 발송이 막히기 전에 알아차리게 한다 */
|
||||
.quota__meter::-webkit-meter-suboptimum-value,
|
||||
.quota__meter::-webkit-meter-even-less-good-value {
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.quota__meter::-moz-meter-bar {
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.quota__meter:-moz-meter-sub-optimum::-moz-meter-bar,
|
||||
.quota__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 툴바 버튼 — 분할 보기 읽기 · 목록 보기 읽기가 같은 모양을 쓴다 (디자인 원본 Main · MainListRead)
|
||||
// 안쪽 좌우 여백은 --toolbar-button-inline(10px) — 툴바 안쪽 여백(22px)과 더해 첫 아이콘이 콘텐츠 열 32px 선에 온다.
|
||||
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
label: string
|
||||
/** 앱 안 이동 — 있으면 RouterLink */
|
||||
to?: RouteLocationRaw | null
|
||||
/** 직접 처리하는 링크(예: 목록으로) — 있으면 <a href>, 누르면 click 을 내보낸다 */
|
||||
href?: string | null
|
||||
/** accent: 강조 글자(목록으로) · danger: 삭제 */
|
||||
tone?: 'default' | 'accent' | 'danger'
|
||||
disabled?: boolean
|
||||
iconStrokeWidth?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
to: null,
|
||||
href: null,
|
||||
tone: 'default',
|
||||
disabled: false,
|
||||
iconStrokeWidth: 1.75,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="to"
|
||||
:to="to"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</RouterLink>
|
||||
<a
|
||||
v-else-if="href"
|
||||
:href="href"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--toolbar-button-inline);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tool-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tool-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tool-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-button--accent:hover:not(:disabled) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.tool-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tool-button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 툴바의 묶음 구분선 (디자인 원본: 1px × 20px, 좌우 6px)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="toolbar-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-divider {
|
||||
width: var(--space-1);
|
||||
height: var(--space-20);
|
||||
margin: 0 var(--space-6);
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailQuotaMeter from '@/components/mail/MailQuotaMeter.vue'
|
||||
import { mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const route = useRoute()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const isComposing = computed(() => route.name === 'mail-compose')
|
||||
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
|
||||
|
||||
function depthClass(depth: number): string {
|
||||
return `mailbox-link--depth-${Math.min(depth, MAX_INDENT_DEPTH)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="mailbox-panel"
|
||||
aria-label="메일함"
|
||||
>
|
||||
<span
|
||||
v-if="isComposing"
|
||||
class="mailbox-panel__primary mailbox-panel__primary--current"
|
||||
aria-current="page"
|
||||
>
|
||||
<AppIcon
|
||||
name="edit"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>메일 쓰는 중</span>
|
||||
</span>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="{ name: 'mail-compose' }"
|
||||
class="mailbox-panel__primary"
|
||||
>
|
||||
<AppIcon
|
||||
name="edit"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>메일 쓰기</span>
|
||||
</RouterLink>
|
||||
|
||||
<ul
|
||||
v-if="mailStore.orderedMailboxes.length > 0"
|
||||
class="mailbox-panel__list"
|
||||
>
|
||||
<li
|
||||
v-for="node in mailStore.orderedMailboxes"
|
||||
:key="node.mailbox.id"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: node.mailbox.id } }"
|
||||
:class="[
|
||||
'mailbox-link',
|
||||
depthClass(node.depth),
|
||||
{ 'is-active': node.mailbox.id === activeMailboxId },
|
||||
]"
|
||||
:aria-current="node.mailbox.id === activeMailboxId ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(node.mailbox)" />
|
||||
<span class="mailbox-link__name">{{ node.mailbox.name }}</span>
|
||||
<span
|
||||
v-if="node.mailbox.unreadCount > 0"
|
||||
class="mailbox-link__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(node.mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul
|
||||
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
|
||||
class="mailbox-panel__list"
|
||||
aria-label="메일함 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
v-for="row in 5"
|
||||
:key="row"
|
||||
class="mailbox-panel__placeholder"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-else-if="mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError"
|
||||
class="mailbox-panel__error"
|
||||
role="alert"
|
||||
>
|
||||
<p class="mailbox-panel__error-text">
|
||||
{{ mailStore.mailboxesError.message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mailbox-panel__retry"
|
||||
@click="mailStore.loadMailboxes()"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-panel {
|
||||
flex: 1 1 13rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-14) var(--space-10);
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.mailbox-panel__primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.mailbox-panel__primary:hover {
|
||||
background: var(--color-brand-strong);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.mailbox-panel__primary--current {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mailbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-link__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-link__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-link.is-active .mailbox-link__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__placeholder {
|
||||
height: var(--size-row-min);
|
||||
margin: var(--space-2) 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mailbox-panel__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.mailbox-panel__error-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.mailbox-panel__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mailbox-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import MessageAttachments from './MessageAttachments.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const CHECKLIST: Attachment = {
|
||||
id: 'att 1',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.ms-excel',
|
||||
size: 49152,
|
||||
}
|
||||
/** 경로의 id 는 인코딩된다 (공백 → %20) */
|
||||
const DOWNLOAD_URL = '/api/messages/m1/attachments/att%201'
|
||||
let clock = Date.UTC(2026, 9, 26, 7, 0, 0)
|
||||
|
||||
interface StartedDownload {
|
||||
href: string | null
|
||||
download: string | null
|
||||
}
|
||||
|
||||
describe('MessageAttachments 다운로드 — 이동 전 세션 확인 (계약 2절)', () => {
|
||||
let pinia: Pinia
|
||||
/** 실제 다운로드를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
|
||||
let downloads: StartedDownload[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
downloads.push({ href: this.getAttribute('href'), download: this.getAttribute('download') })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountAttachments(handler: MockHandler) {
|
||||
const requests = installMockApi(handler)
|
||||
const wrapper = mount(MessageAttachments, {
|
||||
props: { messageId: 'm1', attachments: [CHECKLIST] },
|
||||
global: { plugins: [pinia] },
|
||||
})
|
||||
const link = wrapper.get<HTMLAnchorElement>('a.attachments__download').element
|
||||
return { wrapper, link, requests }
|
||||
}
|
||||
|
||||
function press(link: HTMLAnchorElement, type: 'click' | 'auxclick', init: MouseEventInit = {}) {
|
||||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init })
|
||||
link.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function requestLines(requests: { method: string; url: string }[]): string[] {
|
||||
return requests.map((request) => `${request.method} ${request.url}`)
|
||||
}
|
||||
|
||||
it('링크 주소는 그대로 두고, 누르면 세션을 확인한 뒤 같은 주소로 내려받는다 — 확인 중에 다시 눌러도 한 번만', async () => {
|
||||
let releaseMe: (result: MockResult) => void = () => undefined
|
||||
const meGate = new Promise<MockResult>((resolve) => {
|
||||
releaseMe = resolve
|
||||
})
|
||||
const { link, requests } = mountAttachments(() => meGate)
|
||||
expect(link.getAttribute('href')).toBe(DOWNLOAD_URL)
|
||||
|
||||
const first = press(link, 'click')
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
// 원래 링크의 이동은 막고, 확인이 끝나기 전에는 받지 않는다
|
||||
expect(first.defaultPrevented).toBe(true)
|
||||
expect(downloads).toEqual([])
|
||||
|
||||
releaseMe(ok(USER))
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me'])
|
||||
expect(downloads).toEqual([{ href: DOWNLOAD_URL, download: '' }])
|
||||
})
|
||||
|
||||
it('액세스 토큰이 만료됐으면(AUTH_001) 알림 없이 갱신한 뒤 내려받는다', async () => {
|
||||
const { link, requests } = mountAttachments(({ url }) =>
|
||||
url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001'),
|
||||
)
|
||||
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me', 'POST /auth/refresh'])
|
||||
expect(downloads).toEqual([{ href: DOWNLOAD_URL, download: '' }])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('갱신도 실패하면 내려받지 않고 세션 만료로 정리한다 (토스트 대신 대화상자)', async () => {
|
||||
const { link } = mountAttachments(() => fail(401, 'AUTH_001'))
|
||||
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('확인이 장애로 실패하면 내려받지 않고 사전 문구로 알린다 — 다시 누르면 다시 확인한다', async () => {
|
||||
let healthy = false
|
||||
const { link, requests } = mountAttachments(() => (healthy ? ok(USER) : fail(500, 'SYS_001')))
|
||||
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
ERROR_CODE_MESSAGES.SYS_001,
|
||||
])
|
||||
expect(useAuthStore().sessionExpired).toBe(false)
|
||||
|
||||
healthy = true
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me', 'GET /auth/me'])
|
||||
expect(downloads).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('보조 클릭(Ctrl·가운데 버튼)도 같은 흐름으로 받고, 오른쪽 버튼은 브라우저 메뉴에 맡긴다', async () => {
|
||||
const { link, requests } = mountAttachments(() => ok(USER))
|
||||
|
||||
const right = press(link, 'auxclick', { button: 2 })
|
||||
await flushPromises()
|
||||
expect(right.defaultPrevented).toBe(false)
|
||||
expect(requests).toEqual([])
|
||||
|
||||
const middle = press(link, 'auxclick', { button: 1 })
|
||||
await flushPromises()
|
||||
const withCtrl = press(link, 'click', { ctrlKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(middle.defaultPrevented).toBe(true)
|
||||
expect(withCtrl.defaultPrevented).toBe(true)
|
||||
expect(downloads).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
// 받은 메일의 첨부 목록 — 다운로드는 <a href> 이동으로 받는다 (계약 2절)
|
||||
// 주소는 axios baseURL 과 같은 규칙으로 만들어야 같은 출처·쿠키 경로로 요청이 간다 (useMail.attachmentDownloadUrl)
|
||||
// 이동은 axios 의 401 갱신 규칙을 타지 않으므로, 누르면 바로 이동하지 않고 세션부터 확인한 뒤 받는다.
|
||||
// href 는 그대로 둔다 — "링크 주소 복사" 같은 브라우저 기본 기능을 지키기 위해서다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import { formatFileSize } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
attachments: Attachment[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { attachmentDownloadUrl, downloadAttachment } = useMail()
|
||||
|
||||
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
|
||||
const MIDDLE_BUTTON = 1
|
||||
|
||||
/** 세션을 확인하는 중인 첨부 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
|
||||
const pending = new Set<string>()
|
||||
|
||||
function onDownload(event: MouseEvent, attachmentId: string) {
|
||||
// 오른쪽 버튼(메뉴)은 브라우저에 맡긴다
|
||||
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
|
||||
event.preventDefault()
|
||||
|
||||
const key = `${props.messageId}/${attachmentId}`
|
||||
if (pending.has(key)) return
|
||||
pending.add(key)
|
||||
void downloadAttachment(props.messageId, attachmentId).finally(() => {
|
||||
pending.delete(key)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="attachments"
|
||||
aria-labelledby="reader-attachments-title"
|
||||
>
|
||||
<h3
|
||||
id="reader-attachments-title"
|
||||
class="attachments__title"
|
||||
>
|
||||
첨부 파일 {{ attachments.length }}개
|
||||
</h3>
|
||||
<ul class="attachments__list">
|
||||
<li
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.id"
|
||||
class="attachments__item"
|
||||
>
|
||||
<span
|
||||
class="attachments__icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<AppIcon name="file" />
|
||||
</span>
|
||||
<div class="attachments__meta">
|
||||
<span class="attachments__name">{{ attachment.name }}</span>
|
||||
<span class="attachments__size">{{ formatFileSize(attachment.size) }}</span>
|
||||
</div>
|
||||
<a
|
||||
class="attachments__download"
|
||||
:href="attachmentDownloadUrl(messageId, attachment.id)"
|
||||
:aria-label="`${attachment.name} 다운로드`"
|
||||
download
|
||||
@click="onDownload($event, attachment.id)"
|
||||
@auxclick="onDownload($event, attachment.id)"
|
||||
>
|
||||
<AppIcon name="download" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding-top: var(--space-16);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.attachments__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.attachments__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(16.25rem, 100%), 1fr));
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.attachments__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-8);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.attachments__icon {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.attachments__meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.attachments__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attachments__size {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.attachments__download {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.attachments__download:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 목록 영역 — 머리(메일함 이름·개수·보기 전환·새로고침) + 2줄 행 + 메일 더 보기 (디자인 원본 Main 가운데)
|
||||
// 상태: 불러오는 중 · 메일 서버 장애(다시 시도) · 없는 메일함(받은편지함으로) · 빈 메일함 (디자인 원본 States)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const showRecipients = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="message-list"
|
||||
aria-labelledby="message-list-title"
|
||||
:aria-busy="busy"
|
||||
>
|
||||
<MailListHeader title-id="message-list-title" />
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:mailbox-id="mailboxId"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:show-recipients="showRecipients"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-list__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-list__text-button message-list__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.message-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.message-list__more {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.message-list__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-list__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-list__more-button {
|
||||
width: 100%;
|
||||
min-height: var(--size-row-min);
|
||||
}
|
||||
|
||||
.message-list__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
mailboxId: string
|
||||
/** 지금 열려 있는 메일 */
|
||||
selected?: boolean
|
||||
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
|
||||
showRecipients?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', id: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
showRecipients: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const who = computed(() => {
|
||||
if (props.showRecipients) {
|
||||
const recipients = summarizeRecipients(props.message.to)
|
||||
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
|
||||
}
|
||||
return displayName(props.message.from) || '(보낸 사람 없음)'
|
||||
})
|
||||
const subject = computed(() => props.message.subject || '(제목 없음)')
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': !message.isRead }]">
|
||||
<RouterLink
|
||||
class="message-row__main"
|
||||
:to="{ name: 'mail-message', params: { mailboxId, messageId: message.id } }"
|
||||
>
|
||||
<span class="message-row__top">
|
||||
<span class="message-row__who">
|
||||
<span
|
||||
v-if="!message.isRead"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who }}
|
||||
</span>
|
||||
<time
|
||||
class="message-row__time"
|
||||
:datetime="message.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
|
||||
<span class="message-row__bottom">
|
||||
<span class="message-row__subject">{{ subject }}</span>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="message-row__clip"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
:class="['message-row__star', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="emit('toggle-flag', message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="15"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.message-row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.message-row.is-selected {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
|
||||
.message-row__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--size-list-row);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
|
||||
line-height: var(--line-height-heading);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-row__main:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
.message-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.message-row__who {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.message-row__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.message-row__bottom {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.message-row__subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.message-row.is-unread .message-row__who {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-row.is-unread .message-row__subject,
|
||||
.message-row.is-unread .message-row__time {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-row__star {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-touch);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.message-row__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.message-row__star:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
|
||||
interface Props {
|
||||
rows?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
rows: 7,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="list-skeleton"
|
||||
aria-label="메일 목록 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row"
|
||||
class="list-skeleton__row"
|
||||
>
|
||||
<span class="list-skeleton__bar list-skeleton__bar--who" />
|
||||
<span class="list-skeleton__bar list-skeleton__bar--subject" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list-skeleton__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-12) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.list-skeleton__bar {
|
||||
height: var(--space-10);
|
||||
border-radius: var(--space-6);
|
||||
}
|
||||
|
||||
.list-skeleton__bar--who {
|
||||
width: 36%;
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.list-skeleton__bar--subject {
|
||||
width: 82%;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 행마다 길이를 조금씩 달리한다 (인라인 스타일 대신 순서로) */
|
||||
.list-skeleton__row:nth-child(3n + 2) .list-skeleton__bar--who {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.list-skeleton__row:nth-child(3n + 2) .list-skeleton__bar--subject {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.list-skeleton__row:nth-child(3n) .list-skeleton__bar--who {
|
||||
width: 44%;
|
||||
}
|
||||
|
||||
.list-skeleton__row:nth-child(3n) .list-skeleton__bar--subject {
|
||||
width: 76%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
const DETAIL: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
describe('MessageReader 도구 모음', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
async function mountReader() {
|
||||
const store = useMailStore()
|
||||
store.selected = DETAIL
|
||||
store.selectedId = DETAIL.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
return mount(MessageReader, { global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
function linkHref(wrapper: Awaited<ReturnType<typeof mountReader>>, label: string): string | undefined {
|
||||
return wrapper
|
||||
.findAll('.tool-button')
|
||||
.find((button) => button.text() === label)
|
||||
?.attributes('href')
|
||||
}
|
||||
|
||||
it('답장 · 전체 답장 · 전달이 쓰기 화면으로 원본 메일 id 를 넘긴다', async () => {
|
||||
const wrapper = await mountReader()
|
||||
|
||||
expect(linkHref(wrapper, '답장')).toBe('/mail/compose?replyTo=m1')
|
||||
expect(linkHref(wrapper, '전체 답장')).toBe('/mail/compose?replyTo=m1&all=1')
|
||||
expect(linkHref(wrapper, '전달')).toBe('/mail/compose?forward=m1')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 메일 동작 버튼은 숨긴다', async () => {
|
||||
const store = useMailStore()
|
||||
store.selected = null
|
||||
store.selectedId = 'm1'
|
||||
store.selectedStatus = 'error'
|
||||
store.selectedError = { code: 'RES_001', message: '요청하신 정보나 페이지를 찾을 수 없습니다.' }
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: 'list' },
|
||||
slots: { 'toolbar-start': () => h('a', { class: 'test-nav', href: '/mail/inbox' }, '목록으로') },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.test-nav').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('요청하신 정보나 페이지를 찾을 수 없습니다.')
|
||||
expect(wrapper.findAll('.tool-button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('전달은 더 이상 비활성("준비 중")이 아니다', async () => {
|
||||
const wrapper = await mountReader()
|
||||
|
||||
expect(wrapper.find('[aria-disabled="true"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('준비 중')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 불러오기 실패', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountFailed(code: 'RES_001' | 'MAIL_001', layout: 'split' | 'list') {
|
||||
const store = useMailStore()
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.selected = null
|
||||
store.selectedId = 'missing'
|
||||
store.selectedStatus = 'error'
|
||||
store.selectedError = { code, message: ERROR_CODE_MESSAGES[code] }
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/missing')
|
||||
await router.isReady()
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const wrapper = mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
|
||||
const action = wrapper.get('.mail-load-error__action')
|
||||
return { wrapper, router, push, replace, action }
|
||||
}
|
||||
|
||||
it.each(['split', 'list'] as const)(
|
||||
'%s 보기 — 없는 메일(404 RES_001)이면 "다시 시도" 대신 "목록으로" — 기록을 쌓지 않고 그 메일함 목록으로 간다',
|
||||
async (layout) => {
|
||||
const requests = installMockApi(() => ok({}))
|
||||
const { wrapper, router, push, replace, action } = await mountFailed('RES_001', layout)
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
|
||||
expect(action.text()).toBe('목록으로')
|
||||
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(replace).toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
// 다시 불러오지 않는다 — 없는 메일이다
|
||||
expect(requests).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('메일 서버 장애 같은 일시적 오류는 "다시 시도" 그대로 — 누르면 다시 불러온다', async () => {
|
||||
const requests = installMockApi(() => ok(DETAIL))
|
||||
const { router, action } = await mountFailed('MAIL_001', 'split')
|
||||
|
||||
expect(action.text()).toBe('다시 시도')
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toContain('GET /messages/missing')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* split: 분할 보기 — 삭제하면 다음 메일(없으면 이전 메일)을 연다
|
||||
* list: 목록 보기에서 연 메일 — 툴바 앞에 "목록으로 · 이전/다음"(toolbar-start 슬롯), 삭제하면 목록으로
|
||||
*/
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const deleting = ref<boolean>(false)
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
|
||||
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
|
||||
const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
const subject = computed(() => message.value?.subject || '(제목 없음)')
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
if (!current || !mailboxId || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
const nextId = await mailStore.removeMessage(current.id)
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 삭제하면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToList(mailboxId)
|
||||
return
|
||||
}
|
||||
await router.replace(nextId ? messageLocation(mailboxId, nextId) : mailboxLocation(mailboxId))
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 메일은 그대로 둔다
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
function leaveToList() {
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
void (mailboxId ? backToList(mailboxId) : replaceWithMailbox(null))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader">
|
||||
<!--
|
||||
툴바 띠는 칸 전체 폭, 버튼 줄은 콘텐츠 열 안 — 첫 아이콘이 본문과 같은 32px 선에서 시작한다.
|
||||
목록 보기에서는 메일을 불러오는 중이거나 실패해도 "목록으로 · 이전/다음"이 남아 있어야 하므로 항상 그린다.
|
||||
-->
|
||||
<div
|
||||
v-if="message || layout === 'list'"
|
||||
class="reader__toolbar-bar"
|
||||
>
|
||||
<div
|
||||
class="reader__toolbar"
|
||||
role="group"
|
||||
aria-label="메일 동작"
|
||||
>
|
||||
<slot name="toolbar-start" />
|
||||
<template v-if="message">
|
||||
<MailToolButton
|
||||
icon="reply"
|
||||
label="답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="reply-all"
|
||||
label="전체 답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="forward"
|
||||
label="전달"
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="isTrash ? '영구 삭제' : '삭제'"
|
||||
tone="danger"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mailStore.selectedStatus === 'loading'"
|
||||
:class="['reader__loading', { 'reader__loading--below-toolbar': layout === 'list' }]"
|
||||
aria-busy="true"
|
||||
aria-label="메일 불러오는 중"
|
||||
>
|
||||
<span class="reader__bar reader__bar--title" />
|
||||
<span class="reader__bar reader__bar--meta" />
|
||||
<span class="reader__bar" />
|
||||
<span class="reader__bar" />
|
||||
<span class="reader__bar reader__bar--short" />
|
||||
</div>
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.selectedStatus === 'error' && mailStore.selectedError"
|
||||
:error="mailStore.selectedError"
|
||||
leave-label="목록으로"
|
||||
fill
|
||||
@retry="retry"
|
||||
@leave="leaveToList"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-else-if="message"
|
||||
class="reader__article"
|
||||
aria-labelledby="reader-subject"
|
||||
>
|
||||
<div class="reader__content">
|
||||
<div class="reader__subject-row">
|
||||
<h2
|
||||
id="reader-subject"
|
||||
class="reader__subject"
|
||||
>
|
||||
{{ subject }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
:class="['reader__star', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="18"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="reader__sender">
|
||||
<span
|
||||
class="reader__avatar"
|
||||
aria-hidden="true"
|
||||
>{{ initial }}</span>
|
||||
<div class="reader__sender-info">
|
||||
<div class="reader__from">
|
||||
<span class="reader__from-name">{{ fromName }}</span>
|
||||
<span
|
||||
v-if="fromEmail && fromEmail !== fromName"
|
||||
class="reader__from-email"
|
||||
>{{ fromEmail }}</span>
|
||||
<time
|
||||
class="reader__date"
|
||||
:datetime="message.receivedAt"
|
||||
>{{ receivedAt }}</time>
|
||||
</div>
|
||||
<p
|
||||
v-if="message.to.length > 0"
|
||||
class="reader__recipients"
|
||||
>
|
||||
<span class="reader__recipients-label">받는 사람</span>
|
||||
{{ formatAddressList(message.to) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="message.cc.length > 0"
|
||||
class="reader__recipients"
|
||||
>
|
||||
<span class="reader__recipients-label">참조</span>
|
||||
{{ formatAddressList(message.cc) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="message.hasBlockedImages"
|
||||
class="reader__notice"
|
||||
>
|
||||
<AppIcon
|
||||
name="shield"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span>외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
|
||||
</p>
|
||||
|
||||
<MailBodyFrame
|
||||
:html-body="message.htmlBody"
|
||||
:text-body="message.textBody"
|
||||
:title="`메일 본문: ${subject}`"
|
||||
/>
|
||||
|
||||
<MessageAttachments
|
||||
v-if="message.attachments.length > 0"
|
||||
:message-id="message.id"
|
||||
:attachments="message.attachments"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<StateMessage
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택하세요"
|
||||
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reader {
|
||||
flex: 999 1 30rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reader__article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
|
||||
.reader__toolbar-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 버튼 줄 — 콘텐츠 열 안. 안쪽 여백 22px + 버튼 안쪽 10px = 첫 아이콘이 32px 선 */
|
||||
.reader__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: var(--content-max-width);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
|
||||
}
|
||||
|
||||
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
|
||||
.reader__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
max-width: var(--content-max-width);
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.reader__subject-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.reader__subject {
|
||||
flex: 1 1 auto;
|
||||
margin: var(--space-4) 0 0;
|
||||
font-size: var(--font-size-heading);
|
||||
line-height: var(--line-height-heading);
|
||||
font-weight: 650;
|
||||
letter-spacing: var(--letter-spacing-tighter);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reader__star {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.reader__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.reader__sender {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.reader__avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-36);
|
||||
height: var(--space-36);
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader__sender-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.reader__from {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.reader__from-name {
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader__from-email {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reader__date {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reader__recipients {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reader__recipients-label {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.reader__notice-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 툴바가 없을 때(분할 보기)는 툴바 높이만큼 비워 두어 불러온 뒤 화면이 튀지 않게 한다 */
|
||||
.reader__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
max-width: var(--content-max-width);
|
||||
padding: calc(var(--size-header-min) + var(--content-block-start)) var(--content-inline)
|
||||
var(--content-block-end);
|
||||
}
|
||||
|
||||
.reader__loading--below-toolbar {
|
||||
padding-top: var(--content-block-start);
|
||||
}
|
||||
|
||||
.reader__bar {
|
||||
width: 80%;
|
||||
height: var(--space-10);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.reader__bar--title {
|
||||
width: 55%;
|
||||
height: var(--space-18);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.reader__bar--meta {
|
||||
width: 35%;
|
||||
margin-bottom: var(--space-12);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.reader__bar--short {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 본문 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.reader {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 15,
|
||||
unreadCount: 2,
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '박준호', email: 'junho.park@arasystem.test' }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2025-10-24T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 62 * 1024,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageTable (목록 보기)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountTable(
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
|
||||
) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [options.mailbox ?? INBOX]
|
||||
store.currentMailboxId = (options.mailbox ?? INBOX).id
|
||||
store.messages = options.messages ?? [summary('m1', { isRead: false, hasAttachment: true }), summary('m2')]
|
||||
store.pageInfo = { hasNext: options.hasNext ?? false, nextCursor: options.hasNext ? 'c2' : null, total: 15 }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${store.currentMailboxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageTable, {
|
||||
props: { mailboxId: store.currentMailboxId ?? 'inbox' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('진짜 <table> 이다 — 캡션 · 다섯 칸 colgroup · 머리 칸', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
|
||||
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('행 어디를 눌러도 메일을 연다', async () => {
|
||||
const { wrapper, router } = await mountTable()
|
||||
|
||||
await wrapper.findAll('tbody tr')[1]?.get('.mail-table__who').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('별 버튼은 메일을 열지 않고 중요 표시만 바꾼다', async () => {
|
||||
const requests = installMockApi(() => ok(summary('m2', { isFlagged: true })))
|
||||
const { wrapper, router, store } = await mountTable()
|
||||
|
||||
await wrapper.findAll('.mail-table__star-button')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(requests[0]).toMatchObject({ method: 'PATCH', url: '/messages/m2', data: { isFlagged: true } })
|
||||
expect(store.messages[1]?.isFlagged).toBe(true)
|
||||
})
|
||||
|
||||
it('제목 칸은 키보드로 갈 수 있는 링크다', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
const link = wrapper.findAll('a.mail-table__link')[0]
|
||||
|
||||
expect(link?.attributes('href')).toBe('/mail/inbox/m1')
|
||||
expect(link?.text()).toBe('제목 m1')
|
||||
})
|
||||
|
||||
it('안 읽은 메일은 굵게(행 표시) 하고 스크린리더에는 "안 읽음" 을 읽힌다 · 첨부는 제목 뒤 클립', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
const [unread, read] = wrapper.findAll('tbody tr')
|
||||
|
||||
expect(unread?.classes()).toContain('is-unread')
|
||||
expect(read?.classes()).not.toContain('is-unread')
|
||||
expect(unread?.get('.mail-table__who').text()).toContain('안 읽음,')
|
||||
expect(unread?.find('.mail-table__subject [aria-label="첨부 있음"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('날짜 · 크기 칸 — 크기는 사람이 읽는 단위', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
const firstRow = wrapper.findAll('tbody tr')[0]
|
||||
|
||||
expect(firstRow?.get('.mail-table__date').text()).toBe('2025.10.24')
|
||||
expect(firstRow?.get('td.mail-table__size').text()).toBe('62 KB')
|
||||
})
|
||||
|
||||
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
|
||||
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
|
||||
|
||||
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
|
||||
})
|
||||
|
||||
it('서버에 더 있으면 "메일 더 보기" 로 다음 페이지를 불러온다', async () => {
|
||||
const requests = installMockApi(() =>
|
||||
ok({ items: [summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 15 } }),
|
||||
)
|
||||
const { wrapper } = await mountTable({ hasNext: true })
|
||||
|
||||
await wrapper.get('.message-table__more-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('목록으로 돌아오면 보던 메일의 제목 링크로 초점을 돌려준다', async () => {
|
||||
useMailViewStore().setReturnFocus('m2')
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
const { wrapper } = await mountTable()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
|
||||
scrollTo.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,486 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
|
||||
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { messageLocation, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
/** 넓은 화면에서 표 칸 자체가 스크롤된다 — 메일을 열었다 돌아올 때 위치를 되살린다 */
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const isOutgoing = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
|
||||
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function whoOf(message: MessageSummary): string {
|
||||
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
|
||||
return displayName(message.from) || '(보낸 사람 없음)'
|
||||
}
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent, message: MessageSummary) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('a, button')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
void router.push(messageLocation(props.mailboxId, message.id))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
// 메일을 열면 이 표가 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살린다 (불러온 페이지는 스토어에 그대로 있다)
|
||||
onBeforeUnmount(() => {
|
||||
viewStore.rememberListPosition({
|
||||
mailboxId: props.mailboxId,
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = viewStore.takeListPosition(props.mailboxId)
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
// 보던 메일의 제목 링크로 초점을 돌려준다 (키보드 사용자가 자리를 잃지 않게)
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="message-table"
|
||||
aria-labelledby="message-table-title"
|
||||
:aria-busy="busy"
|
||||
>
|
||||
<MailListHeader
|
||||
title-id="message-table-title"
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<!-- 표 최소 폭(400px)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table class="mail-table">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:class="['mail-table__row', { 'is-unread': !message.isRead }]"
|
||||
@click="onRowClick($event, message)"
|
||||
>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="mail-table__who">
|
||||
<span
|
||||
v-if="!message.isRead"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ whoOf(message) }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
class="mail-table__link"
|
||||
:to="messageLocation(mailboxId, message.id)"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ message.subject || '(제목 없음)' }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-table__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-table__text-button message-table__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
class="message-table__empty"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-table {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
* 가로 스크롤만 맡는다. overflow-x 를 주면 세로도 스크롤 상자가 되어 세로 flex 안에서 줄어들고
|
||||
* 이 상자가 세로 스크롤을 가로챈다 — 줄어들지 않게 해 칸(section) 전체가 스크롤되게 한다
|
||||
* (분할 보기와 같이 "메일 더 보기"가 표 바로 뒤에 오고, 돌아올 때 칸의 스크롤 위치를 되살린다).
|
||||
*/
|
||||
.message-table__scroll {
|
||||
flex: 0 0 auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mail-table {
|
||||
width: 100%;
|
||||
min-width: var(--table-min-width);
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/*
|
||||
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
|
||||
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
|
||||
*/
|
||||
.mail-table__col-star {
|
||||
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-size {
|
||||
width: calc(var(--table-col-size) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th,
|
||||
.mail-table td {
|
||||
padding: 0 var(--table-gap) 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mail-table th:first-child,
|
||||
.mail-table td:first-child {
|
||||
padding-left: var(--space-20);
|
||||
}
|
||||
|
||||
.mail-table th:last-child,
|
||||
.mail-table td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.mail-table thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table thead .mail-table__star {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 `.mail-table th/td { text-align: left }` 보다
|
||||
우선하도록 th·td 를 함께 적는다 (클래스만 쓰면 기본 규칙에 져서 행만 왼쪽으로 어긋난다) */
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table th.mail-table__size,
|
||||
.mail-table td.mail-table__date,
|
||||
.mail-table td.mail-table__size {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mail-table__date,
|
||||
.mail-table__size {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-table__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.message-table__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-table__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-table__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.message-table__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-table__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
.mail-table__size {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table td.mail-table__date {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
|
||||
@media (max-width: 37.4375rem) {
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who-narrow) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date-narrow) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 표 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-table {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
|
||||
import type { RecipientToken } from '@/shared/utils/mail-address'
|
||||
import RecipientField from './RecipientField.vue'
|
||||
|
||||
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
|
||||
const models = new WeakMap<VueWrapper, RecipientToken[]>()
|
||||
|
||||
function mountField(initial: RecipientToken[] = []): VueWrapper {
|
||||
const wrapper: VueWrapper = mount(RecipientField, {
|
||||
props: {
|
||||
modelValue: initial,
|
||||
label: '받는 사람',
|
||||
inputId: 'compose-to',
|
||||
errorId: 'compose-recipient-error',
|
||||
'onUpdate:modelValue': (value: RecipientToken[]) => {
|
||||
models.set(wrapper, value)
|
||||
return wrapper.setProps({ modelValue: value })
|
||||
},
|
||||
},
|
||||
})
|
||||
models.set(wrapper, initial)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function tokensOf(wrapper: VueWrapper): RecipientToken[] {
|
||||
return models.get(wrapper) ?? []
|
||||
}
|
||||
|
||||
describe('RecipientField', () => {
|
||||
it('Enter 로 토큰을 확정하고, Enter 가 폼을 제출하지 않게 막는다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
|
||||
input.element.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('쉼표와 입력칸을 벗어날 때(blur)도 확정한다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('a@arasystem.it.kr')
|
||||
await input.trigger('keydown', { key: ',' })
|
||||
await input.setValue('b@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
|
||||
'a@arasystem.it.kr',
|
||||
'b@arasystem.it.kr',
|
||||
])
|
||||
})
|
||||
|
||||
it('한글 조합 중의 Enter 는 토큰으로 만들지 않는다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('이도')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
})
|
||||
|
||||
it('형식이 틀린 주소는 빨간 물결 밑줄 토큰이 되고 입력칸이 오류 안내를 가리킨다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('kim@arasystem')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
expect(wrapper.get('.recipient-chip').classes()).toContain('is-invalid')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('compose-recipient-error')
|
||||
})
|
||||
|
||||
it('여러 주소를 붙여 넣으면 한 번에 나눠 토큰으로 만든다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
const paste = new Event('paste', { cancelable: true }) as ClipboardEvent
|
||||
Object.defineProperty(paste, 'clipboardData', {
|
||||
value: { getData: () => 'a@b.kr, 박준호 <junho.park@arasystem.it.kr>' },
|
||||
})
|
||||
input.element.dispatchEvent(paste)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(paste.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
|
||||
'a@b.kr',
|
||||
'박준호 <junho.park@arasystem.it.kr>',
|
||||
])
|
||||
})
|
||||
|
||||
it('빈 입력칸에서 Backspace 는 마지막 토큰을 지우고, 삭제 버튼은 그 토큰만 지운다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('a@b.kr, c@d.kr, e@f.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
await input.trigger('keydown', { key: 'Backspace' })
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['a@b.kr', 'c@d.kr'])
|
||||
|
||||
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
|
||||
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
|
||||
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import {
|
||||
hasOpenRecipientGroup,
|
||||
mergeRecipientTokens,
|
||||
parseRecipient,
|
||||
recipientTokenKey,
|
||||
splitRecipientInput,
|
||||
type RecipientToken,
|
||||
} from '@/shared/utils/mail-address'
|
||||
|
||||
interface Props {
|
||||
modelValue: RecipientToken[]
|
||||
label: string
|
||||
inputId: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** 오류 안내 문단 id — 형식이 틀린 주소가 있으면 입력칸과 연결한다 */
|
||||
errorId?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: RecipientToken[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
errorId: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력 */
|
||||
const draft = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
|
||||
|
||||
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
|
||||
function commit(text: string = draft.value) {
|
||||
const tokens = splitRecipientInput(text)
|
||||
.map(parseRecipient)
|
||||
.filter((token): token is RecipientToken => token !== null)
|
||||
draft.value = ''
|
||||
if (tokens.length === 0) return
|
||||
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// 한글 조합 중의 Enter 는 글자 확정이다 — 토큰으로 만들지 않는다
|
||||
if (event.isComposing) return
|
||||
|
||||
const isSeparator =
|
||||
(event.key === ',' || event.key === ';') && !hasOpenRecipientGroup(draft.value)
|
||||
if (event.key === 'Enter' || isSeparator) {
|
||||
// Enter 가 폼을 제출(메일 발송)하지 않게 막는다
|
||||
event.preventDefault()
|
||||
commit()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace' && draft.value === '' && props.modelValue.length > 0) {
|
||||
emit('update:modelValue', props.modelValue.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text') ?? ''
|
||||
// 주소 하나면 평소처럼 붙여 넣고, 여러 개면 바로 토큰으로 나눈다
|
||||
if (!/[,;\n]/.test(text)) return
|
||||
event.preventDefault()
|
||||
commit(`${draft.value}${text}`)
|
||||
}
|
||||
|
||||
function remove(token: RecipientToken) {
|
||||
const key = recipientTokenKey(token)
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
|
||||
)
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
function focus() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ commit, focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recipient-field">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="recipient-field__label"
|
||||
>{{ label }}</label>
|
||||
<div class="recipient-field__control">
|
||||
<span
|
||||
v-for="token in modelValue"
|
||||
:key="recipientTokenKey(token)"
|
||||
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
|
||||
>
|
||||
<span class="recipient-chip__label">{{ token.label }}</span>
|
||||
<span
|
||||
v-if="!token.valid"
|
||||
class="visually-hidden"
|
||||
>(주소 형식 오류)</span>
|
||||
<button
|
||||
type="button"
|
||||
class="recipient-chip__remove"
|
||||
:aria-label="`${token.label} 삭제`"
|
||||
:disabled="disabled"
|
||||
@click="remove(token)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="12"
|
||||
:stroke-width="2.25"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
class="recipient-field__input"
|
||||
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="hasInvalid"
|
||||
:aria-describedby="hasInvalid && errorId ? errorId : undefined"
|
||||
@keydown="onKeydown"
|
||||
@blur="commit()"
|
||||
@paste="onPaste"
|
||||
>
|
||||
</div>
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipient-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6) var(--space-12);
|
||||
min-height: var(--size-touch);
|
||||
/* 쓰기 화면의 다른 줄과 같은 콘텐츠 열 32px 선 */
|
||||
padding: var(--space-4) var(--content-toolbar-start) var(--space-4) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.recipient-field__label {
|
||||
flex: 0 0 4rem;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recipient-field__control {
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.recipient-field__input {
|
||||
flex: 1 1 12.5rem;
|
||||
min-width: 7.5rem;
|
||||
min-height: 2.125rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.recipient-field__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 테두리 없는 넓은 입력 영역이라 쓰기 화면의 제목·본문 칸과 같이 1px 로 가볍게 표시한다 */
|
||||
.recipient-field__control:focus-within {
|
||||
outline: var(--border-width) solid var(--color-brand);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--space-28);
|
||||
max-width: 100%;
|
||||
padding: 0 var(--space-2) 0 var(--space-8);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.recipient-chip__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 형식 오류 — 디자인 원본의 빨간 물결 밑줄 */
|
||||
.recipient-chip.is-invalid {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.recipient-chip.is-invalid .recipient-chip__label {
|
||||
text-decoration: underline wavy var(--color-danger);
|
||||
text-underline-offset: var(--space-3);
|
||||
}
|
||||
|
||||
.recipient-chip__remove {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.recipient-chip__remove:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
// "분할 | 목록" 보기 전환 (디자인 원본 Main · MainList 목록 머리)
|
||||
// 선택은 사용자별로 기억한다 (stores/mail-view.store.ts).
|
||||
//
|
||||
// 크기: 한 칸 높이 30px, 테두리·안쪽 여백을 더해 바깥 36px — 클릭 영역 44px 기준의 의도된 예외다.
|
||||
// 목록 머리에 정보를 촘촘히 두기 위해 사용자가 확정했다 (디자인 README "디자인 원칙").
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMailViewStore, type MailViewMode } from '@/stores/mail-view.store'
|
||||
|
||||
const viewStore = useMailViewStore()
|
||||
|
||||
const OPTIONS: readonly { mode: MailViewMode; label: string; icon: 'view-split' | 'view-list' }[] = [
|
||||
{ mode: 'split', label: '분할', icon: 'view-split' },
|
||||
{ mode: 'list', label: '목록', icon: 'view-list' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="view-switch"
|
||||
role="group"
|
||||
aria-label="보기 방식"
|
||||
>
|
||||
<button
|
||||
v-for="option in OPTIONS"
|
||||
:key="option.mode"
|
||||
type="button"
|
||||
:class="['view-switch__item', { 'is-active': viewStore.mode === option.mode }]"
|
||||
:aria-pressed="viewStore.mode === option.mode"
|
||||
@click="viewStore.setMode(option.mode)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="option.icon"
|
||||
:size="14"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ option.label }}<span class="visually-hidden"> 보기</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-switch {
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.view-switch__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
min-height: var(--size-segment-item);
|
||||
padding: 0 var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.view-switch__item:hover:not(.is-active) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.view-switch__item.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { APP_MODULES } from '@/shared/constants/app-modules'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AppRail from './AppRail.vue'
|
||||
|
||||
describe('AppRail', () => {
|
||||
async function mountRail() {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
return mount(AppRail, { props: { activeModule: 'mail' }, global: { plugins: [router] } })
|
||||
}
|
||||
|
||||
it('모듈 목록 하나로 그린다 — 메일 · 캘린더 · 파일 · ERP · 주소록 · 설정', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const labels = wrapper.findAll('.rail-item').map((item) => item.text().replace('(준비 중)', '').trim())
|
||||
|
||||
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
|
||||
})
|
||||
|
||||
it('지금은 메일만 링크이고 현재 모듈로 표시된다', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const current = wrapper.get('a[aria-current="page"]')
|
||||
|
||||
expect(current.text()).toBe('메일')
|
||||
expect(current.attributes('href')).toBe('/mail')
|
||||
})
|
||||
|
||||
it('나머지는 "준비 중" 비활성 칸이다 (뱃지 없이 툴팁·스크린리더 문구로)', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const disabled = wrapper.findAll('button[aria-disabled="true"]')
|
||||
|
||||
expect(disabled).toHaveLength(APP_MODULES.length - 1)
|
||||
for (const item of disabled) {
|
||||
expect(item.attributes('title')).toContain('준비 중')
|
||||
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 앱 레일 — 모듈 목록(APP_MODULES) 하나로 그린다. 새 모듈은 목록에만 추가하면 된다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRailItem from './AppRailItem.vue'
|
||||
import logoMark from '@/assets/brand/logo-ara-white.png'
|
||||
import { APP_MODULES, type AppModuleId } from '@/shared/constants/app-modules'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
|
||||
interface Props {
|
||||
/** 지금 보고 있는 모듈 */
|
||||
activeModule: AppModuleId
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const mainModules = computed(() => APP_MODULES.filter((module) => module.placement === 'main'))
|
||||
const endModules = computed(() => APP_MODULES.filter((module) => module.placement === 'end'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="app-rail"
|
||||
aria-label="사내 시스템"
|
||||
>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="app-rail__logo"
|
||||
>
|
||||
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 320×111 -->
|
||||
<img
|
||||
:src="logoMark"
|
||||
:alt="APP_NAME"
|
||||
width="320"
|
||||
height="111"
|
||||
class="app-rail__logo-image"
|
||||
>
|
||||
</RouterLink>
|
||||
<ul class="app-rail__list">
|
||||
<li
|
||||
v-for="module in mainModules"
|
||||
:key="module.id"
|
||||
>
|
||||
<AppRailItem
|
||||
:module="module"
|
||||
:active="module.id === activeModule"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="app-rail__list app-rail__list--end">
|
||||
<li
|
||||
v-for="module in endModules"
|
||||
:key="module.id"
|
||||
>
|
||||
<AppRailItem
|
||||
:module="module"
|
||||
:active="module.id === activeModule"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-rail {
|
||||
flex: 0 0 var(--size-rail);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-10) 0 var(--space-12);
|
||||
background: var(--color-brand-surface);
|
||||
}
|
||||
|
||||
.app-rail__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-rail-item);
|
||||
min-height: var(--size-touch);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.app-rail__logo:focus-visible {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
|
||||
.app-rail__logo-image {
|
||||
display: block;
|
||||
width: 3rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-rail__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.app-rail__list--end {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 레일의 모듈 한 칸 — 라우트가 있으면 링크, 없으면 "준비 중" 비활성 칸 (뱃지 없이)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MODULE_NOT_READY_LABEL, type AppModule } from '@/shared/constants/app-modules'
|
||||
|
||||
interface Props {
|
||||
module: AppModule
|
||||
/** 지금 보고 있는 모듈인지 */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
active: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="module.routeName"
|
||||
:to="{ name: module.routeName }"
|
||||
:class="['rail-item', { 'is-active': active }]"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon
|
||||
:name="module.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<span>{{ module.label }}</span>
|
||||
</RouterLink>
|
||||
<!-- 준비 중인 모듈 — 초점은 받아 존재를 알리되 눌러도 아무 일도 하지 않는다 -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="rail-item is-disabled"
|
||||
aria-disabled="true"
|
||||
:title="`${module.label} (${MODULE_NOT_READY_LABEL})`"
|
||||
>
|
||||
<AppIcon
|
||||
:name="module.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<span>{{ module.label }}</span>
|
||||
<span class="visually-hidden">({{ MODULE_NOT_READY_LABEL }})</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
width: var(--size-rail-item);
|
||||
min-height: var(--space-56);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
color: var(--color-on-brand-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.rail-item:hover {
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand-surface-active);
|
||||
}
|
||||
|
||||
.rail-item.is-active {
|
||||
background: var(--color-brand-surface-active);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rail-item.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 어두운 바탕에서는 강조색 테두리가 잘 보이지 않아 밝은 색으로 바꾼다 */
|
||||
.rail-item:focus-visible {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
|
||||
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRail from './AppRail.vue'
|
||||
import AppTopBar from './AppTopBar.vue'
|
||||
import { findAppModule, type AppModuleId } from '@/shared/constants/app-modules'
|
||||
|
||||
interface Props {
|
||||
moduleId: AppModuleId
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const moduleLabel = computed(() => findAppModule(props.moduleId).label)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<AppRail :active-module="moduleId" />
|
||||
<div class="app-shell__main">
|
||||
<AppTopBar :title="moduleLabel" />
|
||||
<div class="app-shell__module">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 모듈 영역 — 좁은 화면에서는 칸이 아래로 접힌다 (디자인 원본의 flex-wrap) */
|
||||
.app-shell__module {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/*
|
||||
* 넓은 화면(tokens.css 화면 폭 기준)에서는 화면 높이에 맞추고 칸마다 따로 스크롤한다.
|
||||
* 줄바꿈(wrap)을 끄는 이유: 여러 줄 flex 는 줄 높이를 내용에 맞춰 늘리므로 칸이 화면 높이에 묶이지 않는다.
|
||||
* 이 폭에서는 칸이 원래 한 줄에 다 들어간다.
|
||||
*/
|
||||
@media (min-width: 70rem) {
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell__main,
|
||||
.app-shell__module {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-shell__module {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { MODULE_NOT_READY_LABEL } from '@/shared/constants/app-modules'
|
||||
import { initialOf } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
interface Props {
|
||||
/** 지금 모듈 이름 (예: 메일) */
|
||||
title: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const loggingOut = ref<boolean>(false)
|
||||
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
|
||||
|
||||
async function onLogout() {
|
||||
if (loggingOut.value) return
|
||||
loggingOut.value = true
|
||||
try {
|
||||
await logout()
|
||||
} finally {
|
||||
loggingOut.value = false
|
||||
}
|
||||
await router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top-bar">
|
||||
<span class="top-bar__title">{{ title }}</span>
|
||||
|
||||
<!-- 통합 검색은 1단계 범위 밖이다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="authStore.user"
|
||||
class="top-bar__account"
|
||||
>
|
||||
<span
|
||||
class="top-bar__avatar"
|
||||
aria-hidden="true"
|
||||
>{{ initial }}</span>
|
||||
<div class="top-bar__who">
|
||||
<span class="top-bar__name">{{ authStore.user.name }}</span>
|
||||
<span class="top-bar__email">{{ authStore.user.email }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="top-bar__logout"
|
||||
:disabled="loggingOut"
|
||||
@click="onLogout"
|
||||
>
|
||||
<AppIcon
|
||||
name="log-out"
|
||||
:size="15"
|
||||
/>
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-16);
|
||||
min-height: var(--size-topbar-min);
|
||||
padding: var(--space-6) var(--space-12) var(--space-6) var(--space-20);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.top-bar__title {
|
||||
font-size: var(--font-size-title);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.top-bar__search {
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: 2.5rem;
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-bar__search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.top-bar__search-input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-bar__account {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.top-bar__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.top-bar__who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.top-bar__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.top-bar__email {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.top-bar__logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-bar__logout:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import {
|
||||
ERROR_CODE_MESSAGES,
|
||||
FALLBACK_ERROR_CODE,
|
||||
} from '@/shared/constants/error-codes.generated'
|
||||
import { getErrorMessage } from './useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import api, {
|
||||
buildApiUrl,
|
||||
confirmSession,
|
||||
getErrorMessage,
|
||||
isApiError,
|
||||
refreshSession,
|
||||
type ApiError,
|
||||
} from './useApi'
|
||||
|
||||
describe('에러 코드 사전', () => {
|
||||
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
|
||||
@@ -35,6 +53,14 @@ describe('에러 코드 사전', () => {
|
||||
'VAL_001',
|
||||
'RES_001',
|
||||
'BIZ_001',
|
||||
// 웹메일 1단계 — 화면이 이 코드로 분기하거나 사전 문구를 직접 꺼내 쓴다
|
||||
'AUTH_004', // 로그인 폼 안내
|
||||
'MAIL_002', // 받는 사람 오류 안내 (형식 오류 · 서버 거부 · 100명 초과)
|
||||
'MAIL_004', // 첨부 만료 — 쓰기 화면이 첨부를 "다시 올리기 필요"로 표시
|
||||
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
|
||||
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
@@ -48,3 +74,442 @@ describe('에러 코드 사전', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── 인터셉터 동작 ─────────────────────────────────────────────
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const TRACE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
|
||||
|
||||
/** 같은 코드 중복 억제(3초)가 테스트끼리 섞이지 않게 테스트마다 시계를 크게 넘긴다 */
|
||||
let clock = Date.UTC(2026, 9, 26, 1, 0, 0)
|
||||
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<ApiError> {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
if (isApiError(error)) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error('요청이 성공해 버렸다')
|
||||
}
|
||||
|
||||
function toastMessages(): string[] {
|
||||
return useToastStore().toasts.map((toast) => toast.message)
|
||||
}
|
||||
|
||||
describe('useApi 인터셉터', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('성공 응답은 data 만 꺼내 돌려준다', async () => {
|
||||
installMockApi(() => ok({ items: [1, 2] }))
|
||||
await expect(api.get('/mailboxes')).resolves.toEqual({ items: [1, 2] })
|
||||
})
|
||||
|
||||
it('실패하면 사전 문구로 토스트를 띄우고 문의 코드(traceId)를 함께 싣는다', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001', TRACE_ID))
|
||||
|
||||
const error = await rejectionOf(api.get('/mailboxes'))
|
||||
|
||||
expect(error).toMatchObject({ code: 'MAIL_001', status: 503, traceId: TRACE_ID })
|
||||
expect(error.message).toBe(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(useToastStore().toasts).toEqual([
|
||||
expect.objectContaining({ kind: 'error', message: ERROR_CODE_MESSAGES.MAIL_001, traceId: TRACE_ID }),
|
||||
])
|
||||
})
|
||||
|
||||
it('같은 코드의 알림은 3초 안에 한 번만 띄우고, 3초가 지나면 다시 띄운다', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001'))
|
||||
|
||||
await rejectionOf(api.get('/mailboxes'))
|
||||
vi.setSystemTime(clock + 1000)
|
||||
await rejectionOf(api.get('/mailboxes'))
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
|
||||
vi.setSystemTime(clock + 3100)
|
||||
await rejectionOf(api.get('/mailboxes'))
|
||||
expect(useToastStore().toasts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('silent: true 면 알림 없이 정규화된 에러만 돌려준다', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001'))
|
||||
|
||||
const error = await rejectionOf(api.get('/mailboxes', { silent: true }))
|
||||
|
||||
expect(error.code).toBe('MAIL_001')
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('silent 에 코드를 주면 그 코드만 알림을 끈다', async () => {
|
||||
let code = 'AUTH_004'
|
||||
installMockApi(() => fail(code === 'AUTH_004' ? 401 : 429, code))
|
||||
|
||||
await rejectionOf(api.post('/auth/login', {}, { silent: ['AUTH_004'] }))
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
code = 'REQ_001'
|
||||
await rejectionOf(api.post('/auth/login', {}, { silent: ['AUTH_004'] }))
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.REQ_001])
|
||||
})
|
||||
|
||||
it('네트워크 단절은 SYS_001 로 알린다', async () => {
|
||||
installMockApi(() => 'network')
|
||||
|
||||
const error = await rejectionOf(api.get('/mailboxes'))
|
||||
|
||||
expect(error.code).toBe('SYS_001')
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('호출부가 취소한 요청은 알리지 않고 canceled 로 표시한다', async () => {
|
||||
installMockApi(() => 'cancel')
|
||||
|
||||
const error = await rejectionOf(api.post('/mail-attachments', new FormData()))
|
||||
|
||||
expect(error.canceled).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('표준 본문이 없는 413(앞단 프록시 거절)은 FILE_002 로 맞춘다', async () => {
|
||||
installMockApi(() => ({ status: 413, data: '<html>413 Request Entity Too Large</html>' }))
|
||||
|
||||
const error = await rejectionOf(api.post('/mail-attachments', new FormData(), { silent: true }))
|
||||
|
||||
expect(error.code).toBe('FILE_002')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[413, 'FILE_002'],
|
||||
[502, 'SYS_001'],
|
||||
[504, 'SYS_001'],
|
||||
] as const)(
|
||||
'프록시(nginx)가 직접 낸 %i 표준 실패(%s, message 빈 문자열)도 코드로 사전 문구를 쓰고 nginx 요청 ID 를 문의 코드로 싣는다 (계약 머리말)',
|
||||
async (status, code) => {
|
||||
const NGINX_REQUEST_ID = '9f2c4b1d7e8a4c3b9d0e1f2a3b4c5d6e'
|
||||
installMockApi(() => ({
|
||||
status,
|
||||
data: { success: false, error: { code, message: '', traceId: NGINX_REQUEST_ID } },
|
||||
}))
|
||||
|
||||
const error = await rejectionOf(api.get('/messages'))
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code,
|
||||
message: ERROR_CODE_MESSAGES[code],
|
||||
traceId: NGINX_REQUEST_ID,
|
||||
status,
|
||||
})
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ message: ERROR_CODE_MESSAGES[code], traceId: NGINX_REQUEST_ID },
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
it('HTTP 200 이어도 success:false 면 실패로 처리한다', async () => {
|
||||
installMockApi(() => ({ status: 200, data: { success: false, error: { code: 'BIZ_001' } } }))
|
||||
|
||||
const error = await rejectionOf(api.get('/mailboxes'))
|
||||
|
||||
expect(error.code).toBe('BIZ_001')
|
||||
})
|
||||
|
||||
it('buildApiUrl 은 axios baseURL 과 같은 규칙으로 주소를 만든다', () => {
|
||||
expect(api.defaults.baseURL).toBe('/api')
|
||||
expect(buildApiUrl('/messages/m1/attachments/a1')).toBe('/api/messages/m1/attachments/a1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApi 401 처리 (계약 1절)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('로그인 중 보호 API 가 401 이면 /auth/refresh 를 한 번 부르고 원래 요청을 다시 보낸다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
let refreshed = false
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') {
|
||||
refreshed = true
|
||||
return ok(USER)
|
||||
}
|
||||
return refreshed ? ok({ items: [] }) : fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
await expect(api.get('/messages')).resolves.toEqual({ items: [] })
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'GET /messages',
|
||||
])
|
||||
expect(toastMessages()).toEqual([])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
it('갱신 응답이 Set-Cookie 없는 200(다른 탭이 먼저 갱신한 유예 응답)이어도 성공으로 보고 원래 요청을 다시 보낸다', async () => {
|
||||
// 브라우저는 Set-Cookie 를 JS 에 보여 주지 않는다 — 클라이언트는 상태 코드만 보고, 쿠키는 먼저 갱신한 요청이 받은 것을 같이 쓴다
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
let refreshed = false
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') {
|
||||
refreshed = true
|
||||
return { status: 200, data: { success: true, data: USER } }
|
||||
}
|
||||
return refreshed ? ok({ items: [] }) : fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
await expect(api.get('/messages')).resolves.toEqual({ items: [] })
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'GET /messages',
|
||||
])
|
||||
expect(authStore.isLoggedIn).toBe(true)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('갱신을 시작한 뒤 세션 세대가 바뀌면(로그아웃 → 다시 로그인) 성공이어도 쓰지 않고 쿠키를 지운다 — signedOut 이 풀려도', async () => {
|
||||
const OTHER = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => (url === '/auth/refresh' ? refreshGate : ok(null)))
|
||||
|
||||
const outcome = refreshSession()
|
||||
await flushPromises()
|
||||
// 이 탭의 login() 은 진행 중인 갱신을 기다리므로, 여기서는 세션이 다른 경로로 바뀐 경우를 흉내 낸다
|
||||
authStore.clearAuth()
|
||||
authStore.startSession(OTHER)
|
||||
releaseRefresh(ok(USER))
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({ ok: false, error: { code: 'AUTH_001' } })
|
||||
expect(authStore.user).toEqual(OTHER)
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
])
|
||||
})
|
||||
|
||||
it('refreshSession 은 200 이면 응답 헤더와 상관없이 성공이다 (유예 응답)', async () => {
|
||||
const authStore = useAuthStore()
|
||||
installMockApi(() => ({ status: 200, data: { success: true, data: USER } }))
|
||||
|
||||
await expect(refreshSession()).resolves.toEqual({ ok: true, user: USER })
|
||||
expect(authStore.user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('동시에 여러 요청이 401 을 받아도 갱신 요청은 한 번만 보낸다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
let refreshed = false
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') return refreshGate
|
||||
return refreshed ? ok(url) : fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
const first = api.get('/messages')
|
||||
const second = api.get('/mailboxes')
|
||||
await flushPromises()
|
||||
refreshed = true
|
||||
releaseRefresh(ok(USER))
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(['/messages', '/mailboxes'])
|
||||
expect(requests.filter((request) => request.url === '/auth/refresh')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('갱신도 401 이면 AUTH_002 로 승격하고 세션을 정리한다 — 토스트 대신 대화상자', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
rejectionOf(api.get('/messages')),
|
||||
rejectionOf(api.get('/mailboxes')),
|
||||
])
|
||||
|
||||
expect(first.code).toBe('AUTH_002')
|
||||
expect(second.code).toBe('AUTH_002')
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.sessionExpired).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('다시 보낸 요청이 또 401 이면 더 갱신하지 않고 세션 만료로 끝낸다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001'),
|
||||
)
|
||||
|
||||
const error = await rejectionOf(api.get('/messages'))
|
||||
|
||||
expect(error.code).toBe('AUTH_002')
|
||||
expect(requests.filter((request) => request.url === '/auth/refresh')).toHaveLength(1)
|
||||
expect(requests.filter((request) => request.url === '/messages')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('갱신 요청이 장애(5xx)로 실패하면 세션을 끝내지 않고 그 오류를 알린다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
installMockApi(({ url }) =>
|
||||
url === '/auth/refresh' ? fail(500, 'SYS_001') : fail(401, 'AUTH_001'),
|
||||
)
|
||||
|
||||
const error = await rejectionOf(api.get('/messages'))
|
||||
|
||||
expect(error.code).toBe('SYS_001')
|
||||
expect(authStore.isLoggedIn).toBe(true)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it.each(['/auth/login', '/auth/refresh', '/auth/me', '/auth/logout'])(
|
||||
'%s 는 401 이어도 갱신 재시도를 하지 않는다',
|
||||
async (endpoint) => {
|
||||
const requests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
await rejectionOf(api.post(endpoint, null, { silent: true }))
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([endpoint])
|
||||
},
|
||||
)
|
||||
|
||||
it('로그인 전에 받은 401 은 세션 만료가 아니라 AUTH_001 이다 (갱신도 하지 않는다)', async () => {
|
||||
const requests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
const error = await rejectionOf(api.get('/messages'))
|
||||
|
||||
expect(error.code).toBe('AUTH_001')
|
||||
expect(useAuthStore().sessionExpired).toBe(false)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_001])
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmSession — 브라우저 이동(첨부 다운로드) 전 세션 확인 (계약 2절)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function requestLines(requests: { method: string; url: string }[]): string[] {
|
||||
return requests.map((request) => `${request.method} ${request.url}`)
|
||||
}
|
||||
|
||||
it('세션이 살아 있으면 /auth/me 한 번으로 통과한다', async () => {
|
||||
const requests = installMockApi(() => ok(USER))
|
||||
|
||||
await expect(confirmSession()).resolves.toBe(true)
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me'])
|
||||
})
|
||||
|
||||
it('액세스 토큰만 만료됐으면(AUTH_001) 세션을 끝내지 않고 /auth/refresh 를 한 번 부른 뒤 통과한다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
let loggedInWhenRefreshing: boolean | null = null
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') {
|
||||
loggedInWhenRefreshing = authStore.isLoggedIn
|
||||
return ok(USER)
|
||||
}
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
await expect(confirmSession()).resolves.toBe(true)
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me', 'POST /auth/refresh'])
|
||||
// 확인 요청의 401 을 곧바로 세션 만료로 승격하면 갱신 전에 대화상자가 떠 버린다
|
||||
expect(loggedInWhenRefreshing).toBe(true)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('갱신도 401 이면 세션 만료(AUTH_002)로 정리하고 통과시키지 않는다 — 토스트 대신 대화상자', async () => {
|
||||
const authStore = useAuthStore()
|
||||
const requests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
await expect(confirmSession()).resolves.toBe(false)
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me', 'POST /auth/refresh'])
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.sessionExpired).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('갱신 요청이 장애(5xx)로 실패하면 세션을 끝내지 않고 그 오류를 알린다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
installMockApi(({ url }) =>
|
||||
url === '/auth/refresh' ? fail(500, 'SYS_001') : fail(401, 'AUTH_001'),
|
||||
)
|
||||
|
||||
await expect(confirmSession()).resolves.toBe(false)
|
||||
|
||||
expect(authStore.isLoggedIn).toBe(true)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('확인 요청 자체가 장애면 갱신하지 않고 그 오류를 알린다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
const requests = installMockApi(() => 'network')
|
||||
|
||||
await expect(confirmSession()).resolves.toBe(false)
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me'])
|
||||
expect(authStore.isLoggedIn).toBe(true)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('여러 번 동시에 확인해도 갱신 요청은 한 번만 보낸다 (1회용 리프레시 토큰)', async () => {
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/auth/refresh' ? refreshGate : fail(401, 'AUTH_001'),
|
||||
)
|
||||
|
||||
const checks = Promise.all([confirmSession(), confirmSession()])
|
||||
await flushPromises()
|
||||
releaseRefresh(ok(USER))
|
||||
|
||||
await expect(checks).resolves.toEqual([true, true])
|
||||
expect(requests.filter((request) => request.url === '/auth/refresh')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,27 @@ import {
|
||||
FALLBACK_ERROR_CODE,
|
||||
type ErrorCode,
|
||||
} from '@/shared/constants/error-codes.generated'
|
||||
import type { AuthUser } from '@/shared/types/auth'
|
||||
import { joinUrl, resolveApiBaseUrl } from '@/shared/utils/api-url'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
|
||||
// 요청 단위 옵션 — axios 설정에 얹어 쓴다 (api.get(url, { silent: true }))
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
/**
|
||||
* 전역 오류 알림(토스트)을 끈다. 화면이 오류를 직접 안내할 때만 쓴다.
|
||||
* - true: 모든 코드의 알림을 끈다 (예: 목록 영역이 "다시 시도" 상태를 그린다)
|
||||
* - 코드 배열: 그 코드만 끈다 (예: 로그인 실패 AUTH_004 는 폼 안에 표시한다)
|
||||
* 세션 만료(AUTH_002) 대화상자는 이 옵션과 상관없이 항상 뜬다.
|
||||
*/
|
||||
silent?: boolean | readonly ErrorCode[]
|
||||
/** 내부용 — 401 뒤 /auth/refresh 재시도를 이미 한 요청인지 (한 번만 재시도한다) */
|
||||
_authRetried?: boolean
|
||||
/** 내부용 — 요청을 보낼 때 로그인 세션이 있었는지 (401 을 세션 만료로 볼지 가른다) */
|
||||
_sentWithSession?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor 와 동일 구조
|
||||
export interface StandardResponse<T = unknown> {
|
||||
@@ -29,12 +49,33 @@ export interface ApiError {
|
||||
message: string
|
||||
traceId?: string
|
||||
status?: number
|
||||
/** 호출부가 요청을 취소(AbortController)해서 끝난 경우 — 알림을 띄우지 않는다 */
|
||||
canceled?: boolean
|
||||
}
|
||||
|
||||
const API_TIMEOUT_MS = 10000
|
||||
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
|
||||
const DUPLICATE_ALERT_WINDOW_MS = 3000
|
||||
|
||||
/** 계약 1절 — 401 이어도 /auth/refresh 로 재시도하지 않는 인증 엔드포인트 */
|
||||
const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh', '/auth/me', '/auth/logout'] as const
|
||||
const REFRESH_ENDPOINT = '/auth/refresh'
|
||||
const ME_ENDPOINT = '/auth/me'
|
||||
const LOGOUT_ENDPOINT = '/auth/logout'
|
||||
|
||||
/**
|
||||
* 중앙 API 주소 — axios baseURL 과 첨부 다운로드 링크(<a href>)가 같은 규칙을 쓴다.
|
||||
* 빌드 시 VITE_API_BASE_URL(운영 이미지는 frontend/Dockerfile 빌드 arg 로 `/api` 고정)이 주입되고, 없으면 같은 출처의 /api 다.
|
||||
* 화면과 API 는 같은 origin 이라는 전제다 (계약 머리말) — 주입값이 다른 origin 이면
|
||||
* CSP connect-src 'self' 와 서버의 출처 검사(403 AUTH_003)에 막힌다.
|
||||
*/
|
||||
export const API_BASE_URL = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||
|
||||
/** 앱 안에서 API 주소를 직접 만들어야 할 때 (예: 다운로드 링크) — axios 와 같은 규칙 */
|
||||
export function buildApiUrl(path: string): string {
|
||||
return joinUrl(API_BASE_URL, path)
|
||||
}
|
||||
|
||||
function isErrorCode(code: string): code is ErrorCode {
|
||||
return code in ERROR_CODE_MESSAGES
|
||||
}
|
||||
@@ -46,28 +87,50 @@ export function getErrorMessage(code: string): string {
|
||||
: ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE]
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수 하나만 교체한다.
|
||||
/** catch 로 받은 값이 인터셉터가 만든 ApiError 인지 */
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const candidate = value as { code?: unknown; message?: unknown }
|
||||
return typeof candidate.code === 'string' && typeof candidate.message === 'string'
|
||||
}
|
||||
|
||||
/** 알 수 없는 실패를 화면 상태용 ApiError 로 맞춘다 (사전 문구 그대로) */
|
||||
export function toApiError(value: unknown): ApiError {
|
||||
if (isApiError(value)) return value
|
||||
return { code: FALLBACK_ERROR_CODE, message: getErrorMessage(FALLBACK_ERROR_CODE) }
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 를 바꾸거나 다른 표시 방식을 쓰려면 이 함수 하나만 교체한다.
|
||||
let lastAlert: { code: string; at: number } | null = null
|
||||
const showErrorUI = (code: ErrorCode, message: string) => {
|
||||
const showErrorUI = (code: ErrorCode, message: string, traceId?: string) => {
|
||||
// 세션 만료는 토스트 대신 대화상자(SessionExpiredDialog)가 안내한다
|
||||
if (code === 'AUTH_002') return
|
||||
|
||||
const now = Date.now()
|
||||
if (lastAlert && lastAlert.code === code && now - lastAlert.at < DUPLICATE_ALERT_WINDOW_MS) {
|
||||
return
|
||||
}
|
||||
lastAlert = { code, at: now }
|
||||
window.alert(message)
|
||||
// 화면 오른쪽 아래 토스트 — 문의 코드(traceId 앞 8자리)를 함께 보인다
|
||||
useToastStore().show({ kind: 'error', message, traceId })
|
||||
}
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: API_TIMEOUT_MS,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
|
||||
// 요청 인터셉터 — 보낼 때의 세션 여부를 기록한다 (CSRF, 추가 헤더 등 필요 시 확장)
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => config,
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 응답이 401 일 때 "세션 만료"(로그인 중에 보낸 요청)인지 "로그인 전"인지 가르는 기준.
|
||||
// 재시도 요청은 처음 값을 그대로 쓴다.
|
||||
config._sentWithSession ??= useAuthStore().isLoggedIn
|
||||
return config
|
||||
},
|
||||
(error: unknown) => Promise.reject(error),
|
||||
)
|
||||
|
||||
@@ -77,35 +140,172 @@ function resolveCodeByStatus(status: number): ErrorCode {
|
||||
if (status === 403) return 'AUTH_003'
|
||||
if (status === 400 || status === 422) return 'VAL_001'
|
||||
if (status === 404) return 'RES_001'
|
||||
// 앞단 프록시(nginx 등)가 큰 업로드를 자체 HTML 로 거절하는 경우
|
||||
if (status === 413) return 'FILE_002'
|
||||
if (status === 429) return 'REQ_001'
|
||||
return FALLBACK_ERROR_CODE
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 세션이 있는 상태에서 401 을 받으면 "세션 만료"로 간주한다.
|
||||
* 인증 세션이 있는 상태에서 보낸 요청이 401 을 받으면 "세션 만료"로 간주한다.
|
||||
* AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
|
||||
*/
|
||||
function toAuthCode(code: ErrorCode): ErrorCode {
|
||||
if (code !== 'AUTH_001') return code
|
||||
function toAuthCode(code: ErrorCode, sentWithSession: boolean): ErrorCode {
|
||||
if (code !== 'AUTH_001' || !sentWithSession) return code
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isLoggedIn) {
|
||||
authStore.clearAuth()
|
||||
authStore.expireSession()
|
||||
return 'AUTH_002'
|
||||
}
|
||||
return 'AUTH_001'
|
||||
// 동시에 실패한 다른 요청이 방금 세션을 정리했다 → 같은 만료로 본다 (대화상자는 이미 떠 있다)
|
||||
return authStore.sessionExpired ? 'AUTH_002' : 'AUTH_001'
|
||||
}
|
||||
|
||||
function isSilenced(code: ErrorCode, silent: boolean | readonly ErrorCode[] | undefined): boolean {
|
||||
if (!silent) return false
|
||||
if (silent === true) return true
|
||||
return silent.includes(code)
|
||||
}
|
||||
|
||||
/** 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다. */
|
||||
function handle(rawCode: string, traceId?: string, status?: number): ApiError {
|
||||
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE)
|
||||
function handle(
|
||||
rawCode: string,
|
||||
traceId: string | undefined,
|
||||
status: number | undefined,
|
||||
config: InternalAxiosRequestConfig | undefined,
|
||||
): ApiError {
|
||||
const authStore = useAuthStore()
|
||||
const sentWithSession = config?._sentWithSession ?? authStore.isLoggedIn
|
||||
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE, sentWithSession)
|
||||
const message = getErrorMessage(code)
|
||||
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 — 사용자가 끝낸 세션이라 알리지 않는다
|
||||
// (로그인 화면에 "로그인이 필요한 서비스입니다" 가 뜨지 않게)
|
||||
const endedBySignOut = code === 'AUTH_001' && sentWithSession && authStore.signedOut
|
||||
|
||||
showErrorUI(code, message)
|
||||
if (!endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
|
||||
|
||||
return { code, message, traceId, status }
|
||||
}
|
||||
|
||||
function isAuthEndpoint(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
const path = (url.split('?')[0] ?? '').replace(/\/+$/, '')
|
||||
return AUTH_ENDPOINTS.some((endpoint) => path === endpoint || path.endsWith(endpoint))
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 1절 — 로그인 중에 보낸 보호 API 가 401 AUTH_001 을 받으면 한 번만 토큰 갱신을 시도한다.
|
||||
* 그사이 로그아웃했으면(이 탭이든 다른 탭이든) 갱신하지 않는다 — 끝낸 세션을 되살리지 않는다.
|
||||
*/
|
||||
function shouldRefresh(code: string, config: InternalAxiosRequestConfig): boolean {
|
||||
return (
|
||||
code === 'AUTH_001' &&
|
||||
config._sentWithSession === true &&
|
||||
!config._authRetried &&
|
||||
!isAuthEndpoint(config.url) &&
|
||||
!useAuthStore().signedOut
|
||||
)
|
||||
}
|
||||
|
||||
/** 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다 */
|
||||
function isSessionEnd(code: ErrorCode): boolean {
|
||||
return code === 'AUTH_001' || code === 'AUTH_002'
|
||||
}
|
||||
|
||||
export type RefreshOutcome = { ok: true; user: AuthUser } | { ok: false; error: ApiError }
|
||||
|
||||
let refreshInFlight: Promise<RefreshOutcome> | null = null
|
||||
|
||||
/**
|
||||
* 늦게 온 갱신이 구웠을 수 있는 쿠키를 지운다 — /auth/logout 을 한 번 더 부른다 (서버 세션은 이미 폐기됐다).
|
||||
* 실패해도 더 할 수 있는 일이 없으므로 알리지 않는다.
|
||||
*/
|
||||
async function clearCookiesOfLateRefresh(): Promise<void> {
|
||||
try {
|
||||
await api.post(LOGOUT_ENDPOINT, null, { silent: true })
|
||||
} catch {
|
||||
// 위 설명과 같다
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키로 두 쿠키를 새로 받는다 (회전).
|
||||
* 동시에 여러 요청이 401 을 받아도 갱신 요청은 한 번만 보내고 결과를 나눠 쓴다.
|
||||
*
|
||||
* 시작한 뒤 세션이 바뀌었으면(로그아웃 — 이 탭이든 다른 탭이든, 세션 만료, 새 로그인) 성공이어도 쓰지 않는다
|
||||
* — 계약 1절 "클라이언트 처리 규칙". 시작할 때 세션 세대(authStore.generation)를 잡아 두고 끝났을 때 비교한다.
|
||||
* 갱신과 로그아웃이 겹치면 서버가 로그아웃 뒤에 회전을 마치고 새 쿠키를 구울 수 있다. 세션은 끊겼어도
|
||||
* 액세스 토큰은 만료(최대 1시간)까지 유효해, 새로고침하면 로그인된 것처럼 보인다(공용 PC 문제).
|
||||
* 그래서 로그아웃을 한 번 더 불러 쿠키를 지우고, 결과는 세션이 끝난 것(AUTH_001)으로 돌려준다.
|
||||
* 이 정리용 로그아웃이 새 로그인을 끊지 않도록, 같은 탭의 로그인은 이 갱신이 끝난 뒤에 보낸다 (settleRefresh).
|
||||
*/
|
||||
export function refreshSession(): Promise<RefreshOutcome> {
|
||||
if (!refreshInFlight) {
|
||||
const startedAt = useAuthStore().generation
|
||||
refreshInFlight = api
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다
|
||||
.post<AuthUser, AuthUser>(REFRESH_ENDPOINT, null, { silent: true })
|
||||
.then(async (user): Promise<RefreshOutcome> => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.generation !== startedAt || authStore.signedOut) {
|
||||
await clearCookiesOfLateRefresh()
|
||||
return { ok: false, error: { code: 'AUTH_001', message: getErrorMessage('AUTH_001') } }
|
||||
}
|
||||
authStore.markAuthenticated(user)
|
||||
return { ok: true, user }
|
||||
})
|
||||
.catch((error: unknown): RefreshOutcome => ({ ok: false, error: toApiError(error) }))
|
||||
.finally(() => {
|
||||
refreshInFlight = null
|
||||
})
|
||||
}
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 탭에서 진행 중인 갱신(과 그 뒤의 정리용 로그아웃)이 끝날 때까지 기다린다 — 로그인 직전에 부른다.
|
||||
* 늦게 온 갱신 응답의 Set-Cookie 가 새 로그인 쿠키를 덮거나, 정리용 로그아웃이 새 세션을 끊지 않게 한다.
|
||||
*/
|
||||
export async function settleRefresh(): Promise<void> {
|
||||
if (refreshInFlight) await refreshInFlight
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 이동(첨부 다운로드 <a href>) 직전에 세션을 확인한다 — 계약 2절 "첨부 업로드 한도"의 다운로드 규칙.
|
||||
* 이동은 axios 를 거치지 않아 아래 응답 인터셉터의 "401 → 갱신 → 재시도"를 타지 않는다.
|
||||
* 그래서 액세스 토큰(1시간)이 만료됐으면 이동하기 전에 여기서 갱신해 둔다.
|
||||
*
|
||||
* - /auth/me 성공 → true
|
||||
* - /auth/me 가 AUTH_001 → /auth/refresh 한 번 → 성공하면 true, 401 이면 세션 만료(AUTH_002 대화상자) 후 false
|
||||
* - 그 밖의 장애(5xx·네트워크 등) → 사전 문구로 알리고 false (세션이 끝났다고 단정하지 않는다)
|
||||
*
|
||||
* false 면 이동하지 않는다. 안내(대화상자·토스트)는 이 함수가 이미 끝냈다.
|
||||
*/
|
||||
export async function confirmSession(): Promise<boolean> {
|
||||
try {
|
||||
// 갱신해 보기 전에는 만료로 단정하지 않는다 — "세션 없이 보낸 요청"으로 표시해
|
||||
// 로그인 중에 받은 401 이 곧바로 AUTH_002 로 승격되지 않게 한다 (AUTH_001 은 알림도 끈다)
|
||||
await api.get(ME_ENDPOINT, { silent: ['AUTH_001'], _sentWithSession: false })
|
||||
return true
|
||||
} catch (error) {
|
||||
// 장애는 인터셉터가 이미 알렸다
|
||||
if (!isApiError(error) || error.code !== 'AUTH_001') return false
|
||||
}
|
||||
|
||||
const outcome = await refreshSession()
|
||||
if (outcome.ok) return true
|
||||
if (isSessionEnd(outcome.error.code)) {
|
||||
// 로그인 중이었다면 갱신 요청의 401 을 인터셉터가 이미 AUTH_002 로 승격했다.
|
||||
// 여기서는 만료 처리를 보장만 한다 (이미 정리됐으면 아무것도 하지 않는다)
|
||||
useAuthStore().expireSession()
|
||||
return false
|
||||
}
|
||||
// 갱신 요청 자체가 장애로 실패 — 갱신은 알림을 끈 요청이라 그 오류를 여기서 알린다
|
||||
showErrorUI(outcome.error.code, outcome.error.message, outcome.error.traceId)
|
||||
return false
|
||||
}
|
||||
|
||||
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<StandardResponse>) => {
|
||||
@@ -118,22 +318,53 @@ api.interceptors.response.use(
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 (백엔드는 4xx 로 내리지만 방어적으로 처리)
|
||||
return Promise.reject(
|
||||
handle(payload.error?.code ?? 'BIZ_001', payload.error?.traceId, response.status),
|
||||
handle(
|
||||
payload.error?.code ?? 'BIZ_001',
|
||||
payload.error?.traceId,
|
||||
response.status,
|
||||
response.config,
|
||||
),
|
||||
)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
if (!error?.response) {
|
||||
async (error: unknown) => {
|
||||
if (axios.isCancel(error)) {
|
||||
// 호출부가 취소한 요청 (예: 첨부 업로드 중 파일 삭제) — 실패가 아니므로 알리지 않는다
|
||||
const canceled: ApiError = {
|
||||
code: FALLBACK_ERROR_CODE,
|
||||
message: getErrorMessage(FALLBACK_ERROR_CODE),
|
||||
canceled: true,
|
||||
}
|
||||
return Promise.reject(canceled)
|
||||
}
|
||||
|
||||
if (!axios.isAxiosError(error) || !error.response) {
|
||||
// 네트워크 단절·타임아웃 — 서버 응답 자체가 없는 경우
|
||||
return Promise.reject(handle(FALLBACK_ERROR_CODE))
|
||||
const config = axios.isAxiosError(error) ? error.config : undefined
|
||||
return Promise.reject(handle(FALLBACK_ERROR_CODE, undefined, undefined, config))
|
||||
}
|
||||
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
const code = payload?.error?.code ?? resolveCodeByStatus(status)
|
||||
const config = error.config
|
||||
|
||||
return Promise.reject(handle(code, payload?.error?.traceId, status))
|
||||
if (config && shouldRefresh(code, config)) {
|
||||
const outcome = await refreshSession()
|
||||
if (outcome.ok) {
|
||||
// 새 쿠키로 원래 요청을 한 번만 다시 보낸다 (또 401 이면 아래 handle 이 세션 만료로 처리)
|
||||
return api.request({ ...config, _authRetried: true })
|
||||
}
|
||||
if (!isSessionEnd(outcome.error.code)) {
|
||||
// 갱신 요청 자체가 장애로 실패 — 세션이 끝났다고 단정하지 않고 그 오류를 그대로 알린다
|
||||
return Promise.reject(
|
||||
handle(outcome.error.code, outcome.error.traceId, outcome.error.status, config),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(handle(code, payload?.error?.traceId, status, config))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
BYTES_PER_MB,
|
||||
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
|
||||
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
|
||||
} from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
emitUploadProgress,
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { useAttachmentUploads, type AttachmentUploadOptions } from './useAttachmentUploads'
|
||||
|
||||
let clock = Date.UTC(2026, 9, 26, 4, 0, 0)
|
||||
|
||||
function file(name: string, size = 10): File {
|
||||
const instance = new File(['x'], name)
|
||||
// jsdom File 은 size 를 내용 길이로 정하므로 큰 파일 검사용으로 덮어쓴다
|
||||
Object.defineProperty(instance, 'size', { value: size })
|
||||
return instance
|
||||
}
|
||||
|
||||
function withUploads(options?: AttachmentUploadOptions) {
|
||||
const scope = effectScope()
|
||||
const uploads = scope.run(() => useAttachmentUploads(options))
|
||||
if (!uploads) throw new Error('scope 실행 실패')
|
||||
return { uploads, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useAttachmentUploads', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('사전 검사에 걸린 파일은 올리지 않고 실패로 표시한다', () => {
|
||||
const requests = installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('설치.exe')])
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(uploads.items.value[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
errorMessage: ERROR_CODE_MESSAGES.FILE_001,
|
||||
})
|
||||
stop()
|
||||
})
|
||||
|
||||
it('파일당 한도는 서버가 알려 준 값으로 검사한다 (대체값 20MB 가 아니라)', () => {
|
||||
installMockApi(() => new Promise<MockResult>(() => undefined))
|
||||
const serverLimit = 5 * BYTES_PER_MB
|
||||
const { uploads, stop } = withUploads({ maxBytes: () => serverLimit })
|
||||
|
||||
uploads.addFiles([file('작은.pdf', serverLimit), file('큰.pdf', serverLimit + 1)])
|
||||
|
||||
expect(uploads.items.value.map((item) => item.status)).toEqual(['uploading', 'failed'])
|
||||
expect(uploads.items.value[1]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('한도를 받지 못했으면 대체값(20MB)으로 검사한다', () => {
|
||||
installMockApi(() => new Promise<MockResult>(() => undefined))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('큰.zip', MAIL_ATTACHMENT_FALLBACK_MAX_BYTES + 1)])
|
||||
|
||||
expect(uploads.items.value[0]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('파일마다 진행률을 보이고, 끝나면 발송에 쓸 첨부로 모은다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const requests = installMockApi(
|
||||
(request) =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
emitUploadProgress(request, 62, 100)
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('회의록_1027.docx')])
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.url).toBe('/mail-attachments')
|
||||
expect(requests[0]?.data).toBeInstanceOf(FormData)
|
||||
expect(uploads.items.value[0]).toMatchObject({ status: 'uploading', progress: 62 })
|
||||
expect(uploads.isUploading.value).toBe(true)
|
||||
|
||||
const attachment = { id: 'att-1', name: '회의록_1027.docx', type: 'application/msword', size: 10 }
|
||||
release(ok(attachment, 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploads.items.value[0]).toMatchObject({ status: 'done', progress: 100 })
|
||||
expect(uploads.uploaded.value).toEqual([attachment])
|
||||
expect(uploads.isUploading.value).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('서버가 크기 초과(FILE_002)로 거절하면 그 파일만 실패로 표시하고 전역 알림은 띄우지 않는다', async () => {
|
||||
installMockApi(() => fail(413, 'FILE_002'))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('현장사진.zip')])
|
||||
await flushPromises()
|
||||
|
||||
expect(uploads.items.value[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
errorMessage: ERROR_CODE_MESSAGES.FILE_002,
|
||||
})
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('올리는 중에 지우면 요청을 취소하고 알림 없이 목록에서 뺀다', async () => {
|
||||
const requests = installMockApi(
|
||||
(request) =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
request.config.signal?.addEventListener?.('abort', () => resolve('cancel'))
|
||||
}),
|
||||
)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('견적.pdf')])
|
||||
await flushPromises()
|
||||
const key = uploads.items.value[0]?.key ?? -1
|
||||
uploads.remove(key)
|
||||
await flushPromises()
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(uploads.items.value).toEqual([])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
stop()
|
||||
})
|
||||
|
||||
describe('전달 — 원본 첨부 미리 올리기 (addExisting)', () => {
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
|
||||
it('다시 올리지 않고 원본 id 그대로 발송 첨부에 넣는다', () => {
|
||||
const requests = installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addExisting([CHECKLIST])
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(uploads.items.value[0]).toMatchObject({ status: 'done', name: '체크리스트.xlsx', size: 49152 })
|
||||
expect(uploads.uploaded.value).toEqual([CHECKLIST])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('허용 목록 밖의 원본 첨부는 실패(FILE_001)로 보이고 발송에서 빠진다', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addExisting([CHECKLIST, { id: 'att2', name: '설치.exe', type: 'application/octet-stream', size: 10 }])
|
||||
|
||||
expect(uploads.items.value[1]).toMatchObject({
|
||||
status: 'failed',
|
||||
errorMessage: ERROR_CODE_MESSAGES.FILE_001,
|
||||
})
|
||||
expect(uploads.uploaded.value).toEqual([CHECKLIST])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('발송이 MAIL_004 면 보낼 예정이던 첨부 전부를 "찾지 못함"으로 바꾸고, 빼기 전까지 보내기를 막는다 (어느 것인지 서버가 알려 주지 않는다)', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
|
||||
|
||||
const marked = uploads.markMissing()
|
||||
|
||||
expect(marked.map((item) => item.name)).toEqual(['체크리스트.xlsx', '견적.pdf'])
|
||||
expect(uploads.uploaded.value).toEqual([])
|
||||
expect(uploads.hasMissing.value).toBe(true)
|
||||
expect(uploads.items.value.map((item) => item.errorMessage)).toEqual([
|
||||
ERROR_CODE_MESSAGES.MAIL_004,
|
||||
ERROR_CODE_MESSAGES.MAIL_004,
|
||||
])
|
||||
|
||||
uploads.items.value.map((item) => item.key).forEach((key) => uploads.remove(key))
|
||||
expect(uploads.hasMissing.value).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('첨부마다 출처를 기억한다 — 찾지 못한 전달 원본은 다시 올릴 수 없어 화면이 "빼기"로 안내한다', async () => {
|
||||
installMockApi(() => ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 10 }, 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addExisting([CHECKLIST])
|
||||
uploads.addFiles([file('회의록.pdf')])
|
||||
await flushPromises()
|
||||
|
||||
const marked = uploads.markMissing()
|
||||
|
||||
expect(marked.map((item) => [item.name, item.origin])).toEqual([
|
||||
['체크리스트.xlsx', 'forward'],
|
||||
['회의록.pdf', 'upload'],
|
||||
])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('하나씩 뺄 수 있다', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
|
||||
|
||||
uploads.remove(uploads.items.value[0]?.key ?? -1)
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['att3'])
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('메일 한 통의 첨부 합계 (attachmentTotalMaxBytes, 계약 2절)', () => {
|
||||
const MB = BYTES_PER_MB
|
||||
const ORIGINAL = { id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 12 * MB }
|
||||
/** 올리기 요청은 끝나지 않게 둔다 — 합계는 올리는 중인 파일도 센다 */
|
||||
const pending = () => new Promise<MockResult>(() => undefined)
|
||||
|
||||
function statuses(uploads: ReturnType<typeof withUploads>['uploads']) {
|
||||
return uploads.items.value.map((item) => item.status)
|
||||
}
|
||||
|
||||
it('붙이는 순간 합계가 서버 한도를 넘는 파일은 올리지 않고 FILE_002 로 안내한다 — 여러 개는 앞에서부터 센다', async () => {
|
||||
const requests = installMockApi(pending)
|
||||
const { uploads, stop } = withUploads({ maxBytes: () => 20 * MB, totalMaxBytes: () => 25 * MB })
|
||||
|
||||
uploads.addFiles([
|
||||
file('1.pdf', 10 * MB),
|
||||
file('2.pdf', 10 * MB),
|
||||
file('3.pdf', 10 * MB),
|
||||
file('4.pdf', 5 * MB),
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(statuses(uploads)).toEqual(['uploading', 'uploading', 'failed', 'uploading'])
|
||||
expect(uploads.items.value[2]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(uploads.attachedBytes.value).toBe(25 * MB)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('전달하는 원본 첨부도 합계에 들어가고, 같은 파일을 두 번 붙이면 두 번 센다', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads({ totalMaxBytes: () => 25 * MB })
|
||||
|
||||
uploads.addExisting([ORIGINAL])
|
||||
uploads.addFiles([file('현장사진.zip', 12 * MB)])
|
||||
uploads.addFiles([file('현장사진.zip', 12 * MB)])
|
||||
|
||||
expect(statuses(uploads)).toEqual(['done', 'uploading', 'failed'])
|
||||
expect(uploads.items.value[2]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('원본 첨부끼리 합계를 넘어도 넘는 것부터 실패로 보이고 발송에서 빠진다 (원본에는 파일당 한도가 없다)', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads({ maxBytes: () => 10 * MB, totalMaxBytes: () => 25 * MB })
|
||||
|
||||
uploads.addExisting([ORIGINAL, { ...ORIGINAL, id: 'att2' }, { ...ORIGINAL, id: 'att3' }])
|
||||
|
||||
expect(statuses(uploads)).toEqual(['done', 'done', 'failed'])
|
||||
expect(uploads.items.value[2]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['att1', 'att2'])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('실패하거나 지운 첨부는 합계에서 빠져 그만큼 다시 붙일 수 있다', async () => {
|
||||
let uploadFails = true
|
||||
installMockApi(() => (uploadFails ? fail(500, 'SYS_001') : pending()))
|
||||
const { uploads, stop } = withUploads({ totalMaxBytes: () => 25 * MB })
|
||||
|
||||
uploads.addFiles([file('실패.pdf', 20 * MB)])
|
||||
await flushPromises()
|
||||
expect(statuses(uploads)).toEqual(['failed'])
|
||||
expect(uploads.attachedBytes.value).toBe(0)
|
||||
|
||||
uploadFails = false
|
||||
uploads.addFiles([file('큰.pdf', 20 * MB), file('작은.pdf', 10 * MB)])
|
||||
expect(statuses(uploads)).toEqual(['failed', 'uploading', 'failed'])
|
||||
|
||||
uploads.remove(uploads.items.value[1]?.key ?? -1)
|
||||
uploads.addFiles([file('작은.pdf', 10 * MB)])
|
||||
expect(statuses(uploads)).toEqual(['failed', 'failed', 'uploading'])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('합계 한도를 받지 못했으면 대체값(25MB)으로 검사한다', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('1.pdf', 20 * MB), file('2.pdf', MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES - 20 * MB + 1)])
|
||||
|
||||
expect(statuses(uploads)).toEqual(['uploading', 'failed'])
|
||||
stop()
|
||||
})
|
||||
})
|
||||
describe('메일 한 통의 첨부 개수 (최대 20개, 계약 2절 MAIL_006)', () => {
|
||||
const ORIGINAL = { id: 'att', name: '원본.pdf', type: 'application/pdf', size: 10 }
|
||||
const pending = () => new Promise<MockResult>(() => undefined)
|
||||
const files = (count: number) => Array.from({ length: count }, (_, index) => file(`파일${index + 1}.pdf`))
|
||||
const originals = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({ ...ORIGINAL, id: `att${index + 1}` }))
|
||||
|
||||
function statuses(uploads: ReturnType<typeof withUploads>['uploads']) {
|
||||
return uploads.items.value.map((item) => item.status)
|
||||
}
|
||||
|
||||
it('한꺼번에 22개를 고르면 앞에서부터 20개만 올리고, 넘는 2개는 올리지 않고 MAIL_006 으로 표시한다', async () => {
|
||||
const requests = installMockApi(pending)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles(files(22))
|
||||
await flushPromises()
|
||||
|
||||
expect(statuses(uploads)).toEqual([...Array<string>(20).fill('uploading'), 'failed', 'failed'])
|
||||
expect(uploads.items.value.slice(20).map((item) => item.errorMessage)).toEqual([
|
||||
ERROR_CODE_MESSAGES.MAIL_006,
|
||||
ERROR_CODE_MESSAGES.MAIL_006,
|
||||
])
|
||||
expect(requests).toHaveLength(20)
|
||||
expect(uploads.attachedCount.value).toBe(20)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('전달 원본과 올린 파일을 합쳐 센다 — 원본 18개 뒤에 파일 3개를 고르면 마지막 파일만 빠진다', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addExisting(originals(18))
|
||||
uploads.addFiles(files(3))
|
||||
|
||||
expect(statuses(uploads).slice(18)).toEqual(['uploading', 'uploading', 'failed'])
|
||||
expect(uploads.items.value[20]?.errorMessage).toBe(ERROR_CODE_MESSAGES.MAIL_006)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('전달 원본이 20개를 넘으면 넘는 원본은 실패로 보이고 발송에서 빠진다', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addExisting(originals(21))
|
||||
|
||||
expect(uploads.uploaded.value).toHaveLength(20)
|
||||
expect(uploads.items.value[20]).toMatchObject({
|
||||
status: 'failed',
|
||||
errorMessage: ERROR_CODE_MESSAGES.MAIL_006,
|
||||
})
|
||||
stop()
|
||||
})
|
||||
|
||||
it('개수를 형식보다 먼저 본다 — 이미 20개면 허용 밖 형식도 FILE_001 이 아니라 MAIL_006 (서버와 같은 순서)', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addFiles(files(20))
|
||||
|
||||
uploads.addFiles([file('설치.exe')])
|
||||
|
||||
expect(uploads.items.value[20]?.errorMessage).toBe(ERROR_CODE_MESSAGES.MAIL_006)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('실패한 첨부는 세지 않는다 — 형식이 틀린 파일은 자리를 차지하지 않고, 빼면 그만큼 다시 붙일 수 있다', () => {
|
||||
installMockApi(pending)
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
uploads.addFiles([file('설치.exe'), ...files(20)])
|
||||
expect(uploads.attachedCount.value).toBe(20)
|
||||
expect(uploads.items.value[0]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_001)
|
||||
|
||||
uploads.remove(uploads.items.value[1]?.key ?? -1)
|
||||
uploads.addFiles([file('다시.pdf')])
|
||||
expect(uploads.items.value.at(-1)?.status).toBe('uploading')
|
||||
expect(uploads.attachedCount.value).toBe(20)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { getErrorMessage, isApiError, toApiError } from './useApi'
|
||||
import { useMail } from './useMail'
|
||||
import {
|
||||
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
|
||||
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
|
||||
MAIL_MAX_ATTACHMENTS,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import { precheckAttachment, type AttachedSummary } from '@/shared/utils/mail-attachment'
|
||||
|
||||
export type UploadStatus = 'uploading' | 'done' | 'failed'
|
||||
|
||||
/** 첨부가 어디서 왔는지 — 새로 올린 파일(upload)인지, 전달하는 원본 메일의 첨부(forward)인지 */
|
||||
export type UploadOrigin = 'upload' | 'forward'
|
||||
|
||||
/** 쓰기 화면의 첨부 한 개 (디자인 원본 States "첨부 업로드") */
|
||||
export interface UploadItem {
|
||||
key: number
|
||||
origin: UploadOrigin
|
||||
name: string
|
||||
size: number
|
||||
status: UploadStatus
|
||||
/** 0~100 */
|
||||
progress: number
|
||||
/** 발송 본문에 싣는 첨부 — 새로 올린 파일은 업로드 응답, 전달은 원본 메일의 첨부 */
|
||||
attachment: Attachment | null
|
||||
/** 실패 사유 — 에러 코드 사전 문구 */
|
||||
errorMessage: string | null
|
||||
/**
|
||||
* 발송이 MAIL_004(서버가 첨부를 찾지 못함 — 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등)로 실패한 첨부.
|
||||
* 빼기 전까지 보내기를 막는다 — 모르는 사이 첨부 없이 나가지 않게.
|
||||
* 올린 파일은 빼고 다시 고르면 되지만, 전달 원본은 다시 올릴 수 없어 빼는 것만 할 수 있다 (계약 2절).
|
||||
*/
|
||||
missing: boolean
|
||||
}
|
||||
|
||||
export interface AttachmentUploadOptions {
|
||||
/**
|
||||
* 파일당 최대 크기(바이트) — 서버 값(GET /mail-quota attachmentMaxBytes)을 돌려주는 함수.
|
||||
* 아직 못 받았으면 대체값(20MB)을 쓴다.
|
||||
*/
|
||||
maxBytes?: () => number
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — 서버 값(GET /mail-quota attachmentTotalMaxBytes)을 돌려주는 함수.
|
||||
* 아직 못 받았으면 대체값(25MB)을 쓴다.
|
||||
*/
|
||||
totalMaxBytes?: () => number
|
||||
}
|
||||
|
||||
const COMPLETE = 100
|
||||
|
||||
/**
|
||||
* 첨부 상태 — 파일마다 진행률을 보이고 실패한 파일만 표시한다.
|
||||
* 화면(스코프)이 사라지면 올리던 요청을 모두 취소한다.
|
||||
*/
|
||||
export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
const { uploadAttachment } = useMail()
|
||||
const items = ref<UploadItem[]>([])
|
||||
const controllers = new Map<number, AbortController>()
|
||||
let nextKey = 1
|
||||
|
||||
const maxBytes = () => options.maxBytes?.() ?? MAIL_ATTACHMENT_FALLBACK_MAX_BYTES
|
||||
const totalMaxBytes = () => options.totalMaxBytes?.() ?? MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES
|
||||
|
||||
/**
|
||||
* 이 메일에 붙을 첨부 — 올리는 중·올린 것·전달하는 원본 (계약 2절 "발송 본문"·"첨부 업로드 한도").
|
||||
* 실패한 첨부는 보내지 않으므로 세지 않고, 같은 파일을 두 번 붙였으면 두 번 센다 (서버도 그렇게 센다).
|
||||
*/
|
||||
const attached = computed<AttachedSummary>(() =>
|
||||
items.value.reduce(
|
||||
(sum, item) =>
|
||||
item.status === 'failed' ? sum : { count: sum.count + 1, bytes: sum.bytes + item.size },
|
||||
{ count: 0, bytes: 0 },
|
||||
),
|
||||
)
|
||||
const attachedBytes = computed(() => attached.value.bytes)
|
||||
const attachedCount = computed(() => attached.value.count)
|
||||
|
||||
/** 새로 붙일 첨부의 한도 — 크기는 서버 값, 개수는 계약 상수 */
|
||||
function limitsFor(origin: UploadOrigin) {
|
||||
return {
|
||||
// 파일당 한도는 업로드에만 있다 — 전달 원본은 서버가 확장자·합계·개수만 본다
|
||||
maxBytes: origin === 'upload' ? maxBytes() : null,
|
||||
totalMaxBytes: totalMaxBytes(),
|
||||
maxCount: MAIL_MAX_ATTACHMENTS,
|
||||
}
|
||||
}
|
||||
|
||||
const isUploading = computed(() => items.value.some((item) => item.status === 'uploading'))
|
||||
/** 서버가 찾지 못한 첨부가 남아 있다 — 보내기를 막는다 */
|
||||
const hasMissing = computed(() => items.value.some((item) => item.missing))
|
||||
const uploaded = computed<Attachment[]>(() =>
|
||||
items.value.flatMap((item) =>
|
||||
item.status === 'done' && item.attachment ? [item.attachment] : [],
|
||||
),
|
||||
)
|
||||
|
||||
function takeKey(): number {
|
||||
const key = nextKey
|
||||
nextKey += 1
|
||||
return key
|
||||
}
|
||||
|
||||
function patch(key: number, changes: Partial<UploadItem>) {
|
||||
items.value = items.value.map((item) => (item.key === key ? { ...item, ...changes } : item))
|
||||
}
|
||||
|
||||
async function upload(file: File, key: number) {
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
try {
|
||||
const attachment = await uploadAttachment(file, {
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => patch(key, { progress }),
|
||||
})
|
||||
patch(key, { status: 'done', progress: COMPLETE, attachment })
|
||||
} catch (error) {
|
||||
// 사용자가 지운 파일 — 목록에서 이미 빠졌다
|
||||
if (isApiError(error) && error.canceled) return
|
||||
// 형식·크기 오류는 이 목록에서만 안내한다(전역 알림 꺼 둠). 그 밖의 오류는 인터셉터가 함께 알렸다.
|
||||
patch(key, { status: 'failed', errorMessage: toApiError(error).message })
|
||||
} finally {
|
||||
controllers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 파일을 올린다 — 사전 검사(개수·형식·파일당 한도·합계 한도)에 걸리면 올리지 않고 실패로 표시한다.
|
||||
* 여러 개를 한 번에 고르면 앞에서부터 붙이며 개수·합계를 센다 (넘는 파일만 빠진다).
|
||||
*/
|
||||
function addFiles(files: Iterable<File>) {
|
||||
for (const file of files) {
|
||||
const key = takeKey()
|
||||
const rejected = precheckAttachment(file, limitsFor('upload'), attached.value)
|
||||
items.value = [
|
||||
...items.value,
|
||||
{
|
||||
key,
|
||||
origin: 'upload',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: rejected ? 'failed' : 'uploading',
|
||||
progress: 0,
|
||||
attachment: null,
|
||||
errorMessage: rejected ? getErrorMessage(rejected) : null,
|
||||
missing: false,
|
||||
},
|
||||
]
|
||||
if (!rejected) void upload(file, key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전달 — 원본 메일의 첨부를 목록에 미리 올려 둔다 (다시 올리지 않고 id 를 그대로 보낸다, 계약 2절 "전달").
|
||||
* 서버는 업로드와 같은 규칙으로 개수·확장자를, 발송 때 합계를 검사한다 — 걸리면 실패로 표시하고 발송에서 뺀다.
|
||||
* (파일당 한도는 업로드에만 있어 보지 않는다)
|
||||
*/
|
||||
function addExisting(attachments: readonly Attachment[]) {
|
||||
const running: AttachedSummary = { ...attached.value }
|
||||
const added = attachments.map((attachment): UploadItem => {
|
||||
const rejected = precheckAttachment(attachment, limitsFor('forward'), running)
|
||||
if (!rejected) {
|
||||
running.count += 1
|
||||
running.bytes += attachment.size
|
||||
}
|
||||
return {
|
||||
key: takeKey(),
|
||||
origin: 'forward',
|
||||
name: attachment.name,
|
||||
size: attachment.size,
|
||||
status: rejected ? 'failed' : 'done',
|
||||
progress: COMPLETE,
|
||||
attachment: rejected ? null : attachment,
|
||||
errorMessage: rejected ? getErrorMessage(rejected) : null,
|
||||
missing: false,
|
||||
}
|
||||
})
|
||||
items.value = [...items.value, ...added]
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송이 MAIL_004 로 실패했을 때 — 서버는 어느 첨부인지 알려 주지 않으므로
|
||||
* 보낼 예정이던 첨부(올리기를 마친 것·전달 원본) 전부를 "찾지 못함"으로 바꾼다. 문구는 에러 코드 사전에서만 온다.
|
||||
* @returns 바꾼 첨부 — 화면이 다음 할 일(다시 고르기·빼기)로 초점을 옮기는 데 쓴다
|
||||
*/
|
||||
function markMissing(): UploadItem[] {
|
||||
const message = getErrorMessage('MAIL_004')
|
||||
const marked: UploadItem[] = []
|
||||
items.value = items.value.map((item) => {
|
||||
if (item.status !== 'done') return item
|
||||
const next: UploadItem = { ...item, status: 'failed', attachment: null, errorMessage: message, missing: true }
|
||||
marked.push(next)
|
||||
return next
|
||||
})
|
||||
return marked
|
||||
}
|
||||
|
||||
/** 첨부 취소 — 올리는 중이면 요청도 끊는다 */
|
||||
function remove(key: number) {
|
||||
controllers.get(key)?.abort()
|
||||
controllers.delete(key)
|
||||
items.value = items.value.filter((item) => item.key !== key)
|
||||
}
|
||||
|
||||
function abortAll() {
|
||||
controllers.forEach((controller) => controller.abort())
|
||||
controllers.clear()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
abortAll()
|
||||
items.value = []
|
||||
}
|
||||
|
||||
onScopeDispose(abortAll)
|
||||
|
||||
return {
|
||||
items,
|
||||
isUploading,
|
||||
hasMissing,
|
||||
uploaded,
|
||||
attachedBytes,
|
||||
attachedCount,
|
||||
addFiles,
|
||||
addExisting,
|
||||
markMissing,
|
||||
remove,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import api, { isApiError } from './useApi'
|
||||
import { useAuth } from './useAuth'
|
||||
import { AUTH_CHANNEL_NAME, closeLogoutChannel } from './useLogoutBroadcast'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
let clock = Date.UTC(2026, 9, 26, 2, 0, 0)
|
||||
|
||||
function requestLine(request: { method: string; url: string }) {
|
||||
return `${request.method} ${request.url}`
|
||||
}
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
// 로그아웃은 다른 탭에 알린다 — 실제 채널 대신 대역을 쓴다
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeLogoutChannel()
|
||||
FakeBroadcastChannel.closeAll()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('세션 복원 (앱 시작)', () => {
|
||||
it('/auth/me 가 성공하면 그 사용자로 시작한다', async () => {
|
||||
installMockApi(() => ok(USER))
|
||||
|
||||
await useAuth().restoreSession()
|
||||
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('/auth/me 가 AUTH_001 이면 알림 없이 리프레시로 한 번 더 시도한다 (액세스 토큰만 만료된 경우)', async () => {
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? fail(401, 'AUTH_001') : ok(USER),
|
||||
)
|
||||
|
||||
await useAuth().restoreSession()
|
||||
|
||||
expect(requests.map(requestLine)).toEqual(['GET /auth/me', 'POST /auth/refresh'])
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('리프레시도 실패하면 알림 없이 로그인 전 상태로 시작한다', async () => {
|
||||
installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
await useAuth().restoreSession()
|
||||
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
expect(useAuthStore().sessionExpired).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('서버 장애면 리프레시를 시도하지 않고 장애를 알린다', async () => {
|
||||
const requests = installMockApi(() => fail(500, 'SYS_001'))
|
||||
|
||||
await useAuth().restoreSession()
|
||||
|
||||
expect(requests.map(requestLine)).toEqual(['GET /auth/me'])
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
ERROR_CODE_MESSAGES.SYS_001,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('성공하면 응답의 사용자(email·name)를 저장한다', async () => {
|
||||
const requests = installMockApi(() => ok(USER))
|
||||
|
||||
await expect(
|
||||
useAuth().login({ email: USER.email, password: 'secret' }),
|
||||
).resolves.toEqual(USER)
|
||||
|
||||
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret' })
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('AUTH_004 는 전역 알림 없이 던진다 — 로그인 폼이 안내한다', async () => {
|
||||
installMockApi(() => fail(401, 'AUTH_004'))
|
||||
|
||||
const error: unknown = await useAuth()
|
||||
.login({ email: USER.email, password: 'wrong' })
|
||||
.catch((reason: unknown) => reason)
|
||||
|
||||
expect(isApiError(error) && error.code).toBe('AUTH_004')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('시도 과다(REQ_001)는 전역 알림으로 안내한다', async () => {
|
||||
installMockApi(() => fail(429, 'REQ_001'))
|
||||
|
||||
await useAuth()
|
||||
.login({ email: USER.email, password: 'x' })
|
||||
.catch(() => undefined)
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
ERROR_CODE_MESSAGES.REQ_001,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('서버 응답과 상관없이 화면 세션을 정리하고, 세션 만료 대화상자는 띄우지 않는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const requests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
|
||||
await useAuth().logout()
|
||||
|
||||
expect(requests.map(requestLine)).toEqual(['POST /auth/logout'])
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('로그아웃 요청을 보내기 전에 다른 탭에 알린다 (로그아웃 응답보다 먼저 닿게)', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
const other = new FakeBroadcastChannel(AUTH_CHANNEL_NAME)
|
||||
const otherHeard = vi.fn()
|
||||
other.addEventListener('message', otherHeard)
|
||||
const announce = vi.spyOn(FakeBroadcastChannel.prototype, 'postMessage')
|
||||
const server = vi.fn<MockHandler>(() => ok(null))
|
||||
installMockApi(server)
|
||||
|
||||
await useAuth().logout()
|
||||
await flushPromises()
|
||||
|
||||
expect(announce).toHaveBeenCalledTimes(1)
|
||||
expect(announce.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
server.mock.invocationCallOrder[0] ?? 0,
|
||||
)
|
||||
expect(otherHeard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('경합 — 이 탭의 갱신(/auth/refresh)이 진행 중일 때 로그아웃을 눌렀다', () => {
|
||||
async function race(order: 'logout-first' | 'refresh-first') {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
let releaseLogout: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const logoutGate = new Promise<MockResult>((resolve) => {
|
||||
releaseLogout = resolve
|
||||
})
|
||||
let logoutCalls = 0
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') return refreshGate
|
||||
if (url === '/auth/logout') {
|
||||
logoutCalls += 1
|
||||
// 사용자가 누른 로그아웃은 응답을 붙잡아 두고, 늦은 갱신 뒤에 다시 부르는 로그아웃은 바로 끝낸다
|
||||
return logoutCalls === 1 ? logoutGate : ok(null)
|
||||
}
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
const pending = api.get('/messages').catch((error: unknown) => error)
|
||||
await flushPromises()
|
||||
const loggingOut = useAuth().logout()
|
||||
await flushPromises()
|
||||
if (order === 'logout-first') {
|
||||
releaseLogout(ok(null))
|
||||
await flushPromises()
|
||||
releaseRefresh(ok(USER))
|
||||
} else {
|
||||
releaseRefresh(ok(USER))
|
||||
await flushPromises()
|
||||
releaseLogout(ok(null))
|
||||
}
|
||||
await loggingOut
|
||||
const error = await pending
|
||||
await flushPromises()
|
||||
return { authStore, requests, error }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['로그아웃 응답이 먼저 오고 갱신 성공이 뒤에 온다', 'logout-first'],
|
||||
['갱신 성공이 먼저 오고 로그아웃 응답이 뒤에 온다', 'refresh-first'],
|
||||
] as const)(
|
||||
'%s — 갱신 성공은 쓰지 않고 /auth/logout 을 한 번 더 부른다. 원래 요청은 다시 보내지 않고 알리지도 않는다',
|
||||
async (_label, order) => {
|
||||
const { authStore, requests, error } = await race(order)
|
||||
|
||||
expect(requests.map(requestLine)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
'POST /auth/logout',
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'AUTH_001' })
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.signedOut).toBe(true)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('로그아웃 뒤 갱신이 실패(401)로 끝나면 구운 쿠키가 없으므로 로그아웃을 더 부르지 않는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') return refreshGate
|
||||
if (url === '/auth/logout') return ok(null)
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
const pending = api.get('/messages').catch((error: unknown) => error)
|
||||
await flushPromises()
|
||||
await useAuth().logout()
|
||||
releaseRefresh(fail(401, 'AUTH_001'))
|
||||
await pending
|
||||
|
||||
expect(requests.map(requestLine)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
])
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('다시 로그인하면 갱신 성공을 다시 쓴다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
installMockApi(() => ok(USER))
|
||||
await useAuth().logout()
|
||||
await useAuth().login({ email: USER.email, password: 'secret' })
|
||||
|
||||
installMockApi(({ url }) => (url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001')))
|
||||
const outcome = await useAuth().refresh()
|
||||
|
||||
expect(outcome).toEqual({ ok: true, user: USER })
|
||||
expect(authStore.signedOut).toBe(false)
|
||||
})
|
||||
|
||||
it('로그아웃과 다시 로그인 사이에 늦게 온 갱신 — 결과를 버리고, 로그인은 그 갱신과 정리용 로그아웃이 끝난 뒤에 보낸다 (공용 PC)', async () => {
|
||||
const OTHER = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const shownUsers: (string | null)[] = []
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
(email) => shownUsers.push(email),
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') return refreshGate
|
||||
if (url === '/auth/logout') return ok(null)
|
||||
if (url === '/auth/login') return ok(OTHER)
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
// 1) A 의 갱신이 늦어지는 중에 2) A 가 로그아웃하고 3) B 가 같은 탭에서 로그인한다
|
||||
const pending = api.get('/messages').catch((error: unknown) => error)
|
||||
await flushPromises()
|
||||
await useAuth().logout()
|
||||
const loggingIn = useAuth().login({ email: OTHER.email, password: 'secret' })
|
||||
await flushPromises()
|
||||
expect(requests.some((request) => request.url === '/auth/login')).toBe(false)
|
||||
|
||||
// 4) A 의 갱신 성공이 도착한다
|
||||
releaseRefresh(ok(USER))
|
||||
await loggingIn
|
||||
await pending
|
||||
|
||||
expect(requests.map(requestLine)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
'POST /auth/logout', // 늦은 갱신이 구운 쿠키를 지운다 — 로그인보다 먼저
|
||||
'POST /auth/login',
|
||||
])
|
||||
expect(authStore.user).toEqual(OTHER)
|
||||
// 로그아웃 뒤에 화면이 A 로 돌아간 적이 없다
|
||||
expect(shownUsers).toEqual([null, OTHER.email])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('진행 중인 갱신이 없으면 로그인은 기다리지 않고 바로 보낸다', async () => {
|
||||
const requests = installMockApi(() => ok(USER))
|
||||
|
||||
await useAuth().login({ email: USER.email, password: 'secret' })
|
||||
|
||||
expect(requests.map(requestLine)).toEqual(['POST /auth/login'])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,43 +1,87 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
|
||||
import { announceLogout } from './useLogoutBroadcast'
|
||||
import type { AuthUser, LoginPayload } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 인증 도메인 API Composable 샘플 — 로그인/로그아웃 흐름의 시작점
|
||||
// 실제 인증 엔드포인트가 추가되면 이 패턴을 그대로 사용/확장한다.
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
export type { LoginPayload } from '@/shared/types/auth'
|
||||
|
||||
// 인증 API Composable — 쿠키 기반 (docs/mail/api-contract.md 1절)
|
||||
// 토큰은 HttpOnly 쿠키로만 오가므로 여기서는 응답의 사용자(email·name)만 다룬다.
|
||||
export function useAuth() {
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
async function login(payload: LoginPayload): Promise<boolean> {
|
||||
/**
|
||||
* 로그인. 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 그 코드만 전역 알림을 끈다.
|
||||
*/
|
||||
async function login(payload: LoginPayload): Promise<AuthUser> {
|
||||
loading.value = true
|
||||
try {
|
||||
// 서버는 인증 성공 시 HttpOnly 쿠키로 토큰을 내려준다 (응답 바디에 토큰 없음)
|
||||
await api.post('/auth/login', payload)
|
||||
authStore.markAuthenticated()
|
||||
return true
|
||||
} catch {
|
||||
// 에러 메시지/알림은 useApi 인터셉터에서 전역 처리됨
|
||||
return false
|
||||
// 이 탭에서 진행 중인 갱신이 있으면 끝난 뒤에 보낸다 (계약 1절) — 로그아웃 전에 시작한 갱신의 늦은 응답이
|
||||
// 새 로그인 쿠키를 덮거나, 그 뒤의 정리용 로그아웃이 새 세션을 끊지 않게 한다
|
||||
await settleRefresh()
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다
|
||||
const user = await api.post<AuthUser, AuthUser>('/auth/login', payload, {
|
||||
silent: ['AUTH_004'],
|
||||
})
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다
|
||||
authStore.startSession(user)
|
||||
return user
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃. 서버 응답과 상관없이 화면의 세션은 정리한다.
|
||||
* 요청보다 먼저 하는 일 두 가지 (계약 1절 "클라이언트 처리 규칙"):
|
||||
* - 정리 — 응답을 기다리는 사이 다른 요청이 401 을 받아도 "세션 만료" 대화상자가 뜨지 않게 하고,
|
||||
* 이 탭에서 진행 중이던 갱신(/auth/refresh)의 성공이 늦게 와도 쓰지 않게 한다 (authStore.signedOut)
|
||||
* - 다른 탭에 알림 — 그 탭도 로그인 화면으로 가고, 그 탭의 늦은 갱신 성공도 버리게 한다.
|
||||
* 로그아웃 응답보다 먼저 닿아야 하므로 요청 전에 보낸다
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
loading.value = true
|
||||
authStore.clearAuth()
|
||||
announceLogout()
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
await api.post('/auth/logout', null, { silent: ['AUTH_001'] })
|
||||
} catch {
|
||||
// 서버가 쿠키를 지우지 못했다 — 알림은 인터셉터가 띄웠다 (SYS_001 등)
|
||||
} finally {
|
||||
authStore.clearAuth()
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, login, logout }
|
||||
/** 현재 세션의 사용자. 로그인 전이면 AUTH_001 — 정상 상태이므로 알림을 띄우지 않는다 (계약 1절) */
|
||||
function me(): Promise<AuthUser> {
|
||||
return api.get<AuthUser, AuthUser>('/auth/me', { silent: ['AUTH_001'] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 복원용 사용자 조회 — 계약 1절 "클라이언트 처리 규칙"의 복원 흐름.
|
||||
* 액세스 토큰(1시간)만 만료되고 리프레시 토큰(14일)이 살아 있으면 /auth/me 가 AUTH_001 을 준다.
|
||||
* 그러면 /auth/refresh 를 알림 없이 한 번 부르고, 성공하면 복원 완료 · 실패하면 로그아웃 상태로 시작한다.
|
||||
* (/auth/me 는 인터셉터의 자동 재시도 대상이 아니라서 여기서 명시적으로 부른다)
|
||||
*/
|
||||
async function fetchSessionUser(): Promise<AuthUser | null> {
|
||||
try {
|
||||
return await me()
|
||||
} catch (error) {
|
||||
if (!isApiError(error) || error.code !== 'AUTH_001') return null
|
||||
}
|
||||
const outcome = await refreshSession()
|
||||
return outcome.ok ? outcome.user : null
|
||||
}
|
||||
|
||||
/** 앱 시작 시 한 번 세션을 복원한다 (여러 번 불러도 조회는 한 번) */
|
||||
function restoreSession(): Promise<void> {
|
||||
return authStore.restore(fetchSessionUser)
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import api from './useApi'
|
||||
import { useAuth } from './useAuth'
|
||||
import {
|
||||
AUTH_CHANNEL_NAME,
|
||||
announceLogout,
|
||||
closeLogoutChannel,
|
||||
onLogoutAnnounced,
|
||||
useLogoutSync,
|
||||
} from './useLogoutBroadcast'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
let clock = Date.UTC(2026, 9, 26, 8, 0, 0)
|
||||
|
||||
/** 이 탭에서 useLogoutSync 를 부르는 최소 화면 (App.vue 대신) */
|
||||
const SyncHost = defineComponent({
|
||||
setup() {
|
||||
useLogoutSync()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
describe('탭 간 로그아웃 알림 (계약 1절 "클라이언트 처리 규칙")', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeLogoutChannel()
|
||||
FakeBroadcastChannel.closeAll()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function mountTab(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(SyncHost, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
/** 같은 브라우저의 다른 탭 */
|
||||
function otherTab() {
|
||||
return new FakeBroadcastChannel(AUTH_CHANNEL_NAME)
|
||||
}
|
||||
|
||||
it('다른 탭이 로그아웃하면 인증을 지우고 로그인 화면으로 바꿔 끼운다 — 세션 만료 대화상자가 아니다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const { router } = await mountTab('/mail/inbox')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
otherTab().postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.signedOut).toBe(true)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('세션 만료 대화상자가 떠 있던 탭도 로그아웃으로 다룬다 (대화상자를 닫고 로그인 화면으로)', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
authStore.expireSession()
|
||||
const { router } = await mountTab('/mail/inbox')
|
||||
|
||||
otherTab().postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('로그인 화면에 있던 탭은 그대로 둔다 · 모르는 메시지는 무시한다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
const { router } = await mountTab('/login')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
otherTab().postMessage({ type: 'something-else' })
|
||||
otherTab().postMessage('logout')
|
||||
await flushPromises()
|
||||
expect(authStore.signedOut).toBe(false)
|
||||
|
||||
otherTab().postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
expect(authStore.signedOut).toBe(true)
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('이 탭의 알림은 다른 탭에만 가고 자기에게는 오지 않는다 · 화면이 사라지면 그만 듣는다', async () => {
|
||||
const heard = vi.fn()
|
||||
const stop = onLogoutAnnounced(heard)
|
||||
const other = otherTab()
|
||||
const otherHeard = vi.fn()
|
||||
other.addEventListener('message', otherHeard)
|
||||
|
||||
announceLogout()
|
||||
await flushPromises()
|
||||
|
||||
expect(heard).not.toHaveBeenCalled()
|
||||
expect(otherHeard).toHaveBeenCalledTimes(1)
|
||||
|
||||
stop()
|
||||
other.postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
expect(heard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('BroadcastChannel 이 없는 브라우저면 조용히 넘어간다', () => {
|
||||
vi.stubGlobal('BroadcastChannel', undefined)
|
||||
|
||||
expect(() => announceLogout()).not.toThrow()
|
||||
expect(() => onLogoutAnnounced(() => undefined)()).not.toThrow()
|
||||
})
|
||||
|
||||
describe('경합 — 이 탭의 갱신(/auth/refresh)이 진행 중일 때 다른 탭이 로그아웃', () => {
|
||||
it('늦게 온 갱신 성공은 쓰지 않고 /auth/logout 을 한 번 더 불러 쿠키를 지운다 — 원래 요청은 다시 보내지 않고 알리지도 않는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const { router } = await mountTab('/mail/inbox')
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') return refreshGate
|
||||
if (url === '/auth/logout') return ok(null)
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
const pending = api.get('/messages').catch((error: unknown) => error)
|
||||
await flushPromises()
|
||||
otherTab().postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
releaseRefresh(ok(USER))
|
||||
const error = await pending
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'AUTH_001' })
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('알림 뒤 이 탭에서 다시 로그인해도 늦게 온 갱신은 버린다 — 로그인은 정리용 로그아웃 뒤에 나간다', async () => {
|
||||
const OTHER = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
await mountTab('/mail/inbox')
|
||||
let releaseRefresh: (result: MockResult) => void = () => undefined
|
||||
const refreshGate = new Promise<MockResult>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/refresh') return refreshGate
|
||||
if (url === '/auth/logout') return ok(null)
|
||||
if (url === '/auth/login') return ok(OTHER)
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
const pending = api.get('/messages').catch((error: unknown) => error)
|
||||
await flushPromises()
|
||||
otherTab().postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
const loggingIn = useAuth().login({ email: OTHER.email, password: 'secret' })
|
||||
await flushPromises()
|
||||
releaseRefresh(ok(USER))
|
||||
await loggingIn
|
||||
await pending
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /messages',
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
'POST /auth/login',
|
||||
])
|
||||
expect(authStore.user).toEqual(OTHER)
|
||||
})
|
||||
|
||||
it('알림을 받은 뒤에 돌아온 401 은 갱신하지 않는다 (끝낸 세션을 되살리지 않는다)', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
await mountTab('/mail/inbox')
|
||||
let releaseList: (result: MockResult) => void = () => undefined
|
||||
const listGate = new Promise<MockResult>((resolve) => {
|
||||
releaseList = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => (url === '/messages' ? listGate : ok(USER)))
|
||||
|
||||
const pending = api.get('/messages').catch((error: unknown) => error)
|
||||
await flushPromises()
|
||||
otherTab().postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
releaseList(fail(401, 'AUTH_001'))
|
||||
await pending
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(['/messages'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 탭 간 로그아웃 알림 — 계약 1절 "클라이언트 처리 규칙"
|
||||
// 같은 브라우저의 다른 탭이 로그아웃하면 이 탭도 로그아웃으로 다루고 로그인 화면으로 간다.
|
||||
// 갱신(/auth/refresh)과 로그아웃이 겹치면 서버가 로그아웃 뒤에 새 쿠키를 구울 수 있다. 늦게 온 갱신 성공을
|
||||
// 버리려면(useApi.refreshSession) 다른 탭의 로그아웃도 이 탭이 알아야 한다.
|
||||
// BroadcastChannel 을 지원하지 않는 브라우저면 조용히 넘어간다.
|
||||
|
||||
/** 같은 출처의 탭끼리만 듣는 채널 이름 */
|
||||
export const AUTH_CHANNEL_NAME = 'arasystem.auth'
|
||||
|
||||
interface LogoutMessage {
|
||||
type: 'logout'
|
||||
}
|
||||
|
||||
const LOGOUT_MESSAGE: LogoutMessage = { type: 'logout' }
|
||||
|
||||
function isLogoutMessage(data: unknown): data is LogoutMessage {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
(data as { type?: unknown }).type === LOGOUT_MESSAGE.type
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 탭의 채널 하나 — 보내기와 듣기가 같은 객체를 써야 자기 알림을 자기가 받지 않는다.
|
||||
* undefined: 아직 만들지 않음 · null: 지원하지 않는 브라우저(또는 만들지 못함)
|
||||
*/
|
||||
let channel: BroadcastChannel | null | undefined
|
||||
|
||||
function getChannel(): BroadcastChannel | null {
|
||||
if (channel !== undefined) return channel
|
||||
try {
|
||||
channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(AUTH_CHANNEL_NAME) : null
|
||||
} catch {
|
||||
channel = null
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
/** 다른 탭에 로그아웃을 알린다 — 로그아웃 요청을 보내기 **전에** 부른다 (늦게 온 갱신 응답보다 먼저 닿게) */
|
||||
export function announceLogout(): void {
|
||||
try {
|
||||
getChannel()?.postMessage(LOGOUT_MESSAGE)
|
||||
} catch {
|
||||
// 알림은 덤이다 — 못 보내도 이 탭의 로그아웃은 그대로 진행한다
|
||||
}
|
||||
}
|
||||
|
||||
/** 다른 탭의 로그아웃 알림을 듣는다. 돌려준 함수로 그만 듣는다 */
|
||||
export function onLogoutAnnounced(handler: () => void): () => void {
|
||||
const target = getChannel()
|
||||
if (!target) return () => undefined
|
||||
|
||||
const listener = (event: MessageEvent<unknown>) => {
|
||||
if (isLogoutMessage(event.data)) handler()
|
||||
}
|
||||
target.addEventListener('message', listener)
|
||||
return () => target.removeEventListener('message', listener)
|
||||
}
|
||||
|
||||
/** 채널을 닫는다 — 테스트가 끝날 때 쓴다 (다음 사용 때 새로 만든다) */
|
||||
export function closeLogoutChannel(): void {
|
||||
channel?.close()
|
||||
channel = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 최상위(App.vue)에서 한 번 부른다 — 다른 탭이 로그아웃하면 이 탭도 로그아웃 상태로 바꾸고 로그인 화면으로 간다.
|
||||
* 세션 만료가 아니라 사용자가 끝낸 세션이므로 만료 대화상자를 띄우지 않는다 (authStore.clearAuth).
|
||||
* 기록을 쌓지 않도록 로그인 화면으로 바꿔 끼운다 — 이 탭에서 사용자가 이동한 것이 아니다.
|
||||
*/
|
||||
export function useLogoutSync(): void {
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const stop = onLogoutAnnounced(() => {
|
||||
authStore.clearAuth()
|
||||
if (router.currentRoute.value.meta.requiresAuth) void router.replace({ name: 'login' })
|
||||
})
|
||||
onScopeDispose(stop)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { buildApiUrl, confirmSession, useApi } from './useApi'
|
||||
import {
|
||||
MAIL_IDEMPOTENCY_HEADER,
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
import type {
|
||||
Attachment,
|
||||
MailQuota,
|
||||
Mailbox,
|
||||
MessageDetail,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
SendMessagePayload,
|
||||
SendMessageResult,
|
||||
UpdateMessagePayload,
|
||||
} from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
export type MailRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
|
||||
export interface SendMessageOptions extends MailRequestOptions {
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
|
||||
* 필수로 둔 이유: 빠뜨려도 서버는 그냥 보내므로(중복 방지 없음) 실수가 드러나지 않는다.
|
||||
*/
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
/** 0~100 */
|
||||
onProgress?: (percent: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const PERCENT = 100
|
||||
|
||||
/** 경로에 넣는 id 는 항상 인코딩한다 */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
/**
|
||||
* 화면을 떠나지 않고 브라우저 다운로드를 시작한다 — 임시 <a download> 를 눌러 원래 링크와 똑같이 받는다.
|
||||
* (location.assign 으로 이동하면 서버가 오류 본문(JSON)을 줄 때 화면이 그 본문으로 바뀐다)
|
||||
*/
|
||||
function startBrowserDownload(url: string): void {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
// 값을 비워 두면 파일 이름은 서버의 Content-Disposition 을 따른다
|
||||
anchor.setAttribute('download', '')
|
||||
anchor.hidden = true
|
||||
document.body.append(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 모든 엔드포인트
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
export function useMail() {
|
||||
const api = useApi()
|
||||
|
||||
function listMailboxes(options?: MailRequestOptions): Promise<PaginatedResult<Mailbox>> {
|
||||
return api.get<PaginatedResult<Mailbox>, PaginatedResult<Mailbox>>('/mailboxes', options)
|
||||
}
|
||||
|
||||
/** 메일함 사용량 · 첨부 파일당 한도 */
|
||||
function getQuota(options?: MailRequestOptions): Promise<MailQuota> {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
/** 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다 */
|
||||
function listMessages(
|
||||
query: MessageListQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
return api.get<PaginatedResult<MessageSummary>, PaginatedResult<MessageSummary>>('/messages', {
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/** 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다 */
|
||||
function getMessage(id: string, options?: MailRequestOptions): Promise<MessageDetail> {
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
function updateMessage(id: string, changes: UpdateMessagePayload): Promise<MessageSummary> {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes)
|
||||
}
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
|
||||
async function deleteMessage(id: string): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송. 같은 idempotencyKey 로 다시 보내면 서버는 두 번 내보내지 않고 처음 결과를 돌려준다.
|
||||
* (401 뒤 갱신 재시도도 같은 헤더로 다시 간다 — 인터셉터가 원래 설정을 그대로 쓴다)
|
||||
*/
|
||||
function sendMessage(
|
||||
payload: SendMessagePayload,
|
||||
options: SendMessageOptions,
|
||||
): Promise<SendMessageResult> {
|
||||
const { idempotencyKey, ...requestOptions } = options
|
||||
return api.post<SendMessageResult, SendMessageResult>('/messages', payload, {
|
||||
timeout: MAIL_SEND_TIMEOUT_MS,
|
||||
...requestOptions,
|
||||
headers: { [MAIL_IDEMPOTENCY_HEADER]: idempotencyKey },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 한 개 업로드 (multipart `file`).
|
||||
* 형식·크기 오류(FILE_001·FILE_002)는 파일 목록에 직접 표시하므로 그 코드만 전역 알림을 끈다.
|
||||
*/
|
||||
function uploadAttachment(file: File, options: UploadOptions = {}): Promise<Attachment> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<Attachment, Attachment>('/mail-attachments', form, {
|
||||
silent: ['FILE_001', 'FILE_002'],
|
||||
timeout: MAIL_UPLOAD_TIMEOUT_MS,
|
||||
signal: options.signal,
|
||||
onUploadProgress: (event) => {
|
||||
const total = event.total ?? file.size
|
||||
if (!total) return
|
||||
options.onProgress?.(Math.min(PERCENT, Math.round((event.loaded / total) * PERCENT)))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 다운로드 주소 — <a href> 이동으로 받는다 (표준 래퍼 없음, Content-Disposition: attachment).
|
||||
* axios baseURL 과 같은 규칙으로 만들어야 쿠키(Path=/api)가 함께 간다.
|
||||
*/
|
||||
function attachmentDownloadUrl(messageId: string, attachmentId: string): string {
|
||||
return buildApiUrl(`/messages/${segment(messageId)}/attachments/${segment(attachmentId)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부를 내려받는다 — 이동하기 전에 세션을 확인한다 (계약 2절 "다운로드").
|
||||
* <a href> 이동은 axios 의 401 갱신 규칙을 타지 않아, 액세스 토큰이 만료된 채 이동하면 실패하기 때문이다.
|
||||
* @returns 다운로드를 시작했으면 true. false 면 안내(세션 만료 대화상자·토스트)는 이미 끝났다.
|
||||
*/
|
||||
async function downloadAttachment(messageId: string, attachmentId: string): Promise<boolean> {
|
||||
if (!(await confirmSession())) return false
|
||||
startBrowserDownload(attachmentDownloadUrl(messageId, attachmentId))
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
listMessages,
|
||||
getMessage,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
sendMessage,
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
downloadAttachment,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 메일 화면 사이 이동 규칙 — 브라우저 기록(history)을 깔끔하게 유지한다.
|
||||
* - 이전·다음 메일은 기록을 바꿔 끼운다: 몇 통을 넘겨 봐도 "뒤로"는 목록으로 간다
|
||||
* - "목록으로"는 바로 앞 기록이 그 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다
|
||||
* - 없는 주소(404 RES_001)에서 다른 메일함으로 빠져나갈 때도 바꿔 끼운다: 뒤로 가기로 그 주소에 다시 오지 않게
|
||||
*/
|
||||
export function useMailNavigation() {
|
||||
const router = useRouter()
|
||||
|
||||
function mailboxLocation(mailboxId: string): RouteLocationRaw {
|
||||
return { name: 'mail-mailbox', params: { mailboxId } }
|
||||
}
|
||||
|
||||
function messageLocation(mailboxId: string, messageId: string): RouteLocationRaw {
|
||||
return { name: 'mail-message', params: { mailboxId, messageId } }
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
const listPath = router.resolve(mailboxLocation(mailboxId)).fullPath
|
||||
if (router.options.history.state.back === listPath) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
await router.replace(mailboxLocation(mailboxId))
|
||||
}
|
||||
|
||||
async function showSibling(mailboxId: string, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocation(mailboxId, messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 메일함으로 바꿔 끼운다 — 주소창에 남은 옛 메일함 id 처럼 없는 주소에서 빠져나갈 때 쓴다.
|
||||
* 메일함을 아직 모르면(메일함 목록도 못 받음) /mail 로 보낸다 — /mail 은 메일함을 받은 뒤 기본 메일함으로 다시 바꿔 끼운다.
|
||||
*/
|
||||
async function replaceWithMailbox(mailboxId: string | null): Promise<void> {
|
||||
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
|
||||
}
|
||||
|
||||
return { mailboxLocation, messageLocation, backToList, showSibling, replaceWithMailbox }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
|
||||
function success(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'success', message, action })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
// 글꼴 — npm pretendard 패키지를 번들에 포함해 자체 호스팅한다 (외부 CDN 금지 · nginx CSP font-src 'self')
|
||||
// 쓰는 글자 범위의 조각 파일만 내려받는 dynamic subset 판이다.
|
||||
import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css'
|
||||
import '@/styles/tokens.css'
|
||||
import '@/styles/base.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -6,6 +12,7 @@ import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 라우터 가드가 스토어(세션 복원)를 쓰므로 Pinia 를 먼저 설치한다
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 홈 페이지 — 템플릿 기본 진입점
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref<string>('You did it!')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<h1>{{ message }}</h1>
|
||||
<p>
|
||||
Visit
|
||||
<a
|
||||
href="https://vuejs.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuejs.org</a>
|
||||
to read the documentation
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import LoginPage from './LoginPage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
let clock = Date.UTC(2026, 9, 26, 5, 0, 0)
|
||||
|
||||
describe('LoginPage', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function mountLogin(path = '/login') {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(LoginPage, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
async function submit(wrapper: Awaited<ReturnType<typeof mountLogin>>['wrapper'], password: string) {
|
||||
await wrapper.get('#login-email').setValue(USER.email)
|
||||
await wrapper.get('#login-password').setValue(password)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('로그인 버튼은 진짜 submit 버튼이다 (Enter 로 제출된다)', async () => {
|
||||
const { wrapper } = await mountLogin()
|
||||
const button = wrapper.get('form button')
|
||||
|
||||
expect(button.attributes('type')).toBe('submit')
|
||||
expect(button.text()).toBe('로그인')
|
||||
})
|
||||
|
||||
it('자격 증명이 틀리면(AUTH_004) 사전 문구를 폼 안에 보이고 전역 알림은 띄우지 않는다', async () => {
|
||||
installMockApi(() => fail(401, 'AUTH_004'))
|
||||
const { wrapper } = await mountLogin()
|
||||
|
||||
await submit(wrapper, 'wrong')
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_004)
|
||||
expect(wrapper.get('#login-password').attributes('aria-invalid')).toBe('true')
|
||||
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('성공하면 사용자를 저장하고 redirect 로 돌아간다', async () => {
|
||||
installMockApi(() => ok(USER))
|
||||
const { wrapper, router } = await mountLogin('/login?redirect=/mail/inbox/m1')
|
||||
|
||||
await submit(wrapper, 'secret')
|
||||
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
})
|
||||
|
||||
it('redirect 가 앱 밖 주소면 무시하고 메일로 간다', async () => {
|
||||
installMockApi(() => ok(USER))
|
||||
const { wrapper, router } = await mountLogin('/login?redirect=//evil.example')
|
||||
|
||||
await submit(wrapper, 'secret')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('mail')
|
||||
})
|
||||
|
||||
it('응답이 올 때까지 다시 보낼 수 없다 — 버튼을 막고, Enter 로 한 번 더 제출해도 요청은 하나다 (계약 1절)', async () => {
|
||||
let releaseLogin: (result: MockResult) => void = () => undefined
|
||||
const loginGate = new Promise<MockResult>((resolve) => {
|
||||
releaseLogin = resolve
|
||||
})
|
||||
const requests = installMockApi(() => loginGate)
|
||||
const { wrapper } = await mountLogin()
|
||||
await wrapper.get('#login-email').setValue(USER.email)
|
||||
await wrapper.get('#login-password').setValue('wrong')
|
||||
const form = wrapper.get('form')
|
||||
const button = wrapper.get('form button')
|
||||
|
||||
await form.trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(button.attributes('aria-busy')).toBe('true')
|
||||
expect(button.text()).toBe('로그인하는 중…')
|
||||
|
||||
// 비밀번호 칸에서 Enter — 브라우저는 막힌 기본 버튼이면 제출하지 않지만, 제출 이벤트가 와도 무시한다
|
||||
await wrapper.get('#login-password').trigger('keydown', { key: 'Enter' })
|
||||
await form.trigger('submit')
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.url === '/auth/login')).toHaveLength(1)
|
||||
|
||||
releaseLogin(fail(401, 'AUTH_004'))
|
||||
await flushPromises()
|
||||
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
expect(button.attributes('aria-busy')).toBe('false')
|
||||
expect(button.text()).toBe('로그인')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 (디자인 원본 Login) — 직원 계정 = 메일 계정
|
||||
// 실패 문구는 에러 코드 사전(AUTH_004)에서만 온다. 계정 없음·비밀번호 틀림을 구분하지 않는다.
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import logoFull from '@/assets/brand/logo-arasystem-white.png'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { isApiError } from '@/composables/useApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { APP_NAME, COMPANY_MAIL_DOMAIN } from '@/shared/constants/brand'
|
||||
import { toSafeRedirect } from '@/shared/utils/redirect'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { login } = useAuth()
|
||||
|
||||
const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const submitting = ref<boolean>(false)
|
||||
/** 자격 증명 불일치(AUTH_004) 안내 — 사전 문구 그대로 */
|
||||
const credentialError = ref<string | null>(null)
|
||||
const passwordRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
credentialError.value = null
|
||||
try {
|
||||
await login({ email: email.value.trim(), password: password.value })
|
||||
await router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
|
||||
} catch (error) {
|
||||
if (isApiError(error) && error.code === 'AUTH_004') {
|
||||
credentialError.value = error.message
|
||||
password.value = ''
|
||||
passwordRef.value?.focus()
|
||||
}
|
||||
// 그 밖의 실패(시도 과다 REQ_001 · 메일 엔진 장애 MAIL_001 등)는 인터셉터가 알렸다
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<section
|
||||
class="login__brand"
|
||||
:aria-label="`${APP_NAME} 소개`"
|
||||
>
|
||||
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 960×459 -->
|
||||
<img
|
||||
:src="logoFull"
|
||||
:alt="APP_NAME"
|
||||
width="960"
|
||||
height="459"
|
||||
class="login__logo"
|
||||
>
|
||||
<div class="login__pitch">
|
||||
<p class="login__headline">
|
||||
사내 업무를<br>한 계정으로
|
||||
</p>
|
||||
<p class="login__lead">
|
||||
메일부터 시작해 캘린더, 파일 저장소, ERP까지 같은 계정으로 이어집니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="login__main">
|
||||
<div class="login__panel">
|
||||
<div class="login__heading">
|
||||
<h1 class="login__title">
|
||||
로그인
|
||||
</h1>
|
||||
<p class="login__subtitle">
|
||||
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="credentialError"
|
||||
id="login-error"
|
||||
class="login__error"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="login__error-icon"
|
||||
/>
|
||||
<span>{{ credentialError }}</span>
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="login__form"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="login__field">
|
||||
<label
|
||||
for="login-email"
|
||||
class="login__label"
|
||||
>메일 주소</label>
|
||||
<input
|
||||
id="login-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="username"
|
||||
:placeholder="`name@${COMPANY_MAIL_DOMAIN}`"
|
||||
required
|
||||
:class="['login__input', { 'is-invalid': credentialError }]"
|
||||
:aria-invalid="credentialError ? 'true' : undefined"
|
||||
:aria-describedby="credentialError ? 'login-error' : undefined"
|
||||
>
|
||||
</div>
|
||||
<div class="login__field">
|
||||
<label
|
||||
for="login-password"
|
||||
class="login__label"
|
||||
>비밀번호</label>
|
||||
<input
|
||||
id="login-password"
|
||||
ref="passwordRef"
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="비밀번호 입력"
|
||||
required
|
||||
:class="['login__input', { 'is-invalid': credentialError }]"
|
||||
:aria-invalid="credentialError ? 'true' : undefined"
|
||||
:aria-describedby="credentialError ? 'login-error' : undefined"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="login__submit"
|
||||
:disabled="submitting"
|
||||
:aria-busy="submitting"
|
||||
>
|
||||
{{ submitting ? '로그인하는 중…' : '로그인' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login__help">
|
||||
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
<p class="login__note">
|
||||
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.login__brand {
|
||||
flex: 1 1 25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-56);
|
||||
padding: var(--space-44) var(--space-48) var(--space-48);
|
||||
background: var(--color-brand-surface);
|
||||
color: var(--color-on-brand-text);
|
||||
}
|
||||
|
||||
.login__logo {
|
||||
display: block;
|
||||
width: 15rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.login__pitch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
max-width: 26.25rem;
|
||||
}
|
||||
|
||||
.login__headline {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-display);
|
||||
line-height: 1.32;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-display);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.login__lead {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 1.65;
|
||||
color: var(--color-on-brand-muted);
|
||||
}
|
||||
|
||||
.login__main {
|
||||
flex: 999 1 30rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-48) var(--space-24);
|
||||
}
|
||||
|
||||
.login__panel {
|
||||
width: 100%;
|
||||
max-width: 22.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.login__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.login__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading-lg);
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-heading);
|
||||
}
|
||||
|
||||
.login__subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.login__error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.login__error-icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.login__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
}
|
||||
|
||||
.login__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.login__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.login__input {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.login__input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.login__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-6);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login__submit:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.login__submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.login__help {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.login__note {
|
||||
margin: 0;
|
||||
padding-top: var(--space-16);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,555 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { 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 MockHandler,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
cc: [{ name: '인프라팀', email: 'infra@arasystem.it.kr' }],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: '<m1@arasystem.it.kr>',
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '김아라님, 안녕하세요.',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
describe('ComposePage', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** attach: 초점 이동을 확인할 때 문서에 붙인다 (테스트 끝에 wrapper.unmount()) */
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, router, requests }
|
||||
}
|
||||
|
||||
function chips(wrapper: VueWrapper, inputId: string): string[] {
|
||||
const field = wrapper.get(`#${inputId}`).element.closest('.recipient-field')
|
||||
return Array.from(field?.querySelectorAll('.recipient-chip__label') ?? []).map(
|
||||
(chip) => chip.textContent ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
function sendRequest(requests: MockRequest[]) {
|
||||
return requests.find((request) => request.method === 'POST' && request.url === '/messages')
|
||||
}
|
||||
|
||||
const replyApi: MockHandler = ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
|
||||
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
|
||||
it('답장(?replyTo=)이면 받는 사람 · "Re:" 제목 · 인용 본문을 채운다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('답장')
|
||||
expect(chips(wrapper, 'compose-to')).toEqual(['이도윤 <doyun.lee@arasystem.it.kr>'])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Re: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
|
||||
expect(body).toContain('──────────')
|
||||
expect(body).toContain('이도윤 <doyun.lee@arasystem.it.kr> 작성:')
|
||||
expect(body).toContain('> 김아라님, 안녕하세요.')
|
||||
})
|
||||
|
||||
it('전체 답장(&all=1)은 원래 참조까지 채운다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1&all=1', replyApi)
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('전체 답장')
|
||||
expect(chips(wrapper, 'compose-cc')).toEqual(['인프라팀 <infra@arasystem.it.kr>'])
|
||||
})
|
||||
|
||||
it('보내면 계약대로 발송하고(inReplyTo 포함) 성공 토스트 후 보던 메일함으로 돌아간다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)?.data).toEqual({
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: 'Re: 메일 서버 이전 일정 공유',
|
||||
body: expect.stringContaining('> 김아라님, 안녕하세요.'),
|
||||
attachments: [],
|
||||
inReplyTo: 'm1',
|
||||
})
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
})
|
||||
|
||||
it('형식이 틀린 주소가 있으면 보내지 않고 MAIL_002 문구를 보인다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('kim@arasystem')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('받는 사람 없이 보내면 보내지 않고 같은 안내를 보인다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('서버가 받는 사람을 거부하면(MAIL_002) 토스트 없이 같은 문구로 알리고 쓴 내용은 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', ({ url }) =>
|
||||
url === '/messages' ? fail(400, 'MAIL_002') : ok({}),
|
||||
)
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('nobody@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await wrapper.get('#compose-subject').setValue('안녕하세요')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
|
||||
})
|
||||
|
||||
describe('전달 (?forward=)', () => {
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const ESTIMATE = { id: 'att2', name: '견적.pdf', type: 'application/pdf', size: 1000 }
|
||||
const forwardApi: MockHandler = ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') {
|
||||
return ok({ ...ORIGINAL, attachments: [CHECKLIST, ESTIMATE] })
|
||||
}
|
||||
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
|
||||
function attachmentNames(wrapper: VueWrapper): string[] {
|
||||
return wrapper.findAll('.upload-item__name').map((item) => item.text())
|
||||
}
|
||||
|
||||
it('"Fwd:" 제목 · 원문 · 원본 첨부를 채우고, 받는 사람은 비워 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?forward=m1', forwardApi)
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('전달')
|
||||
expect(chips(wrapper, 'compose-to')).toEqual([])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Fwd: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
|
||||
expect(body).toContain('전달된 메일')
|
||||
expect(body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(body).toContain('김아라님, 안녕하세요.')
|
||||
expect(attachmentNames(wrapper)).toEqual(['체크리스트.xlsx', '견적.pdf'])
|
||||
})
|
||||
|
||||
it('원본 첨부 id 를 그대로 보내고 inReplyTo 는 넣지 않는다 — 뺀 첨부는 보내지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?forward=m1', forwardApi)
|
||||
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.attachments).toEqual([{ id: 'att2', name: '견적.pdf' }])
|
||||
expect(payload?.subject).toBe('Fwd: 메일 서버 이전 일정 공유')
|
||||
expect(payload).not.toHaveProperty('inReplyTo')
|
||||
// 다시 올리지 않는다
|
||||
expect(requests.some((request) => request.url === '/mail-attachments')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('발송이 MAIL_004 면 첨부 전체를 "찾지 못함"으로 표시하고, 빼기 전까지 보내지 않는다 — 전달 원본뿐이면 다시 올릴 수 없어 빼기 버튼으로 초점을 옮긴다', async () => {
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?forward=m1',
|
||||
({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [CHECKLIST] })
|
||||
if (method === 'POST' && url === '/messages') return fail(400, 'MAIL_004')
|
||||
return ok({})
|
||||
},
|
||||
true,
|
||||
)
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const item = wrapper.get('.upload-item')
|
||||
expect(item.classes()).toContain('upload-item--failed')
|
||||
expect(item.get('.upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_004)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
const removeButton = wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]')
|
||||
expect(document.activeElement).toBe(removeButton.element)
|
||||
|
||||
// 찾지 못한 첨부를 빼면 다시 보낼 수 있다
|
||||
await removeButton.trigger('click')
|
||||
expect(wrapper.get('.compose__send').attributes('disabled')).toBeUndefined()
|
||||
expect(requests.filter((request) => request.url === '/messages' && request.method === 'POST')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('발송이 MAIL_004 인데 올린 파일이 있으면 바로 다시 고를 수 있게 "파일 첨부" 버튼으로 초점을 옮긴다', async () => {
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const { wrapper } = await mountCompose(
|
||||
'/mail/compose?forward=m1',
|
||||
({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [CHECKLIST] })
|
||||
if (method === 'POST' && url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 10 }, 201)
|
||||
}
|
||||
if (method === 'POST' && url === '/messages') return fail(400, 'MAIL_004')
|
||||
return ok({})
|
||||
},
|
||||
true,
|
||||
)
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', {
|
||||
value: [new File(['x'], '회의록.pdf')],
|
||||
configurable: true,
|
||||
})
|
||||
await picker.trigger('change')
|
||||
await flushPromises()
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.upload-item--failed')).toHaveLength(2)
|
||||
expect(document.activeElement).toBe(wrapper.get('.compose__attach').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('받는 사람이 합계 100명을 넘으면 보내지 않고 MAIL_002 문구를 보인다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
const input = wrapper.get('#compose-to')
|
||||
const many = Array.from({ length: 101 }, (_, index) => `user${index}@arasystem.it.kr`).join(', ')
|
||||
await input.setValue(many)
|
||||
await input.trigger('blur')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(chips(wrapper, 'compose-to')).toHaveLength(101)
|
||||
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('첨부 안내 문구는 서버가 알려 준 파일당 · 합계 한도로 만든다', async () => {
|
||||
useMailStore().quota = {
|
||||
usedBytes: 0,
|
||||
limitBytes: null,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 30 * 1024 * 1024,
|
||||
}
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.get('.compose__attach-hint').text()).toBe(
|
||||
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 문서, 이미지, 압축 파일',
|
||||
)
|
||||
})
|
||||
|
||||
it('한도를 아직 못 받았으면 안내 문구는 대체값(파일당 20MB · 합계 25MB)으로 만든다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(useMailStore().quota).toBeNull()
|
||||
expect(wrapper.get('.compose__attach-hint').text()).toBe(
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일',
|
||||
)
|
||||
})
|
||||
|
||||
it('첨부 합계는 서버 한도(attachmentTotalMaxBytes)로 붙일 때 막는다 — 전달 원본도 세고, 넘는 파일은 올리지 않고 빼고 보낸다', async () => {
|
||||
const MB = 1024 * 1024
|
||||
useMailStore().quota = {
|
||||
usedBytes: 0,
|
||||
limitBytes: null,
|
||||
attachmentMaxBytes: 20 * MB,
|
||||
attachmentTotalMaxBytes: 15 * MB,
|
||||
}
|
||||
const PHOTOS = { id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 12 * MB }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?forward=m1', ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [PHOTOS] })
|
||||
if (method === 'POST' && url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
|
||||
}
|
||||
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
|
||||
return ok({})
|
||||
})
|
||||
const sized = (name: string, size: number) => {
|
||||
const chosen = new File(['x'], name)
|
||||
Object.defineProperty(chosen, 'size', { value: size })
|
||||
return chosen
|
||||
}
|
||||
|
||||
// 원본 12MB + 회의록 2MB = 14MB 는 통과, 도면 5MB 를 더하면 19MB 라 막힌다
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', {
|
||||
value: [sized('회의록.pdf', 2 * MB), sized('도면.pdf', 5 * MB)],
|
||||
configurable: true,
|
||||
})
|
||||
await picker.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([
|
||||
expect.arrayContaining(['upload-item--done']),
|
||||
expect.arrayContaining(['upload-item--done']),
|
||||
expect.arrayContaining(['upload-item--failed']),
|
||||
])
|
||||
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(
|
||||
ERROR_CODE_MESSAGES.FILE_002,
|
||||
)
|
||||
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.attachments).toEqual([
|
||||
{ id: 'att1', name: '현장사진.zip' },
|
||||
{ id: 'up1', name: '회의록.pdf' },
|
||||
])
|
||||
})
|
||||
|
||||
it('보내기 버튼은 submit 이고, 제목에서 Enter 를 눌러도 보내지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.get('.compose__send').attributes('type')).toBe('submit')
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
|
||||
wrapper.get('#compose-subject').element.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('중복 발송 방지 키 (Idempotency-Key, 계약 2절)', () => {
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
function sendRequests(requests: MockRequest[]): MockRequest[] {
|
||||
return requests.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
}
|
||||
|
||||
function sentKeys(requests: MockRequest[]): unknown[] {
|
||||
return sendRequests(requests).map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
}
|
||||
|
||||
async function submit(wrapper: VueWrapper) {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function addRecipient(wrapper: VueWrapper, email: string) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue(email)
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
/** 보내기 응답을 차례로 돌려준다 — 그 밖의 요청은 답장 원문 등 */
|
||||
function sendSequence(outcomes: MockResult[]): MockHandler {
|
||||
return (request) =>
|
||||
request.method === 'POST' && request.url === '/messages'
|
||||
? (outcomes.shift() ?? ok({ id: 'sent-1' }, 201))
|
||||
: replyApi(request)
|
||||
}
|
||||
|
||||
// 키는 성공과 MAIL_005 때만 바꾼다 — 아래 실패는 실제로 나갔거나 아직 처리 중일 수 있어 같은 키로 다시 보낸다
|
||||
it.each([
|
||||
['응답 없음(시간 초과·네트워크 오류)', 'network', 'SYS_001'],
|
||||
['503 MAIL_001 (엔진 장애)', fail(503, 'MAIL_001'), 'MAIL_001'],
|
||||
['429 REQ_001 (발송 한도·같은 키 처리 중)', fail(429, 'REQ_001'), 'REQ_001'],
|
||||
['413 FILE_002 (첨부 합계 초과)', fail(413, 'FILE_002'), 'FILE_002'],
|
||||
['403 AUTH_003 (보낼 권한 없음·출처 불일치)', fail(403, 'AUTH_003'), 'AUTH_003'],
|
||||
['400 MAIL_006 (첨부 20개 초과 — 붙일 때 미리 막지만 서버가 마지막에 본다)', fail(400, 'MAIL_006'), 'MAIL_006'],
|
||||
] as const)(
|
||||
'%s — 사전 문구로 알리고 쓴 내용·키를 그대로 둔다. 자동으로 다시 보내지 않고, 다시 누르면 같은 키다',
|
||||
async (_label, failure, code) => {
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose?replyTo=m1',
|
||||
sendSequence([failure, ok({ id: 'sent-1' }, 201)]),
|
||||
)
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(sendRequests(requests)).toHaveLength(1)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
ERROR_CODE_MESSAGES[code],
|
||||
])
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
|
||||
expect(chips(wrapper, 'compose-to')).toEqual(['이도윤 <doyun.lee@arasystem.it.kr>'])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Re: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
|
||||
// 사용자가 잠시 뒤 다시 누른다 — 이미 나간 메일이면 서버가 처음 결과를 돌려준다
|
||||
await submit(wrapper)
|
||||
|
||||
const keys = sentKeys(requests)
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys[0]).toMatch(UUID_V4)
|
||||
expect(keys[1]).toBe(keys[0])
|
||||
},
|
||||
)
|
||||
|
||||
it('409 MAIL_005(같은 키·다른 내용)면 사전 문구로 알리고 쓴 내용은 둔 채 키를 바꾼다 — 다시 누르면 새 메일로 나간다', async () => {
|
||||
const { wrapper, router, requests } = await mountCompose(
|
||||
'/mail/compose?replyTo=m1',
|
||||
sendSequence([fail(409, 'MAIL_005'), ok({ id: 'sent-2' }, 201)]),
|
||||
)
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
ERROR_CODE_MESSAGES.MAIL_005,
|
||||
])
|
||||
expect(chips(wrapper, 'compose-to')).toEqual(['이도윤 <doyun.lee@arasystem.it.kr>'])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Re: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
const [first, second] = sentKeys(requests)
|
||||
expect(first).toMatch(UUID_V4)
|
||||
expect(second).toMatch(UUID_V4)
|
||||
expect(second).not.toBe(first)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail')
|
||||
})
|
||||
|
||||
it('로그인 갱신(401 → /auth/refresh) 뒤 다시 보낸 요청도 같은 키다', async () => {
|
||||
let refreshed = false
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) => {
|
||||
if (request.url === '/auth/refresh') {
|
||||
refreshed = true
|
||||
return ok(USER)
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/messages') {
|
||||
return refreshed ? ok({ id: 'sent-1' }, 201) : fail(401, 'AUTH_001')
|
||||
}
|
||||
return replyApi(request)
|
||||
})
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
const keys = sentKeys(requests)
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys[1]).toBe(keys[0])
|
||||
})
|
||||
|
||||
it('발송에 성공하면 키를 바꾼다 — 같은 화면에서 또 보내면 다른 메일이다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
|
||||
await submit(wrapper)
|
||||
await submit(wrapper)
|
||||
|
||||
const [first, second] = sentKeys(requests)
|
||||
expect(first).toMatch(UUID_V4)
|
||||
expect(second).toMatch(UUID_V4)
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
|
||||
it('쓰기 화면을 새로 열거나 같은 화면에서 쓰기 종류가 바뀌면 새 키를 쓴다', async () => {
|
||||
const failing: MockHandler = (request) =>
|
||||
request.method === 'POST' && request.url === '/messages'
|
||||
? fail(503, 'MAIL_001')
|
||||
: replyApi(request)
|
||||
|
||||
const first = await mountCompose('/mail/compose?replyTo=m1', failing)
|
||||
await submit(first.wrapper)
|
||||
// 같은 화면을 재사용한 채 답장 → 새 메일로 바뀐다 (양식을 새로 채운다)
|
||||
await first.router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
await addRecipient(first.wrapper, 'doyun.lee@arasystem.it.kr')
|
||||
await submit(first.wrapper)
|
||||
const [replyKey, newMailKey] = sentKeys(first.requests)
|
||||
first.wrapper.unmount()
|
||||
|
||||
const reopened = await mountCompose('/mail/compose?replyTo=m1', failing)
|
||||
await submit(reopened.wrapper)
|
||||
const [reopenedKey] = sentKeys(reopened.requests)
|
||||
|
||||
expect(replyKey).toMatch(UUID_V4)
|
||||
expect(new Set([replyKey, newMailKey, reopenedKey]).size).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,715 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 쓰기 (디자인 원본 Compose) — /mail/compose
|
||||
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 인용 본문을 채운다.
|
||||
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 본문 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
|
||||
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
|
||||
import RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import { getErrorMessage, toApiError } from '@/composables/useApi'
|
||||
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
COMPOSE_TITLES,
|
||||
MAIL_ATTACHMENT_ACCEPT,
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_MAX_ATTACHMENTS,
|
||||
MAIL_MAX_RECIPIENTS,
|
||||
MAIL_SENT_ACTION_LABEL,
|
||||
MAIL_SENT_MESSAGE,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
type ComposeMode,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { SendMessagePayload } from '@/shared/types/mail'
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
shouldRenewSendKey,
|
||||
type SendOutcome,
|
||||
} from '@/shared/utils/idempotency-key'
|
||||
import { formatAddress, tokenFromAddress, type RecipientToken } from '@/shared/utils/mail-address'
|
||||
import { attachmentHint } from '@/shared/utils/mail-attachment'
|
||||
import { buildForwardDraft } from '@/shared/utils/mail-forward'
|
||||
import { buildReplyDraft } from '@/shared/utils/mail-reply'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 받는 사람 오류 안내 문단 — 세 입력칸이 함께 가리킨다 */
|
||||
const RECIPIENT_ERROR_ID = 'compose-recipient-error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const { getMessage, sendMessage } = useMail()
|
||||
const toast = useToast()
|
||||
const {
|
||||
items: uploadItems,
|
||||
isUploading,
|
||||
hasMissing,
|
||||
uploaded,
|
||||
addFiles,
|
||||
addExisting,
|
||||
markMissing,
|
||||
remove: removeUpload,
|
||||
reset: resetUploads,
|
||||
} = useAttachmentUploads({
|
||||
// 한도는 서버 값(GET /mail-quota 의 파일당 attachmentMaxBytes · 합계 attachmentTotalMaxBytes)
|
||||
// — 못 받았으면 스토어가 대체값을 준다
|
||||
maxBytes: () => mailStore.attachmentMaxBytes,
|
||||
totalMaxBytes: () => mailStore.attachmentTotalMaxBytes,
|
||||
})
|
||||
|
||||
const to = ref<RecipientToken[]>([])
|
||||
const cc = ref<RecipientToken[]>([])
|
||||
const bcc = ref<RecipientToken[]>([])
|
||||
const showCopies = ref<boolean>(false)
|
||||
const subject = ref<string>('')
|
||||
const body = ref<string>('')
|
||||
/** 답장할 원문을 불러오는 중 — 그동안 입력을 막아 미리 채운 값이 사용자 입력을 덮지 않게 한다 */
|
||||
const preparing = ref<boolean>(false)
|
||||
const sending = ref<boolean>(false)
|
||||
/** 보내기를 한 번 눌렀는지 — 받는 사람이 비었다는 안내는 그 뒤에만 보인다 */
|
||||
const attempted = ref<boolean>(false)
|
||||
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
|
||||
const rejectedByServer = ref<boolean>(false)
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
* 성공이나 MAIL_005 일 때만 바꾼다 (shouldRenewSendKey). 그 밖의 실패 뒤 다시 보내기는 같은 키다.
|
||||
* (화면에 그리지 않으므로 반응형이 아니어도 된다)
|
||||
*/
|
||||
let sendKey = createIdempotencyKey()
|
||||
|
||||
const toField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const ccField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const bccField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const bodyRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const attachButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
|
||||
|
||||
function queryId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
function isReply(value: ComposeMode): value is 'reply' | 'replyAll' {
|
||||
return value === 'reply' || value === 'replyAll'
|
||||
}
|
||||
|
||||
/** 답장·전달할 원본 메일 id */
|
||||
const sourceId = computed(() => queryId(route.query.forward) ?? queryId(route.query.replyTo))
|
||||
const mode = computed<ComposeMode>(() => {
|
||||
if (queryId(route.query.forward)) return 'forward'
|
||||
if (!queryId(route.query.replyTo)) return 'new'
|
||||
return route.query.all === '1' ? 'replyAll' : 'reply'
|
||||
})
|
||||
const title = computed(() => COMPOSE_TITLES[mode.value])
|
||||
const attachmentHintText = computed(() =>
|
||||
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
|
||||
)
|
||||
/** 쓰기를 마치거나 취소하면 보던 화면으로 — 처음부터 쓰기로 들어왔으면 받은편지함 */
|
||||
const backTo = computed(() => mailStore.lastBrowseLocation ?? { name: 'mail' })
|
||||
const sender = computed(() => (authStore.user ? formatAddress(authStore.user) : ''))
|
||||
|
||||
const allRecipients = computed(() => [...to.value, ...cc.value, ...bcc.value])
|
||||
const hasInvalidRecipient = computed(() => allRecipients.value.some((token) => !token.valid))
|
||||
/** 받는 사람 합계 100명 초과 — 서버도 MAIL_002 로 거절하므로 보내기 전에 같은 문구로 막는다 (계약 2절) */
|
||||
const tooManyRecipients = computed(() => allRecipients.value.length > MAIL_MAX_RECIPIENTS)
|
||||
const showRecipientError = computed(
|
||||
() =>
|
||||
hasInvalidRecipient.value ||
|
||||
rejectedByServer.value ||
|
||||
(attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
|
||||
)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const recipientErrorMessage = getErrorMessage('MAIL_002')
|
||||
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
|
||||
const canSend = computed(
|
||||
() => !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
to.value = []
|
||||
cc.value = []
|
||||
bcc.value = []
|
||||
showCopies.value = false
|
||||
subject.value = ''
|
||||
body.value = ''
|
||||
attempted.value = false
|
||||
rejectedByServer.value = false
|
||||
resetUploads()
|
||||
// 양식을 새로 채우면 다른 메일이다
|
||||
sendKey = createIdempotencyKey()
|
||||
}
|
||||
|
||||
let prepareSeq = 0
|
||||
|
||||
/** 답장·전달 — 원본 메일을 불러와 양식을 채운다 */
|
||||
async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, 'new'>) {
|
||||
prepareSeq += 1
|
||||
const seq = prepareSeq
|
||||
preparing.value = true
|
||||
try {
|
||||
const original = await getMessage(messageId)
|
||||
if (seq !== prepareSeq) return
|
||||
if (sourceMode === 'forward') {
|
||||
const draft = buildForwardDraft(original)
|
||||
subject.value = draft.subject
|
||||
body.value = draft.body
|
||||
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
|
||||
addExisting(draft.attachments)
|
||||
} else {
|
||||
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode)
|
||||
to.value = draft.to.map(tokenFromAddress)
|
||||
cc.value = draft.cc.map(tokenFromAddress)
|
||||
showCopies.value = cc.value.length > 0
|
||||
subject.value = draft.subject
|
||||
body.value = draft.body
|
||||
}
|
||||
} catch {
|
||||
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
|
||||
} finally {
|
||||
if (seq === prepareSeq) preparing.value = false
|
||||
}
|
||||
if (seq !== prepareSeq) return
|
||||
await nextTick()
|
||||
if (sourceMode === 'forward') {
|
||||
// 전달은 받는 사람부터 정한다
|
||||
toField.value?.focus()
|
||||
return
|
||||
}
|
||||
// 답장은 커서를 본문 맨 앞(인용 위)에 둔다
|
||||
bodyRef.value?.focus()
|
||||
bodyRef.value?.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
function commitRecipientInputs() {
|
||||
toField.value?.commit()
|
||||
ccField.value?.commit()
|
||||
bccField.value?.commit()
|
||||
}
|
||||
|
||||
/** 보내기 결과를 보고 키를 정한다 — 바꾸는 것은 성공과 MAIL_005 뿐 */
|
||||
function settleSendKey(outcome: SendOutcome) {
|
||||
if (shouldRenewSendKey(outcome)) sendKey = createIdempotencyKey()
|
||||
}
|
||||
|
||||
function notifySent() {
|
||||
const sent = mailStore.mailboxByRole('sent')
|
||||
toast.success(
|
||||
MAIL_SENT_MESSAGE,
|
||||
sent
|
||||
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sent.id } } }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
|
||||
commitRecipientInputs()
|
||||
attempted.value = true
|
||||
rejectedByServer.value = false
|
||||
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
|
||||
toField.value?.focus()
|
||||
return
|
||||
}
|
||||
if (!canSend.value) return
|
||||
|
||||
const payload: SendMessagePayload = {
|
||||
to: to.value.map((token) => token.address),
|
||||
cc: cc.value.map((token) => token.address),
|
||||
bcc: bcc.value.map((token) => token.address),
|
||||
subject: subject.value.trim(),
|
||||
body: body.value,
|
||||
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id 를 그대로 보낸다 (계약 2절 "전달")
|
||||
attachments: uploaded.value.map(({ id, name }) => ({ id, name })),
|
||||
// 스레드 연결은 답장만 — 전달에는 inReplyTo 를 넣지 않는다
|
||||
...(isReply(mode.value) && sourceId.value ? { inReplyTo: sourceId.value } : {}),
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
// 받는 사람 거부(MAIL_002)와 첨부 만료(MAIL_004)는 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
|
||||
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
|
||||
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
|
||||
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
await sendMessage(payload, { idempotencyKey: sendKey, silent: ['MAIL_002', 'MAIL_004'] })
|
||||
settleSendKey('sent')
|
||||
notifySent()
|
||||
// 보낸편지함 개수 · 사용량을 서버 기준으로 다시 맞춘다
|
||||
mailStore.syncCounts()
|
||||
await router.push(backTo.value)
|
||||
} catch (error) {
|
||||
const { code } = toApiError(error)
|
||||
settleSendKey(code)
|
||||
if (code === 'MAIL_002') {
|
||||
rejectedByServer.value = true
|
||||
toField.value?.focus()
|
||||
} else if (code === 'MAIL_004') {
|
||||
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
|
||||
const marked = markMissing()
|
||||
await nextTick()
|
||||
if (marked.some((item) => item.origin === 'upload')) {
|
||||
// 올린 파일이 있으면 바로 다시 고를 수 있게 "파일 첨부" 버튼으로
|
||||
attachButtonRef.value?.focus()
|
||||
} else if (marked[0]) {
|
||||
// 전달 원본뿐이면 다시 올릴 수 없으므로 첫 첨부의 빼기 버튼으로
|
||||
attachmentListRef.value?.focusRemove(marked[0].key)
|
||||
}
|
||||
}
|
||||
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 */
|
||||
function onSubjectEnter(event: KeyboardEvent) {
|
||||
if (event.isComposing) return
|
||||
event.preventDefault()
|
||||
bodyRef.value?.focus()
|
||||
}
|
||||
|
||||
function pickFiles() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFilesChosen(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) addFiles(Array.from(input.files))
|
||||
// 같은 파일을 다시 고를 수 있게 비운다 (실패한 파일 다시 올리기)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 쓰기 종류(새 메일·답장·전달)나 원본 메일이 바뀌면 양식을 새로 채운다
|
||||
const draftKey = computed(() => `${mode.value}:${sourceId.value ?? ''}`)
|
||||
watch(
|
||||
draftKey,
|
||||
() => {
|
||||
resetForm()
|
||||
const id = sourceId.value
|
||||
const current = mode.value
|
||||
if (id && current !== 'new') void prepareFrom(id, current)
|
||||
else void nextTick(() => toField.value?.focus())
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 받는 사람을 고치면 서버 거부 안내를 내린다
|
||||
watch(allRecipients, () => {
|
||||
rejectedByServer.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="compose">
|
||||
<form
|
||||
class="compose__form"
|
||||
aria-labelledby="compose-title"
|
||||
novalidate
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="compose__header">
|
||||
<h1
|
||||
id="compose-title"
|
||||
class="compose__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<RouterLink
|
||||
:to="backTo"
|
||||
class="compose__close"
|
||||
aria-label="쓰기 닫기"
|
||||
>
|
||||
<AppIcon name="x" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<RecipientField
|
||||
ref="toField"
|
||||
v-model="to"
|
||||
label="받는 사람"
|
||||
input-id="compose-to"
|
||||
placeholder="메일 주소 입력 후 Enter"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:disabled="preparing"
|
||||
>
|
||||
<template #trailing>
|
||||
<button
|
||||
type="button"
|
||||
class="compose__copies-toggle"
|
||||
aria-controls="compose-copies"
|
||||
:aria-expanded="showCopies"
|
||||
@click="showCopies = !showCopies"
|
||||
>
|
||||
참조 · 숨은참조
|
||||
</button>
|
||||
</template>
|
||||
</RecipientField>
|
||||
|
||||
<div
|
||||
v-show="showCopies"
|
||||
id="compose-copies"
|
||||
>
|
||||
<RecipientField
|
||||
ref="ccField"
|
||||
v-model="cc"
|
||||
label="참조"
|
||||
input-id="compose-cc"
|
||||
placeholder="메일 주소"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
<RecipientField
|
||||
ref="bccField"
|
||||
v-model="bcc"
|
||||
label="숨은참조"
|
||||
input-id="compose-bcc"
|
||||
placeholder="메일 주소"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showRecipientError"
|
||||
:id="RECIPIENT_ERROR_ID"
|
||||
class="compose__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ recipientErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__row">
|
||||
<label
|
||||
for="compose-subject"
|
||||
class="compose__label"
|
||||
>제목</label>
|
||||
<input
|
||||
id="compose-subject"
|
||||
v-model="subject"
|
||||
type="text"
|
||||
class="compose__subject"
|
||||
placeholder="제목을 입력하세요"
|
||||
autocomplete="off"
|
||||
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
@keydown.enter="onSubjectEnter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="bodyRef"
|
||||
v-model="body"
|
||||
class="compose__body"
|
||||
aria-label="본문"
|
||||
rows="13"
|
||||
placeholder="내용을 입력하세요"
|
||||
:maxlength="MAIL_BODY_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
|
||||
<div class="compose__attachments">
|
||||
<div class="compose__attach-bar">
|
||||
<button
|
||||
ref="attachButtonRef"
|
||||
type="button"
|
||||
class="compose__attach"
|
||||
@click="pickFiles"
|
||||
>
|
||||
<AppIcon
|
||||
name="paperclip"
|
||||
:size="15"
|
||||
/>
|
||||
<span>파일 첨부</span>
|
||||
</button>
|
||||
<span class="compose__attach-hint">{{ attachmentHintText }}</span>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="visually-hidden"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
:accept="MAIL_ATTACHMENT_ACCEPT"
|
||||
@change="onFilesChosen"
|
||||
>
|
||||
</div>
|
||||
<ComposeAttachmentList
|
||||
v-if="uploadItems.length > 0"
|
||||
ref="attachmentListRef"
|
||||
:items="uploadItems"
|
||||
@remove="removeUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="compose__footer">
|
||||
<button
|
||||
type="submit"
|
||||
class="compose__send"
|
||||
:disabled="!canSend"
|
||||
:aria-busy="sending"
|
||||
>
|
||||
<AppIcon
|
||||
name="send"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
|
||||
</button>
|
||||
<RouterLink
|
||||
:to="backTo"
|
||||
class="compose__cancel"
|
||||
>
|
||||
취소
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="sender"
|
||||
class="compose__sender"
|
||||
>
|
||||
<span class="compose__sender-label">보내는 사람</span>
|
||||
{{ sender }}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose {
|
||||
flex: 999 1 35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.compose__form {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 콘텐츠 열 — 읽기 화면과 같은 폭·같은 32px 선 (tokens.css --content-*) */
|
||||
max-width: var(--content-max-width);
|
||||
}
|
||||
|
||||
.compose__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-18) var(--space-4) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.compose__title {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.compose__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.compose__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.compose__copies-toggle {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__copies-toggle:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.compose__alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-8) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-danger-border);
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.compose__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6) var(--space-12);
|
||||
min-height: var(--size-touch);
|
||||
padding: var(--space-4) var(--content-toolbar-start) var(--space-4) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.compose__label {
|
||||
flex: 0 0 4rem;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.compose__subject {
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
min-height: 2.125rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__body {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 18.75rem;
|
||||
max-width: var(--compose-body-max-width);
|
||||
padding: var(--space-16) var(--content-inline);
|
||||
border: 0;
|
||||
resize: vertical;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
/* 테두리 없는 넓은 입력칸이라 초점 표시는 1px 로 가볍게, 안쪽에 둔다 */
|
||||
.compose__subject:focus-visible,
|
||||
.compose__body:focus-visible {
|
||||
outline-width: var(--border-width);
|
||||
outline-offset: calc(var(--border-width) * -1);
|
||||
}
|
||||
|
||||
.compose__attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--content-inline) var(--space-14);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.compose__attach-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-10);
|
||||
}
|
||||
|
||||
.compose__attach {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
/* 바깥 여백 -6px — 아이콘이 콘텐츠 열 32px 선에 온다 (디자인 원본 Compose) */
|
||||
margin-left: calc(var(--space-6) * -1);
|
||||
padding: 0 var(--space-10) 0 var(--space-6);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compose__attach:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.compose__attach-hint {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.compose__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--content-inline);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.compose__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-20);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__send:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.compose__send:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compose__cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.compose__cancel:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.compose__sender {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compose__sender-label {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.compose {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
// 하위 화면(목록·본문)보다 먼저 요청을 보낸다 — 메일함 개수가 본문 읽음 처리보다 늦게 도착하는 일을 줄인다
|
||||
void mailStore.ensureMailboxes()
|
||||
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
|
||||
void mailStore.ensureQuota()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell module-id="mail">
|
||||
<MailboxPanel />
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { mailViewModeStorageKey, useMailViewStore } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
const SUMMARY: MessageSummary = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
}
|
||||
|
||||
const DETAIL: MessageDetail = {
|
||||
...SUMMARY,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
describe('MailboxPage — 보기 방식에 따라 같은 주소를 다르게 그린다', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
installMockApi(({ url }) => {
|
||||
if (url === '/messages') {
|
||||
return ok({ items: [SUMMARY], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
}
|
||||
return ok(DETAIL)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('분할 보기(기본) — 2줄 목록과 본문 칸이 나란히', async () => {
|
||||
const wrapper = await mountAt('/mail/inbox')
|
||||
|
||||
expect(wrapper.find('.message-list').exists()).toBe(true)
|
||||
expect(wrapper.find('.reader').exists()).toBe(true)
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('목록 보기 — 메일을 열지 않았으면 표만 넓게', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
const wrapper = await mountAt('/mail/inbox')
|
||||
|
||||
expect(useMailViewStore().mode).toBe('list')
|
||||
expect(wrapper.find('table.mail-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.message-list').exists()).toBe(false)
|
||||
expect(wrapper.find('.reader').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('목록 보기에서 메일을 열면 표 자리에 본문을 넓게 — "목록으로 · 이전/다음" 이 붙는다', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
const wrapper = await mountAt('/mail/inbox/m1')
|
||||
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(wrapper.get('.reader').text()).toContain('목록으로')
|
||||
expect(wrapper.find('button[aria-label="다음 메일"]').exists()).toBe(true)
|
||||
expect(wrapper.get('#reader-subject').text()).toBe('메일 서버 이전 일정 공유')
|
||||
})
|
||||
|
||||
it('전환 버튼을 누르면 바로 바뀌고 그 사용자 키로 기억한다', async () => {
|
||||
const wrapper = await mountAt('/mail/inbox')
|
||||
|
||||
await wrapper.findAll('[aria-label="보기 방식"] button')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 목록 머리는 보기마다 다시 그려진다 — 새로 그린 전환 버튼에서 확인한다
|
||||
const pressed = wrapper.findAll('[aria-label="보기 방식"] button').map((button) => button.attributes('aria-pressed'))
|
||||
expect(pressed).toEqual(['false', 'true'])
|
||||
expect(wrapper.find('table.mail-table').exists()).toBe(true)
|
||||
expect(window.localStorage.getItem(mailViewModeStorageKey(USER.email))).toBe('list')
|
||||
})
|
||||
|
||||
describe('목록을 못 불러왔을 때', () => {
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox-id',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 1,
|
||||
unreadCount: 0,
|
||||
}
|
||||
const emptyPage = { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }
|
||||
|
||||
function listAreaOf(wrapper: Awaited<ReturnType<typeof mountAt>>, mode: 'split' | 'list') {
|
||||
return wrapper.get(mode === 'list' ? 'section.message-table' : 'section.message-list')
|
||||
}
|
||||
|
||||
it.each(['split', 'list'] as const)(
|
||||
'주소창에 남은 옛 메일함 id 면(404 RES_001) %s 보기 모두 사전 문구와 "받은편지함으로" — 누르면 기록을 쌓지 않고 받은편지함으로 바꿔 끼운다',
|
||||
async (mode) => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), mode)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = [INBOX]
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/messages' ? fail(404, 'RES_001') : ok(emptyPage),
|
||||
)
|
||||
const wrapper = await mountAt('/mail/old-mailbox')
|
||||
|
||||
expect(requests.find((request) => request.url === '/messages')?.params).toMatchObject({
|
||||
mailboxId: 'old-mailbox',
|
||||
})
|
||||
const listArea = listAreaOf(wrapper, mode)
|
||||
expect(listArea.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
|
||||
expect(wrapper.text()).not.toContain(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
const action = listArea.get('.mail-load-error__action')
|
||||
expect(action.text()).toBe('받은편지함으로')
|
||||
const router = wrapper.vm.$router
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox-id')
|
||||
expect(replace).toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('메일함 목록도 아직 모르면 /mail 로 바꿔 끼운다 (메일함을 받은 뒤 기본 메일함으로 간다)', async () => {
|
||||
installMockApi(({ url }) => (url === '/messages' ? fail(404, 'RES_001') : ok(emptyPage)))
|
||||
const wrapper = await mountAt('/mail/old-mailbox')
|
||||
const router = wrapper.vm.$router
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['split', 'list'] as const)(
|
||||
'메일 서버 장애 같은 일시적 오류는 %s 보기에서도 "다시 시도" 그대로 — 누르면 다시 불러온다',
|
||||
async (mode) => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), mode)
|
||||
let healthy = false
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/messages' && !healthy ? fail(503, 'MAIL_001') : ok(emptyPage),
|
||||
)
|
||||
const wrapper = await mountAt('/mail/inbox')
|
||||
const action = listAreaOf(wrapper, mode).get('.mail-load-error__action')
|
||||
|
||||
expect(listAreaOf(wrapper, mode).text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(action.text()).toBe('다시 시도')
|
||||
|
||||
healthy = true
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.url === '/messages')).toHaveLength(2)
|
||||
expect(wrapper.vm.$router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(wrapper.find('.mail-load-error__action').exists()).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main)
|
||||
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import ListReadNav from '@/components/mail/ListReadNav.vue'
|
||||
import MessageList from '@/components/mail/MessageList.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
|
||||
function paramOf(value: string | string[] | undefined): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
const mailboxId = computed(() => paramOf(route.params.mailboxId))
|
||||
const messageId = computed(() => paramOf(route.params.messageId))
|
||||
const isListView = computed(() => viewStore.mode === 'list')
|
||||
|
||||
watch(
|
||||
mailboxId,
|
||||
(id) => {
|
||||
if (id) void mailStore.openMailbox(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
if (id) void mailStore.openMessage(id)
|
||||
else mailStore.closeMessage()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(fullPath) => {
|
||||
if (mailboxId.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// /mail 로 들어오면 메일함을 불러온 뒤 받은편지함으로 보낸다
|
||||
watch(
|
||||
[mailboxId, () => mailStore.defaultMailbox],
|
||||
([id, fallback]) => {
|
||||
if (!id && fallback) {
|
||||
void router.replace({ name: 'mail-mailbox', params: { mailboxId: fallback.id } })
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main :class="['mail-view', { 'mail-view--list': isListView }]">
|
||||
<template v-if="mailboxId && isListView">
|
||||
<MessageReader
|
||||
v-if="messageId"
|
||||
layout="list"
|
||||
>
|
||||
<template #toolbar-start>
|
||||
<ListReadNav
|
||||
:mailbox-id="mailboxId"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</template>
|
||||
</MessageReader>
|
||||
<MessageTable
|
||||
v-else
|
||||
:mailbox-id="mailboxId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="mailboxId">
|
||||
<MessageList :mailbox-id="mailboxId" />
|
||||
<MessageReader />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section
|
||||
class="mail-view__pending"
|
||||
aria-label="메일함"
|
||||
>
|
||||
<StateMessage
|
||||
v-if="mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError"
|
||||
tone="danger"
|
||||
icon="alert-circle"
|
||||
:title="mailStore.mailboxesError.message"
|
||||
:trace-id="mailStore.mailboxesError.traceId"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mail-view__retry"
|
||||
@click="mailStore.loadMailboxes()"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</StateMessage>
|
||||
<MessageListSkeleton v-else />
|
||||
</section>
|
||||
<MessageReader v-if="!isListView" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-view {
|
||||
flex: 999 1 35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mail-view__pending {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 메일함 패널 오른쪽 전체 폭을 한 칸이 쓴다 */
|
||||
.mail-view--list .mail-view__pending {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.mail-view__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-view__retry:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록·본문이 한 줄에 놓이고 칸마다 스크롤한다 (AppShell 과 같은 이유로 줄바꿈을 끈다) */
|
||||
@media (min-width: 70rem) {
|
||||
.mail-view {
|
||||
flex-wrap: nowrap;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,58 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { toSafeRedirect } from '@/shared/utils/redirect'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/** 로그인해야 들어갈 수 있는 화면 (하위 라우트에 이어진다) */
|
||||
requiresAuth?: boolean
|
||||
/** 로그인한 사용자는 들어올 필요가 없는 화면 (로그인) */
|
||||
guestOnly?: boolean
|
||||
/** 문서 제목 — "제목 · ARA SYSTEM" */
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 목록·본문 라우트가 같은 컴포넌트를 쓰도록 한 번만 정의한다 (메일을 바꿔도 화면을 다시 만들지 않는다)
|
||||
const MailboxPage = () => import('@/pages/mail/MailboxPage.vue')
|
||||
|
||||
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/pages/HomePage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
redirect: { name: 'mail' },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { guestOnly: true, title: '로그인' },
|
||||
},
|
||||
{
|
||||
path: '/mail',
|
||||
component: () => import('@/pages/mail/MailLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '메일' },
|
||||
children: [
|
||||
// /mail → 메일함을 불러온 뒤 받은편지함으로 (MailboxPage 가 이동시킨다)
|
||||
{ path: '', name: 'mail', component: MailboxPage },
|
||||
{
|
||||
path: 'compose',
|
||||
name: 'mail-compose',
|
||||
component: () => import('@/pages/mail/ComposePage.vue'),
|
||||
meta: { title: '메일 쓰기' },
|
||||
},
|
||||
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
|
||||
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/pages/NotFoundPage.vue'),
|
||||
meta: { title: '페이지를 찾을 수 없음' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -21,15 +61,24 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 네비게이션 가드 — 보호 라우트(requiresAuth) 접근 시 인증 여부 검사
|
||||
router.beforeEach((to) => {
|
||||
// 네비게이션 가드 — 앱 시작 시 /auth/me 로 세션을 복원한 뒤 첫 네비게이션을 판정한다
|
||||
router.beforeEach(async (to) => {
|
||||
// 첫 호출만 실제로 조회하고, 이후에는 같은 결과를 바로 돌려준다
|
||||
await useAuth().restoreSession()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
// 미인증 시 홈으로 리다이렉트
|
||||
// (실제 로그인 페이지 추가 시 { name: 'login' } 으로 변경)
|
||||
return { name: 'home' }
|
||||
// 로그인 후 원래 가려던 화면으로 돌아온다
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.meta.guestOnly && authStore.isLoggedIn) {
|
||||
return toSafeRedirect(to.query.redirect) ?? { name: 'mail' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} · ${APP_NAME}` : APP_NAME
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 전역 앱 레일에 붙는 사내 시스템 모듈 목록 — 레일은 이 목록 하나만 보고 그린다.
|
||||
//
|
||||
// 새 모듈을 붙이는 방법:
|
||||
// 1) 모듈 라우트를 router/index.ts 에 추가하고 (레이아웃은 AppShell 을 감싼다)
|
||||
// 2) 아래 항목의 routeName 에 그 라우트 이름을 적는다 → 레일에서 바로 활성화된다.
|
||||
// routeName 이 null 인 모듈은 "준비 중"으로 비활성 표시한다 (뱃지 없이).
|
||||
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
export type AppModuleId = 'mail' | 'calendar' | 'files' | 'erp' | 'contacts' | 'settings'
|
||||
|
||||
export interface AppModule {
|
||||
id: AppModuleId
|
||||
/** 레일과 상단 바에 보이는 이름 */
|
||||
label: string
|
||||
icon: IconName
|
||||
/** 모듈 첫 화면의 라우트 이름. null 이면 아직 준비 중이다 */
|
||||
routeName: string | null
|
||||
/** 레일 위치 — 'end' 는 맨 아래(설정) */
|
||||
placement: 'main' | 'end'
|
||||
}
|
||||
|
||||
export const APP_MODULES: readonly AppModule[] = [
|
||||
{ id: 'mail', label: '메일', icon: 'mail', routeName: 'mail', placement: 'main' },
|
||||
{ id: 'calendar', label: '캘린더', icon: 'calendar', routeName: null, placement: 'main' },
|
||||
{ id: 'files', label: '파일', icon: 'folder', routeName: null, placement: 'main' },
|
||||
{ id: 'erp', label: 'ERP', icon: 'briefcase', routeName: null, placement: 'main' },
|
||||
{ id: 'contacts', label: '주소록', icon: 'users', routeName: null, placement: 'main' },
|
||||
{ id: 'settings', label: '설정', icon: 'settings', routeName: null, placement: 'end' },
|
||||
]
|
||||
|
||||
/** 비활성 모듈에 붙이는 안내 (툴팁·스크린리더) */
|
||||
export const MODULE_NOT_READY_LABEL = '준비 중'
|
||||
|
||||
export function findAppModule(id: AppModuleId): AppModule {
|
||||
const found = APP_MODULES.find((module) => module.id === id)
|
||||
if (!found) throw new Error(`등록되지 않은 모듈입니다: ${id}`)
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// 브랜드·회사 식별 값 — 화면 문구에서 반복해 쓰는 값만 둔다
|
||||
|
||||
export const APP_NAME = 'ARA SYSTEM'
|
||||
|
||||
/**
|
||||
* 회사 메일 도메인 (로그인 안내 문구용).
|
||||
* 실제 허용 도메인 검사는 서버가 한다 — 여기 값은 화면 표시용이라 개발 환경(arasystem.test)과 달라도 동작에는 영향이 없다.
|
||||
*/
|
||||
export const COMPANY_MAIL_DOMAIN = 'arasystem.it.kr'
|
||||
@@ -0,0 +1,136 @@
|
||||
// 선 아이콘 모음 (24×24 viewBox, 디자인 원본과 같은 Feather 계열 도형)
|
||||
// AppIcon.vue 가 이 목록으로 <svg> 를 그린다. 새 아이콘은 여기에만 추가한다.
|
||||
|
||||
/** rem 변환 기준 — 16px = 1rem */
|
||||
export const REM_BASE_PX = 16
|
||||
|
||||
export type IconShapeTag = 'path' | 'polyline' | 'polygon' | 'line' | 'circle' | 'rect'
|
||||
|
||||
export interface IconShape {
|
||||
tag: IconShapeTag
|
||||
attrs: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const path = (d: string): IconShape => ({ tag: 'path', attrs: { d } })
|
||||
const polyline = (points: string): IconShape => ({ tag: 'polyline', attrs: { points } })
|
||||
const polygon = (points: string): IconShape => ({ tag: 'polygon', attrs: { points } })
|
||||
const line = (x1: number, y1: number, x2: number, y2: number): IconShape => ({
|
||||
tag: 'line',
|
||||
attrs: { x1: String(x1), y1: String(y1), x2: String(x2), y2: String(y2) },
|
||||
})
|
||||
const circle = (cx: number, cy: number, r: number): IconShape => ({
|
||||
tag: 'circle',
|
||||
attrs: { cx: String(cx), cy: String(cy), r: String(r) },
|
||||
})
|
||||
const rect = (x: number, y: number, width: number, height: number, radius = 0): IconShape => ({
|
||||
tag: 'rect',
|
||||
attrs: {
|
||||
x: String(x),
|
||||
y: String(y),
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
rx: String(radius),
|
||||
ry: String(radius),
|
||||
},
|
||||
})
|
||||
|
||||
const ALERT_DOT = [line(12, 8, 12, 12), line(12, 16, 12.01, 16)]
|
||||
|
||||
export const ICONS = {
|
||||
mail: [
|
||||
path('M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z'),
|
||||
polyline('22,6 12,13 2,6'),
|
||||
],
|
||||
calendar: [rect(3, 4, 18, 18, 2), line(16, 2, 16, 6), line(8, 2, 8, 6), line(3, 10, 21, 10)],
|
||||
folder: [path('M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z')],
|
||||
briefcase: [rect(2, 7, 20, 14, 2), path('M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16')],
|
||||
users: [
|
||||
path('M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2'),
|
||||
circle(9, 7, 4),
|
||||
path('M23 21v-2a4 4 0 0 0-3-3.87'),
|
||||
path('M16 3.13a4 4 0 0 1 0 7.75'),
|
||||
],
|
||||
settings: [
|
||||
circle(12, 12, 3),
|
||||
path(
|
||||
'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z',
|
||||
),
|
||||
],
|
||||
search: [circle(11, 11, 8), line(21, 21, 16.65, 16.65)],
|
||||
'log-out': [
|
||||
path('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'),
|
||||
polyline('16 17 21 12 16 7'),
|
||||
line(21, 12, 9, 12),
|
||||
],
|
||||
edit: [path('M12 20h9'), path('M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z')],
|
||||
inbox: [
|
||||
polyline('22 12 16 12 14 15 10 15 8 12 2 12'),
|
||||
path(
|
||||
'M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z',
|
||||
),
|
||||
],
|
||||
send: [line(22, 2, 11, 13), polygon('22 2 15 22 11 13 2 9 22 2')],
|
||||
'file-text': [
|
||||
path('M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'),
|
||||
polyline('14 2 14 8 20 8'),
|
||||
line(16, 13, 8, 13),
|
||||
line(16, 17, 8, 17),
|
||||
],
|
||||
'alert-octagon': [
|
||||
polygon('7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2'),
|
||||
...ALERT_DOT,
|
||||
],
|
||||
trash: [
|
||||
polyline('3 6 5 6 21 6'),
|
||||
path('M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'),
|
||||
],
|
||||
archive: [polyline('21 8 21 21 3 21 3 8'), rect(1, 3, 22, 5), line(10, 12, 14, 12)],
|
||||
refresh: [
|
||||
polyline('23 4 23 10 17 10'),
|
||||
polyline('1 20 1 14 7 14'),
|
||||
path('M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15'),
|
||||
],
|
||||
paperclip: [
|
||||
path(
|
||||
'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48',
|
||||
),
|
||||
],
|
||||
star: [
|
||||
polygon(
|
||||
'12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2',
|
||||
),
|
||||
],
|
||||
reply: [polyline('9 14 4 9 9 4'), path('M20 20v-7a4 4 0 0 0-4-4H4')],
|
||||
'reply-all': [
|
||||
polyline('7 17 2 12 7 7'),
|
||||
polyline('12 17 7 12 12 7'),
|
||||
path('M22 18v-2a4 4 0 0 0-4-4H7'),
|
||||
],
|
||||
forward: [polyline('15 14 20 9 15 4'), path('M4 20v-7a4 4 0 0 1 4-4h12')],
|
||||
shield: [path('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z')],
|
||||
'alert-circle': [circle(12, 12, 10), ...ALERT_DOT],
|
||||
x: [line(18, 6, 6, 18), line(6, 6, 18, 18)],
|
||||
download: [
|
||||
path('M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'),
|
||||
polyline('7 10 12 15 17 10'),
|
||||
line(12, 15, 12, 3),
|
||||
],
|
||||
file: [path('M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z'), polyline('13 2 13 9 20 9')],
|
||||
check: [polyline('20 6 9 17 4 12')],
|
||||
/** 보기 전환 — 분할 */
|
||||
'view-split': [rect(3, 4, 18, 16, 2), line(10, 4, 10, 20)],
|
||||
/** 보기 전환 — 목록 */
|
||||
'view-list': [
|
||||
line(9, 6, 20, 6),
|
||||
line(9, 12, 20, 12),
|
||||
line(9, 18, 20, 18),
|
||||
line(4, 6, 4.01, 6),
|
||||
line(4, 12, 4.01, 12),
|
||||
line(4, 18, 4.01, 18),
|
||||
],
|
||||
'arrow-left': [line(19, 12, 5, 12), polyline('12 19 5 12 12 5')],
|
||||
'chevron-left': [polyline('15 18 9 12 15 6')],
|
||||
'chevron-right': [polyline('9 18 15 12 9 6')],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
@@ -0,0 +1,135 @@
|
||||
// 메일 화면·검증 상수 — 한도 값은 docs/mail/api-contract.md 와 같다 (서버가 최종 검증한다)
|
||||
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import type { MailboxRole } from '@/shared/types/mail'
|
||||
|
||||
/**
|
||||
* 메일함 정렬 순서 — 계약 2절 "메일함 정렬(확정 디자인 기준)".
|
||||
* 역할 없는 메일함은 이 뒤에 sortOrder·이름 순으로 놓는다.
|
||||
*/
|
||||
export const MAILBOX_ROLE_ORDER: readonly MailboxRole[] = [
|
||||
'inbox',
|
||||
'sent',
|
||||
'drafts',
|
||||
'junk',
|
||||
'trash',
|
||||
'archive',
|
||||
]
|
||||
|
||||
/** 역할 없는(사용자가 만든) 메일함을 가리키는 키 */
|
||||
export const CUSTOM_MAILBOX_KEY = 'custom'
|
||||
export type MailboxKind = MailboxRole | typeof CUSTOM_MAILBOX_KEY
|
||||
|
||||
export const MAILBOX_ICONS: Readonly<Record<MailboxKind, IconName>> = {
|
||||
inbox: 'inbox',
|
||||
drafts: 'file-text',
|
||||
sent: 'send',
|
||||
junk: 'alert-octagon',
|
||||
trash: 'trash',
|
||||
archive: 'archive',
|
||||
custom: 'folder',
|
||||
}
|
||||
|
||||
/** 빈 메일함 안내 (디자인 원본 States · Main) */
|
||||
export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
|
||||
inbox: '새 메일이 오면 여기에 표시됩니다.',
|
||||
drafts: '임시 저장한 메일이 여기에 표시됩니다.',
|
||||
sent: '보낸 메일이 여기에 표시됩니다.',
|
||||
junk: '스팸으로 분류된 메일이 여기에 표시됩니다.',
|
||||
trash: '삭제한 메일이 여기에 표시됩니다.',
|
||||
archive: '보관한 메일이 여기에 표시됩니다.',
|
||||
custom: '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
|
||||
}
|
||||
|
||||
/** 받는 사람 칸을 보여 주는 메일함 — 목록에 보낸 사람 대신 "받는 사람: …" 을 쓴다 */
|
||||
export const OUTGOING_MAILBOX_ROLES: readonly MailboxRole[] = ['sent', 'drafts']
|
||||
|
||||
// ── 발송 한도 (계약 2절 "발송 본문") ─────────────────────────
|
||||
export const MAIL_SUBJECT_MAX_LENGTH = 255
|
||||
export const MAIL_BODY_MAX_LENGTH = 100_000
|
||||
export const MAIL_MAX_RECIPIENTS = 100
|
||||
/**
|
||||
* 메일 한 통의 첨부 개수 한도 — 계약 2절 "발송 본문"(attachments 20개 이하, 넘으면 400 MAIL_006).
|
||||
* 서버 설정이 아니라 계약으로 정한 값이라 상수로 둔다. 업로드한 파일과 전달하는 원본 첨부를 합쳐 센다.
|
||||
*/
|
||||
export const MAIL_MAX_ATTACHMENTS = 20
|
||||
|
||||
// ── 첨부 (계약 2절 "첨부 업로드 한도") ─────────────────────────
|
||||
/**
|
||||
* 파일당 최대 크기의 대체값 — 서버 기본값(MAIL_ATTACHMENT_MAX_SIZE_MB=20)과 같다.
|
||||
* 실제 한도는 GET /mail-quota 의 attachmentMaxBytes 를 쓴다. 그 값을 못 받았을 때만 이 값으로 사전 검사·안내한다.
|
||||
*/
|
||||
export const MAIL_ATTACHMENT_FALLBACK_MAX_SIZE_MB = 20
|
||||
export const BYTES_PER_MB = 1024 * 1024
|
||||
export const MAIL_ATTACHMENT_FALLBACK_MAX_BYTES = MAIL_ATTACHMENT_FALLBACK_MAX_SIZE_MB * BYTES_PER_MB
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 대체값 — 서버 기본값(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25)과 같다.
|
||||
* 실제 한도는 GET /mail-quota 의 attachmentTotalMaxBytes 를 쓴다 (발송 때 서버가 검사하는 값과 같다).
|
||||
*/
|
||||
export const MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_SIZE_MB = 25
|
||||
export const MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES =
|
||||
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_SIZE_MB * BYTES_PER_MB
|
||||
export const MAIL_ATTACHMENT_EXTENSIONS: readonly string[] = [
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'hwp',
|
||||
'hwpx',
|
||||
'txt',
|
||||
'csv',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'zip',
|
||||
]
|
||||
/** <input type="file" accept> 값 */
|
||||
export const MAIL_ATTACHMENT_ACCEPT = MAIL_ATTACHMENT_EXTENSIONS.map((ext) => `.${ext}`).join(',')
|
||||
/** 첨부 안내에서 한도 뒤에 붙는 허용 형식 설명 (디자인 원본 Compose) */
|
||||
export const MAIL_ATTACHMENT_KINDS_LABEL = '문서, 이미지, 압축 파일'
|
||||
|
||||
// ── 메일함 사용량 (계약 2절 GET /mail-quota) ───────────────────
|
||||
/** 사용량이 이 비율을 넘으면 막대를 경고색으로 그린다 (용량 부족 MAIL_003 전에 알아차리게) */
|
||||
export const MAIL_QUOTA_WARNING_RATIO = 0.9
|
||||
|
||||
// ── 요청 시간 한도 ─────────────────────────────────────────
|
||||
/** 첨부 업로드 — 기본 10초로는 20MB 파일이 느린 망에서 끊긴다 */
|
||||
export const MAIL_UPLOAD_TIMEOUT_MS = 5 * 60 * 1000
|
||||
/** 발송 — 첨부를 붙여 메일 엔진에 넘기는 시간까지 기다린다 */
|
||||
export const MAIL_SEND_TIMEOUT_MS = 60 * 1000
|
||||
|
||||
// ── 중복 발송 방지 (계약 2절 "발송 본문") ─────────────────────
|
||||
/** 쓰기 화면마다 만든 키(UUID)를 이 헤더로 보낸다 — 서버는 같은 키의 두 번째 발송을 내보내지 않는다 */
|
||||
export const MAIL_IDEMPOTENCY_HEADER = 'Idempotency-Key'
|
||||
|
||||
// ── 답장 · 전달 ─────────────────────────────────────────
|
||||
export const MAIL_REPLY_PREFIX = 'Re: '
|
||||
export const MAIL_FORWARD_PREFIX = 'Fwd: '
|
||||
/** 답장·전달 본문에서 내 글과 원문을 나누는 선 (디자인 원본 Compose) */
|
||||
export const MAIL_QUOTE_SEPARATOR = '──────────'
|
||||
/** 전달 본문의 원문 머리 제목 */
|
||||
export const MAIL_FORWARD_HEADING = '전달된 메일'
|
||||
/** 답장 인용 줄 머리 */
|
||||
export const MAIL_QUOTE_LINE_PREFIX = '> '
|
||||
/**
|
||||
* 답장·전달 초안의 인용(원문)을 본문 한도에 맞춰 줄였을 때 끝에 두는 표시 (계약 2절 "답장·전달 초안의 길이")
|
||||
*/
|
||||
export const MAIL_QUOTE_OMITTED_MARK = '(원문이 길어 이하 생략)'
|
||||
|
||||
/** 쓰기 화면 제목 */
|
||||
export const COMPOSE_TITLES = {
|
||||
new: '새 메일',
|
||||
reply: '답장',
|
||||
replyAll: '전체 답장',
|
||||
forward: '전달',
|
||||
} as const
|
||||
export type ComposeMode = keyof typeof COMPOSE_TITLES
|
||||
|
||||
/** 발송 완료 토스트 (디자인 원본 States "보내기 완료") */
|
||||
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
|
||||
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
|
||||
@@ -0,0 +1,14 @@
|
||||
// 인증 API 공유 타입 — docs/mail/api-contract.md 1절과 같은 구조
|
||||
// (토큰은 HttpOnly 쿠키로만 오가며 응답 본문에는 없다)
|
||||
|
||||
/** 로그인한 직원 — /auth/login · /auth/me · /auth/refresh 응답 */
|
||||
export interface AuthUser {
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** POST /auth/login 요청 본문 */
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 메일 API 공유 타입 — docs/mail/api-contract.md 2절과 같은 구조
|
||||
// 계약이 바뀌면 이 파일부터 맞춘다 (백엔드 Swagger 가 최종 기준).
|
||||
|
||||
import type { CursorPaginationQuery } from '@/shared/types/pagination'
|
||||
|
||||
export interface Address {
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
|
||||
export type MailboxRole = 'inbox' | 'drafts' | 'sent' | 'junk' | 'trash' | 'archive'
|
||||
|
||||
export interface Mailbox {
|
||||
id: string
|
||||
/** 역할이 있으면 서버가 한국어 표준 이름을 준다 (받은편지함 등) */
|
||||
name: string
|
||||
role: MailboxRole | null
|
||||
parentId: string | null
|
||||
sortOrder: number
|
||||
totalCount: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
id: string
|
||||
threadId: string
|
||||
mailboxIds: string[]
|
||||
from: Address | null
|
||||
to: Address[]
|
||||
/** 제목이 없으면 빈 문자열 */
|
||||
subject: string
|
||||
preview: string
|
||||
receivedAt: string
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachment: boolean
|
||||
/** 바이트 */
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
cc: Address[]
|
||||
bcc: Address[]
|
||||
replyTo: Address[]
|
||||
sentAt: string | null
|
||||
/** Message-ID 헤더 (답장 스레드 연결용) */
|
||||
messageId: string | null
|
||||
references: string[]
|
||||
/** 서버가 정화한 HTML — 그래도 격리된 iframe 에만 그린다 (MailBodyFrame) */
|
||||
htmlBody: string | null
|
||||
textBody: string | null
|
||||
/** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 */
|
||||
hasBlockedImages: boolean
|
||||
attachments: Attachment[]
|
||||
}
|
||||
|
||||
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
|
||||
export interface MailQuota {
|
||||
usedBytes: number
|
||||
/** 한도가 없으면 null → 사용량만 표시한다 */
|
||||
limitBytes: number | null
|
||||
/** 첨부 파일당 최대 크기 (서버의 MAIL_ATTACHMENT_MAX_SIZE_MB) */
|
||||
attachmentMaxBytes: number
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도 — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값.
|
||||
* 발송 때 서버가 검사하는 값과 같다 (전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다)
|
||||
*/
|
||||
attachmentTotalMaxBytes: number
|
||||
}
|
||||
|
||||
/** GET /messages 쿼리 */
|
||||
export interface MessageListQuery extends CursorPaginationQuery {
|
||||
mailboxId: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
/** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */
|
||||
export interface UpdateMessagePayload {
|
||||
isRead?: boolean
|
||||
isFlagged?: boolean
|
||||
mailboxId?: string
|
||||
}
|
||||
|
||||
/** 발송 본문에 싣는 첨부 — POST /mail-attachments 응답의 id */
|
||||
export interface AttachmentRef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** POST /messages 본문 */
|
||||
export interface SendMessagePayload {
|
||||
to: Address[]
|
||||
cc: Address[]
|
||||
bcc: Address[]
|
||||
subject: string
|
||||
/** 일반 텍스트 */
|
||||
body: string
|
||||
attachments: AttachmentRef[]
|
||||
/** 답장할 메일의 id — 스레드 헤더는 서버가 채운다 */
|
||||
inReplyTo?: string
|
||||
}
|
||||
|
||||
/** POST /messages 응답 — 보낸편지함에 저장된 메일 id */
|
||||
export interface SendMessageResult {
|
||||
id: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_API_BASE_URL, joinUrl, resolveApiBaseUrl } from './api-url'
|
||||
|
||||
describe('resolveApiBaseUrl', () => {
|
||||
it('주입된 값을 쓴다', () => {
|
||||
expect(resolveApiBaseUrl('https://api.arasystem.it.kr/api')).toBe('https://api.arasystem.it.kr/api')
|
||||
})
|
||||
|
||||
it('없거나 빈 문자열이면 같은 출처의 /api (compose 는 정의 안 된 변수를 빈 값으로 넣는다)', () => {
|
||||
expect(resolveApiBaseUrl(undefined)).toBe(DEFAULT_API_BASE_URL)
|
||||
expect(resolveApiBaseUrl(' ')).toBe(DEFAULT_API_BASE_URL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinUrl', () => {
|
||||
it('슬래시를 하나로 맞춰 잇는다', () => {
|
||||
expect(joinUrl('/api/', '/messages/m1')).toBe('/api/messages/m1')
|
||||
expect(joinUrl('https://api.arasystem.it.kr/api', 'messages')).toBe(
|
||||
'https://api.arasystem.it.kr/api/messages',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// API 주소 규칙 — 순수 함수만 둔다.
|
||||
// axios baseURL 과 첨부 다운로드 링크(<a href>)가 반드시 같은 규칙으로 주소를 만들어야
|
||||
// 같은 출처·같은 쿠키 경로(Path=/api)로 요청이 간다 (계약 2절 "첨부 업로드 한도").
|
||||
|
||||
/** 빌드 시 VITE_API_BASE_URL 이 없을 때의 기본값 — dev 는 vite 프록시가 /api 를 백엔드로 넘긴다 */
|
||||
export const DEFAULT_API_BASE_URL = '/api'
|
||||
|
||||
/** 빈 문자열(compose 가 정의되지 않은 변수를 빈 값으로 주입하는 경우)까지 기본값으로 돌린다 */
|
||||
export function resolveApiBaseUrl(configured: string | undefined): string {
|
||||
const value = configured?.trim()
|
||||
return value ? value : DEFAULT_API_BASE_URL
|
||||
}
|
||||
|
||||
/** base 와 path 사이의 슬래시를 하나로 맞춰 잇는다 */
|
||||
export function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatFileSize,
|
||||
formatMailDateTime,
|
||||
formatMailListTime,
|
||||
formatMailTableDate,
|
||||
formatMegabytes,
|
||||
formatQuotaUsage,
|
||||
formatTraceCode,
|
||||
initialOf,
|
||||
} from './format'
|
||||
|
||||
const KB = 1024
|
||||
const MB = KB * 1024
|
||||
const GB = MB * 1024
|
||||
|
||||
const SEOUL = 'Asia/Seoul'
|
||||
/** 2026-10-26(월) 오후 3시 (서울) */
|
||||
const NOW = new Date('2026-10-26T06:00:00Z')
|
||||
|
||||
describe('formatMailListTime', () => {
|
||||
it('오늘 받은 메일은 시각만 보인다', () => {
|
||||
expect(formatMailListTime('2026-10-26T01:42:00Z', NOW, SEOUL)).toBe('오전 10:42')
|
||||
})
|
||||
|
||||
it('어제 받은 메일은 "어제"', () => {
|
||||
expect(formatMailListTime('2026-10-25T09:03:00Z', NOW, SEOUL)).toBe('어제')
|
||||
})
|
||||
|
||||
it('달이 바뀌어도 하루 전이면 "어제"', () => {
|
||||
const firstOfMonth = new Date('2026-11-01T03:00:00Z')
|
||||
expect(formatMailListTime('2026-10-31T10:00:00Z', firstOfMonth, SEOUL)).toBe('어제')
|
||||
})
|
||||
|
||||
it('올해 메일은 월·일', () => {
|
||||
expect(formatMailListTime('2026-10-24T02:00:00Z', NOW, SEOUL)).toBe('10월 24일')
|
||||
})
|
||||
|
||||
it('지난해 메일은 연·월·일', () => {
|
||||
expect(formatMailListTime('2025-10-24T02:00:00Z', NOW, SEOUL)).toBe('2025. 10. 24.')
|
||||
})
|
||||
|
||||
it('UTC 기준 날짜가 아니라 보는 사람의 시간대 날짜로 판단한다', () => {
|
||||
// UTC 로는 10/25 이지만 서울로는 10/26 오전 8시 — 오늘
|
||||
expect(formatMailListTime('2026-10-25T23:00:00Z', NOW, SEOUL)).toBe('오전 8:00')
|
||||
})
|
||||
|
||||
it('잘못된 값이면 빈 문자열', () => {
|
||||
expect(formatMailListTime('not-a-date', NOW, SEOUL)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMailTableDate (목록 보기 날짜 칸)', () => {
|
||||
it('오늘이면 시각: "오전 10:42"', () => {
|
||||
expect(formatMailTableDate('2026-10-26T01:42:00Z', NOW, SEOUL)).toBe('오전 10:42')
|
||||
})
|
||||
|
||||
it('오늘이 아니면 월.일: 어제도 "10.25"', () => {
|
||||
expect(formatMailTableDate('2026-10-25T09:03:00Z', NOW, SEOUL)).toBe('10.25')
|
||||
expect(formatMailTableDate('2026-03-05T02:00:00Z', NOW, SEOUL)).toBe('03.05')
|
||||
})
|
||||
|
||||
it('지난해 이전이면 연도까지: "2025.10.24"', () => {
|
||||
expect(formatMailTableDate('2025-10-24T02:00:00Z', NOW, SEOUL)).toBe('2025.10.24')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMailDateTime', () => {
|
||||
it('디자인 원본 형식 "2026. 10. 26. (월) 오전 10:42"', () => {
|
||||
expect(formatMailDateTime('2026-10-26T01:42:00Z', SEOUL)).toBe('2026. 10. 26. (월) 오전 10:42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it.each([
|
||||
[0, '0 B'],
|
||||
[512, '512 B'],
|
||||
[48 * 1024, '48 KB'],
|
||||
[312 * 1024, '312 KB'],
|
||||
[1024 * 1024 - 100, '1 MB'],
|
||||
[1.5 * 1024 * 1024, '1.5 MB'],
|
||||
[38 * 1024 * 1024, '38 MB'],
|
||||
[5 * 1024 * 1024 * 1024, '5 GB'],
|
||||
])('%d 바이트 → %s', (bytes, expected) => {
|
||||
expect(formatFileSize(bytes)).toBe(expected)
|
||||
})
|
||||
|
||||
it('음수·숫자가 아닌 값은 빈 문자열', () => {
|
||||
expect(formatFileSize(-1)).toBe('')
|
||||
expect(formatFileSize(Number.NaN)).toBe('')
|
||||
})
|
||||
|
||||
it('TB 까지 줄인다', () => {
|
||||
expect(formatFileSize(2 * 1024 * GB)).toBe('2 TB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatQuotaUsage (메일함 사용량)', () => {
|
||||
it('사용량을 한도 단위로 써서 디자인 원본과 같다: "0.9 / 5 GB"', () => {
|
||||
// 0.9GB 는 자연 단위로는 921.6MB 지만 한도 단위(GB)로 맞춘다
|
||||
expect(formatQuotaUsage(0.9 * GB, 5 * GB)).toBe('0.9 / 5 GB')
|
||||
expect(formatQuotaUsage(12.34 * GB, 50 * GB)).toBe('12.3 / 50 GB')
|
||||
expect(formatQuotaUsage(0, 5 * GB)).toBe('0 / 5 GB')
|
||||
})
|
||||
|
||||
it('한도 단위로 0.1 이 안 되는 작은 사용량은 원래 단위로 쓴다', () => {
|
||||
expect(formatQuotaUsage(12 * MB, 5 * GB)).toBe('12 MB / 5 GB')
|
||||
})
|
||||
|
||||
it('한도가 없으면(null) 사용량만 GB 기준으로', () => {
|
||||
expect(formatQuotaUsage(0.9 * GB, null)).toBe('0.9 GB')
|
||||
expect(formatQuotaUsage(5 * MB, null)).toBe('5 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMegabytes (첨부 한도 안내)', () => {
|
||||
it('MB 정수는 그대로, 아니면 소수 한 자리', () => {
|
||||
expect(formatMegabytes(20 * MB)).toBe('20MB')
|
||||
expect(formatMegabytes(10.5 * MB)).toBe('10.5MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTraceCode', () => {
|
||||
it('traceId 앞 8자리를 문의 코드로 쓴다', () => {
|
||||
expect(formatTraceCode('3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d')).toBe('3f1c0a2e')
|
||||
})
|
||||
|
||||
it('없으면 null', () => {
|
||||
expect(formatTraceCode(undefined)).toBeNull()
|
||||
expect(formatTraceCode(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialOf', () => {
|
||||
it('이름의 첫 글자', () => {
|
||||
expect(initialOf('김아라')).toBe('김')
|
||||
})
|
||||
|
||||
it('"(주)" 같은 앞 괄호는 건너뛴다', () => {
|
||||
expect(initialOf('(주)한빛솔루션')).toBe('한')
|
||||
})
|
||||
|
||||
it('이름이 없으면 메일 주소 첫 글자를 대문자로', () => {
|
||||
expect(initialOf(null, 'ara.kim@arasystem.test')).toBe('A')
|
||||
})
|
||||
})
|
||||
@@ -18,3 +18,202 @@ export function formatDate(input: Date | string): string {
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
/** 개수 표기 (예: 1234 → "1,234") */
|
||||
export function formatCount(value: number): string {
|
||||
return value.toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
const BYTES_PER_UNIT = 1024
|
||||
const FILE_SIZE_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
|
||||
/** 이 값보다 작으면 소수 한 자리까지 보인다 (예: 1.5 MB) */
|
||||
const DECIMAL_THRESHOLD = 10
|
||||
|
||||
type FileSizeUnit = 'B' | (typeof FILE_SIZE_UNITS)[number]
|
||||
|
||||
/** 크기를 표시용 숫자와 단위로 나눈다 — formatFileSize · formatQuotaUsage 가 같은 반올림 규칙을 쓴다 */
|
||||
function splitFileSize(bytes: number): { value: number; unit: FileSizeUnit } {
|
||||
if (bytes < BYTES_PER_UNIT) return { value: bytes, unit: 'B' }
|
||||
|
||||
const lastUnitIndex = FILE_SIZE_UNITS.length - 1
|
||||
let value = bytes / BYTES_PER_UNIT
|
||||
let unitIndex = 0
|
||||
// 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다
|
||||
while (Math.round(value) >= BYTES_PER_UNIT && unitIndex < lastUnitIndex) {
|
||||
value /= BYTES_PER_UNIT
|
||||
unitIndex += 1
|
||||
}
|
||||
// KB 는 정수로 충분하다 (디자인 원본 기준). MB 이상은 10 미만일 때 소수 한 자리
|
||||
const rounded =
|
||||
unitIndex > 0 && value < DECIMAL_THRESHOLD ? Math.round(value * 10) / 10 : Math.round(value)
|
||||
return { value: rounded, unit: FILE_SIZE_UNITS[unitIndex] ?? 'TB' }
|
||||
}
|
||||
|
||||
function isValidSize(bytes: number): boolean {
|
||||
return Number.isFinite(bytes) && bytes >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 크기 표기 (디자인 원본: "48 KB", "38 MB")
|
||||
* 1KB 미만은 바이트 그대로, 그 위는 KB·MB·GB·TB 로 줄인다.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!isValidSize(bytes)) return ''
|
||||
const { value, unit } = splitFileSize(bytes)
|
||||
return `${value} ${unit}`
|
||||
}
|
||||
|
||||
const UNIT_BYTES: Readonly<Record<FileSizeUnit, number>> = {
|
||||
B: 1,
|
||||
KB: BYTES_PER_UNIT,
|
||||
MB: BYTES_PER_UNIT ** 2,
|
||||
GB: BYTES_PER_UNIT ** 3,
|
||||
TB: BYTES_PER_UNIT ** 4,
|
||||
}
|
||||
/** 한도가 없을 때 사용량을 나타내는 기준 단위 — 메일함 용량은 GB 로 읽는 것이 익숙하다 */
|
||||
const QUOTA_DEFAULT_UNIT: FileSizeUnit = 'GB'
|
||||
/** 기준 단위로 이 값보다 작으면 원래 단위로 쓴다 ("0 GB" 대신 "12 MB") */
|
||||
const QUOTA_MIN_VISIBLE_VALUE = 0.1
|
||||
|
||||
/**
|
||||
* 메일함 사용량 표기 (디자인 원본 Main: "0.9 / 5 GB")
|
||||
* 사용량은 한도의 단위(한도가 없으면 GB)로 소수 한 자리까지 쓴다.
|
||||
* - 한도가 있으면: "0.9 / 5 GB"
|
||||
* - 기준 단위로 0.1 이 안 되는 작은 사용량: "12 MB / 5 GB"
|
||||
* - 한도가 없으면(null) 사용량만: "0.9 GB"
|
||||
*/
|
||||
export function formatQuotaUsage(usedBytes: number, limitBytes: number | null): string {
|
||||
if (!isValidSize(usedBytes)) return ''
|
||||
|
||||
const limit =
|
||||
limitBytes !== null && isValidSize(limitBytes) && limitBytes > 0 ? splitFileSize(limitBytes) : null
|
||||
const unit = limit?.unit ?? QUOTA_DEFAULT_UNIT
|
||||
const usedInUnit = Math.round((usedBytes / UNIT_BYTES[unit]) * 10) / 10
|
||||
|
||||
if (usedBytes > 0 && usedInUnit < QUOTA_MIN_VISIBLE_VALUE) {
|
||||
const used = formatFileSize(usedBytes)
|
||||
return limit ? `${used} / ${limit.value} ${limit.unit}` : used
|
||||
}
|
||||
return limit ? `${usedInUnit} / ${limit.value} ${unit}` : `${usedInUnit} ${unit}`
|
||||
}
|
||||
|
||||
/** 첨부 한도 안내용 MB 표기 (디자인 원본 Compose: "파일당 최대 20MB") — 정수가 아니면 소수 한 자리 */
|
||||
export function formatMegabytes(bytes: number): string {
|
||||
const megabytes = bytes / (BYTES_PER_UNIT * BYTES_PER_UNIT)
|
||||
const rounded = Number.isInteger(megabytes) ? megabytes : Math.round(megabytes * 10) / 10
|
||||
return `${rounded}MB`
|
||||
}
|
||||
|
||||
interface CalendarDay {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
/** 주어진 시간대의 달력 날짜 (timeZone 이 없으면 브라우저 시간대) */
|
||||
function calendarDayOf(date: Date, timeZone?: string): CalendarDay {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).formatToParts(date)
|
||||
const pick = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((part) => part.type === type)?.value)
|
||||
return { year: pick('year'), month: pick('month'), day: pick('day') }
|
||||
}
|
||||
|
||||
function isSameDay(a: CalendarDay, b: CalendarDay): boolean {
|
||||
return a.year === b.year && a.month === b.month && a.day === b.day
|
||||
}
|
||||
|
||||
/** 달력 날짜 하루 전 (월·연 경계 포함) */
|
||||
function previousDay(day: CalendarDay): CalendarDay {
|
||||
const date = new Date(Date.UTC(day.year, day.month - 1, day.day - 1))
|
||||
return calendarDayOf(date, 'UTC')
|
||||
}
|
||||
|
||||
function parseDate(value: string): Date | null {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 목록의 받은 시각 (디자인 원본 Main)
|
||||
* - 오늘: "오전 10:42"
|
||||
* - 어제: "어제"
|
||||
* - 올해: "10월 24일"
|
||||
* - 그 이전: "2025. 10. 24."
|
||||
*/
|
||||
export function formatMailListTime(value: string, now: Date = new Date(), timeZone?: string): string {
|
||||
const date = parseDate(value)
|
||||
if (!date) return ''
|
||||
|
||||
const target = calendarDayOf(date, timeZone)
|
||||
const today = calendarDayOf(now, timeZone)
|
||||
|
||||
if (isSameDay(target, today)) {
|
||||
return new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
|
||||
}
|
||||
if (isSameDay(target, previousDay(today))) return '어제'
|
||||
if (target.year === today.year) {
|
||||
return new Intl.DateTimeFormat('ko-KR', { timeZone, month: 'long', day: 'numeric' }).format(date)
|
||||
}
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 보기 표의 날짜 칸 (디자인 원본 MainList: 칸 폭 84px)
|
||||
* - 오늘: "오전 10:42"
|
||||
* - 올해: "10.24"
|
||||
* - 그 이전: "2025.10.24" (연도가 없으면 헷갈린다)
|
||||
*/
|
||||
export function formatMailTableDate(value: string, now: Date = new Date(), timeZone?: string): string {
|
||||
const date = parseDate(value)
|
||||
if (!date) return ''
|
||||
|
||||
const target = calendarDayOf(date, timeZone)
|
||||
const today = calendarDayOf(now, timeZone)
|
||||
if (isSameDay(target, today)) {
|
||||
return new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
|
||||
}
|
||||
const monthDay = `${String(target.month).padStart(2, '0')}.${String(target.day).padStart(2, '0')}`
|
||||
return target.year === today.year ? monthDay : `${target.year}.${monthDay}`
|
||||
}
|
||||
|
||||
/** 메일 본문 머리의 날짜 (디자인 원본: "2026. 10. 26. (월) 오전 10:42") */
|
||||
export function formatMailDateTime(value: string, timeZone?: string): string {
|
||||
const date = parseDate(value)
|
||||
if (!date) return ''
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/** 문의 코드로 보여 줄 traceId 앞자리 수 (디자인 원본 States: "문의 코드 3f1c0a2e") */
|
||||
export const TRACE_CODE_LENGTH = 8
|
||||
|
||||
/** 서버 추적 ID → 화면에 보여 줄 문의 코드. 없으면 null */
|
||||
export function formatTraceCode(traceId: string | null | undefined): string | null {
|
||||
const value = traceId?.trim()
|
||||
return value ? value.slice(0, TRACE_CODE_LENGTH) : null
|
||||
}
|
||||
|
||||
/** 아바타에 쓰는 첫 글자 — "(주)한빛솔루션" → "한", 이름이 없으면 메일 주소 첫 글자 */
|
||||
export function initialOf(name: string | null | undefined, fallback = ''): string {
|
||||
const cleaned = (name ?? '').replace(/^\s*[([][^)\]]*[)\]]\s*/, '').trim()
|
||||
const source = cleaned || fallback.trim()
|
||||
const first = Array.from(source)[0] ?? ''
|
||||
return first.toUpperCase()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ERROR_CODE_MESSAGES, type ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { createIdempotencyKey, shouldRenewSendKey } from './idempotency-key'
|
||||
|
||||
/** RFC 9562 UUID v4 — 버전 4, 변형 비트 10 */
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
describe('createIdempotencyKey', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('UUID v4 형식의 키를 만들고, 부를 때마다 다른 값을 준다', () => {
|
||||
const first = createIdempotencyKey()
|
||||
const second = createIdempotencyKey()
|
||||
|
||||
expect(first).toMatch(UUID_V4)
|
||||
expect(second).toMatch(UUID_V4)
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
|
||||
it('randomUUID 가 없는 곳(보안 컨텍스트 밖)에서도 같은 형식을 만든다', () => {
|
||||
const original = globalThis.crypto
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: <T extends ArrayBufferView<ArrayBuffer>>(array: T) =>
|
||||
original.getRandomValues(array),
|
||||
})
|
||||
|
||||
const keys = Array.from({ length: 20 }, () => createIdempotencyKey())
|
||||
|
||||
for (const key of keys) expect(key).toMatch(UUID_V4)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
|
||||
it('직접 만들 때도 버전·변형 비트를 맞춘다 (난수가 모두 0xff 여도)', () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: <T extends ArrayBufferView<ArrayBuffer>>(array: T) => {
|
||||
new Uint8Array(array.buffer).fill(0xff)
|
||||
return array
|
||||
},
|
||||
})
|
||||
|
||||
expect(createIdempotencyKey()).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRenewSendKey — 키는 성공과 MAIL_005 때만 바꾼다 (계약 2절 "중복 발송 방지")', () => {
|
||||
it('성공했거나 MAIL_005(같은 키·다른 내용)면 새 키', () => {
|
||||
expect(shouldRenewSendKey('sent')).toBe(true)
|
||||
expect(shouldRenewSendKey('MAIL_005')).toBe(true)
|
||||
})
|
||||
|
||||
it.each<ErrorCode>([
|
||||
'MAIL_001',
|
||||
'SYS_001',
|
||||
'REQ_001',
|
||||
'FILE_002',
|
||||
'AUTH_003',
|
||||
'MAIL_002',
|
||||
'MAIL_004',
|
||||
'MAIL_006',
|
||||
])(
|
||||
'%s 뒤에는 같은 키로 다시 보낸다 (나갔거나 처리 중일 수 있다)',
|
||||
(code) => {
|
||||
expect(shouldRenewSendKey(code)).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('사전의 다른 모든 코드도 키를 유지한다 — 새 코드가 생겨도 바꾸는 경우는 늘지 않는다', () => {
|
||||
const renewing = (Object.keys(ERROR_CODE_MESSAGES) as ErrorCode[]).filter(shouldRenewSendKey)
|
||||
expect(renewing).toEqual(['MAIL_005'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
// 중복 발송 방지 키 — 계약 2절 "발송 본문"의 Idempotency-Key 헤더 값 (무작위 UUID)
|
||||
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
|
||||
/** 보내기 한 번의 결과 — 성공했거나, 실패한 에러 코드 */
|
||||
export type SendOutcome = 'sent' | ErrorCode
|
||||
|
||||
/** UUID 한 개의 바이트 수 */
|
||||
const UUID_BYTES = 16
|
||||
/** 7번째 바이트 상위 4비트 = 버전(0100: 무작위 v4) */
|
||||
const VERSION_BYTE = 6
|
||||
/** 9번째 바이트 상위 2비트 = 변형(10: RFC 9562) */
|
||||
const VARIANT_BYTE = 8
|
||||
/** 16진 32자리 → 8-4-4-4-12 */
|
||||
const UUID_LAYOUT = /^(.{8})(.{4})(.{4})(.{4})(.{12})$/
|
||||
const HEX_RADIX = 16
|
||||
|
||||
function toHex(byte: number): string {
|
||||
return byte.toString(HEX_RADIX).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** crypto.getRandomValues 로 UUID v4 를 만든다 — randomUUID 를 쓸 수 없을 때만 쓴다 */
|
||||
function randomUuidV4(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(UUID_BYTES))
|
||||
bytes[VERSION_BYTE] = ((bytes[VERSION_BYTE] ?? 0) & 0x0f) | 0x40
|
||||
bytes[VARIANT_BYTE] = ((bytes[VARIANT_BYTE] ?? 0) & 0x3f) | 0x80
|
||||
return Array.from(bytes, toHex).join('').replace(UUID_LAYOUT, '$1-$2-$3-$4-$5')
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 화면 하나에 쓸 새 키.
|
||||
* crypto.randomUUID 는 보안 컨텍스트(HTTPS·localhost)에서만 있다 — 사내망 IP 로 연 개발 서버처럼
|
||||
* 그렇지 않은 곳에서도 쓰기 화면이 깨지지 않게, 같은 형식(UUID v4)을 직접 만들어 쓴다.
|
||||
*/
|
||||
export function createIdempotencyKey(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
return randomUuidV4()
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 결과를 보고 다음 보내기에 새 키를 써야 하는지 — 계약 2절 "중복 발송 방지".
|
||||
* 같은 쓰기 화면에서 키를 바꾸는 것은 **성공과 MAIL_005 뿐**이다.
|
||||
* - 성공: 이 메일은 끝났다. 또 보내면 새 메일이다.
|
||||
* - MAIL_005(같은 키·다른 내용): 첫 메일이 나갔을 수 있어 서버가 보내지 않았다. 사용자가 보낸편지함을 확인한 뒤
|
||||
* 다시 누르면 새 메일로 나가야 한다.
|
||||
* 그 밖의 실패는 키를 그대로 둔다. MAIL_001·시간 초과·네트워크 오류는 실제로는 나갔을 수 있고,
|
||||
* REQ_001 은 같은 키가 아직 처리 중일 수 있다. 같은 키로 다시 보내야 서버가 두 번 내보내지 않는다.
|
||||
* (서버는 실제로 보낸 메일에만 키를 남기므로, 보내지 못한 실패 뒤 내용을 고쳐 같은 키로 보내도 MAIL_005 가 되지 않는다)
|
||||
*/
|
||||
export function shouldRenewSendKey(outcome: SendOutcome): boolean {
|
||||
return outcome === 'sent' || outcome === 'MAIL_005'
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { subjectParticle } from './korean'
|
||||
|
||||
describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
it.each([
|
||||
['받은편지함', '이'],
|
||||
['휴지통', '이'],
|
||||
['프로젝트', '가'],
|
||||
['Work', '이(가)'],
|
||||
['', '이(가)'],
|
||||
])('%s → %s', (word, particle) => {
|
||||
expect(subjectParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// 한국어 문구 보조 — 순수 함수만 둔다
|
||||
|
||||
const HANGUL_SYLLABLE_START = 0xac00
|
||||
const HANGUL_SYLLABLE_END = 0xd7a3
|
||||
/** 한글 음절 하나당 받침 경우의 수 (받침 없음 포함) */
|
||||
const JONGSEONG_COUNT = 28
|
||||
|
||||
/**
|
||||
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
|
||||
*/
|
||||
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
|
||||
const last = Array.from(word.trim()).pop()
|
||||
if (!last) return '이(가)'
|
||||
|
||||
const code = last.charCodeAt(0)
|
||||
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return '이(가)'
|
||||
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT === 0 ? '가' : '이'
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
displayName,
|
||||
formatAddress,
|
||||
hasOpenRecipientGroup,
|
||||
isValidEmail,
|
||||
mergeRecipientTokens,
|
||||
parseRecipient,
|
||||
splitRecipientInput,
|
||||
summarizeRecipients,
|
||||
tokenFromAddress,
|
||||
uniqueAddresses,
|
||||
} from './mail-address'
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it.each(['doyun.lee@arasystem.it.kr', 'ara.kim@arasystem.test', 'a+tag@b.co'])(
|
||||
'%s 는 올바른 주소',
|
||||
(email) => {
|
||||
expect(isValidEmail(email)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['kim@arasystem', '이도윤', 'a@b', 'a b@c.kr', '<a@b.kr>'])('%s 는 형식 오류', (email) => {
|
||||
expect(isValidEmail(email)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRecipient', () => {
|
||||
it('"이름 <주소>" 를 이름과 주소로 나눈다', () => {
|
||||
expect(parseRecipient('이도윤 <doyun.lee@arasystem.it.kr>')).toEqual({
|
||||
label: '이도윤 <doyun.lee@arasystem.it.kr>',
|
||||
address: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
valid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('따옴표로 감싼 이름도 받는다', () => {
|
||||
expect(parseRecipient('"Kim, Ara" <ara.kim@arasystem.it.kr>')?.address).toEqual({
|
||||
name: 'Kim, Ara',
|
||||
email: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
})
|
||||
|
||||
it('주소만 쓰면 이름은 null', () => {
|
||||
expect(parseRecipient(' junho.park@arasystem.it.kr ')?.address).toEqual({
|
||||
name: null,
|
||||
email: 'junho.park@arasystem.it.kr',
|
||||
})
|
||||
})
|
||||
|
||||
it('형식이 틀리면 토큰은 만들되 valid=false (빨간 물결 밑줄)', () => {
|
||||
expect(parseRecipient('kim@arasystem')).toMatchObject({ label: 'kim@arasystem', valid: false })
|
||||
})
|
||||
|
||||
it('빈 입력은 null', () => {
|
||||
expect(parseRecipient(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitRecipientInput', () => {
|
||||
it('쉼표·세미콜론·줄바꿈으로 나눈다', () => {
|
||||
expect(splitRecipientInput('a@b.kr, c@d.kr;e@f.kr\ng@h.kr')).toEqual([
|
||||
'a@b.kr',
|
||||
'c@d.kr',
|
||||
'e@f.kr',
|
||||
'g@h.kr',
|
||||
])
|
||||
})
|
||||
|
||||
it('따옴표·꺾쇠 안의 쉼표는 나누지 않는다', () => {
|
||||
expect(splitRecipientInput('"Kim, Ara" <ara@b.kr>, <x,y@b.kr>')).toEqual([
|
||||
'"Kim, Ara" <ara@b.kr>',
|
||||
'<x,y@b.kr>',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasOpenRecipientGroup', () => {
|
||||
it('따옴표나 꺾쇠가 열린 채면 true', () => {
|
||||
expect(hasOpenRecipientGroup('"Kim')).toBe(true)
|
||||
expect(hasOpenRecipientGroup('Kim <ara')).toBe(true)
|
||||
expect(hasOpenRecipientGroup('"Kim" <ara@b.kr>')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeRecipientTokens', () => {
|
||||
it('이미 있는 주소(대소문자 무시)는 다시 넣지 않는다', () => {
|
||||
const existing = [tokenFromAddress({ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' })]
|
||||
const incoming = [
|
||||
parseRecipient('DOYUN.LEE@arasystem.it.kr'),
|
||||
parseRecipient('junho.park@arasystem.it.kr'),
|
||||
].filter((token) => token !== null)
|
||||
|
||||
expect(mergeRecipientTokens(existing, incoming).map((token) => token.address.email)).toEqual([
|
||||
'doyun.lee@arasystem.it.kr',
|
||||
'junho.park@arasystem.it.kr',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('표시', () => {
|
||||
it('formatAddress — 이름이 있으면 "이름 <주소>"', () => {
|
||||
expect(formatAddress({ name: '김아라', email: 'ara.kim@arasystem.it.kr' })).toBe(
|
||||
'김아라 <ara.kim@arasystem.it.kr>',
|
||||
)
|
||||
expect(formatAddress({ name: null, email: 'hr@arasystem.it.kr' })).toBe('hr@arasystem.it.kr')
|
||||
})
|
||||
|
||||
it('displayName — 이름이 없으면 주소', () => {
|
||||
expect(displayName({ name: ' ', email: 'hr@arasystem.it.kr' })).toBe('hr@arasystem.it.kr')
|
||||
expect(displayName(null)).toBe('')
|
||||
})
|
||||
|
||||
it('summarizeRecipients — 첫 사람 + "외 N명"', () => {
|
||||
expect(summarizeRecipients([{ name: '박준호', email: 'junho.park@arasystem.it.kr' }])).toBe('박준호')
|
||||
expect(
|
||||
summarizeRecipients([
|
||||
{ name: '박준호', email: 'junho.park@arasystem.it.kr' },
|
||||
{ name: null, email: 'infra@arasystem.it.kr' },
|
||||
{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
]),
|
||||
).toBe('박준호 외 2명')
|
||||
expect(summarizeRecipients([])).toBe('')
|
||||
})
|
||||
|
||||
it('uniqueAddresses — 처음 나온 주소만 남긴다', () => {
|
||||
expect(
|
||||
uniqueAddresses([
|
||||
{ name: 'A', email: 'a@b.kr' },
|
||||
{ name: 'A2', email: 'A@B.kr' },
|
||||
]),
|
||||
).toEqual([{ name: 'A', email: 'a@b.kr' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
// 메일 주소 파싱·표시 — 순수 함수만 둔다
|
||||
|
||||
import type { Address } from '@/shared/types/mail'
|
||||
|
||||
/**
|
||||
* 형식 검사 — 디자인 원본(Compose)의 기준과 같다.
|
||||
* 화면은 입력 즉시 이 기준으로 표시하고, 최종 판단은 서버가 한다(MAIL_002).
|
||||
*/
|
||||
const EMAIL_PATTERN = /^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$/
|
||||
/** "이름 <주소>" 형식 */
|
||||
const NAMED_ADDRESS_PATTERN = /^(.*?)\s*<([^<>]*)>$/
|
||||
/** 여러 주소를 한 번에 붙여 넣을 때의 구분자 */
|
||||
const RECIPIENT_SEPARATORS = new Set([',', ';', '\n', '\r'])
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return EMAIL_PATTERN.test(email.trim())
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function sameAddress(a: Address, b: Address): boolean {
|
||||
return normalizeEmail(a.email) === normalizeEmail(b.email)
|
||||
}
|
||||
|
||||
/** 같은 주소(대소문자 무시)는 처음 것만 남긴다 */
|
||||
export function uniqueAddresses(list: readonly Address[]): Address[] {
|
||||
const seen = new Set<string>()
|
||||
return list.filter((address) => {
|
||||
const key = normalizeEmail(address.email)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** "이름 <주소>" — 이름이 없으면 주소만 */
|
||||
export function formatAddress(address: Address): string {
|
||||
const name = address.name?.trim()
|
||||
return name ? `${name} <${address.email}>` : address.email
|
||||
}
|
||||
|
||||
export function formatAddressList(list: readonly Address[]): string {
|
||||
return list.map(formatAddress).join(', ')
|
||||
}
|
||||
|
||||
/** 목록에 보이는 이름 — 이름이 없으면 주소 */
|
||||
export function displayName(address: Address | null | undefined): string {
|
||||
if (!address) return ''
|
||||
return address.name?.trim() || address.email
|
||||
}
|
||||
|
||||
/** 받는 사람 요약 — "박준호", 여럿이면 "박준호 외 2명", 없으면 빈 문자열 */
|
||||
export function summarizeRecipients(list: readonly Address[]): string {
|
||||
const [first, ...rest] = list
|
||||
if (!first) return ''
|
||||
return rest.length > 0 ? `${displayName(first)} 외 ${rest.length}명` : displayName(first)
|
||||
}
|
||||
|
||||
/** 받는 사람 칸의 토큰 하나 */
|
||||
export interface RecipientToken {
|
||||
/** 화면에 보이는 글자 — 입력한 그대로 */
|
||||
label: string
|
||||
address: Address
|
||||
/** 형식이 맞는지 — 틀리면 빨간 물결 밑줄 (디자인 원본 States "받는 사람 오류") */
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const quoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
|
||||
return quoted ? trimmed.slice(1, -1).trim() : trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 한 덩어리를 토큰으로 만든다. 빈 값이면 null.
|
||||
* "이름 <주소>" · "\"이름\" <주소>" · "<주소>" · "주소" 를 받는다.
|
||||
*/
|
||||
export function parseRecipient(raw: string): RecipientToken | null {
|
||||
const label = raw.trim()
|
||||
if (!label) return null
|
||||
|
||||
const named = NAMED_ADDRESS_PATTERN.exec(label)
|
||||
const email = (named?.[2] ?? label).trim()
|
||||
const name = named ? unquote(named[1] ?? '') : ''
|
||||
return { label, address: { name: name || null, email }, valid: isValidEmail(email) }
|
||||
}
|
||||
|
||||
/** 답장 등으로 미리 채울 때 — 서버가 준 주소를 토큰으로 */
|
||||
export function tokenFromAddress(address: Address): RecipientToken {
|
||||
return { label: formatAddress(address), address, valid: isValidEmail(address.email) }
|
||||
}
|
||||
|
||||
/** 따옴표나 꺾쇠가 열린 채인지 — 그 안의 쉼표는 구분자가 아니다 ("Kim, Ara" <a@b.kr>) */
|
||||
export function hasOpenRecipientGroup(text: string): boolean {
|
||||
let inQuotes = false
|
||||
let inAngle = false
|
||||
for (const char of text) {
|
||||
if (char === '"' && !inAngle) inQuotes = !inQuotes
|
||||
else if (char === '<' && !inQuotes) inAngle = true
|
||||
else if (char === '>' && !inQuotes) inAngle = false
|
||||
}
|
||||
return inQuotes || inAngle
|
||||
}
|
||||
|
||||
/** 붙여 넣은 글을 주소 단위로 나눈다 (따옴표·꺾쇠 안의 구분자는 무시) */
|
||||
export function splitRecipientInput(text: string): string[] {
|
||||
const parts: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
let inAngle = false
|
||||
for (const char of text) {
|
||||
if (char === '"' && !inAngle) inQuotes = !inQuotes
|
||||
else if (char === '<' && !inQuotes) inAngle = true
|
||||
else if (char === '>' && !inQuotes) inAngle = false
|
||||
|
||||
if (RECIPIENT_SEPARATORS.has(char) && !inQuotes && !inAngle) {
|
||||
parts.push(current)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
current += char
|
||||
}
|
||||
parts.push(current)
|
||||
return parts.map((part) => part.trim()).filter((part) => part.length > 0)
|
||||
}
|
||||
|
||||
/** v-for 키이자 중복 판단 기준 — 형식이 맞으면 주소, 아니면 입력한 글자 */
|
||||
export function recipientTokenKey(token: RecipientToken): string {
|
||||
return token.valid ? normalizeEmail(token.address.email) : token.label
|
||||
}
|
||||
|
||||
/** 이미 있는 토큰과 겹치는 것은 빼고 뒤에 붙인다 */
|
||||
export function mergeRecipientTokens(
|
||||
existing: readonly RecipientToken[],
|
||||
incoming: readonly RecipientToken[],
|
||||
): RecipientToken[] {
|
||||
const seen = new Set(existing.map(recipientTokenKey))
|
||||
const merged = [...existing]
|
||||
for (const token of incoming) {
|
||||
const key = recipientTokenKey(token)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
merged.push(token)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BYTES_PER_MB, MAIL_MAX_ATTACHMENTS } from '@/shared/constants/mail'
|
||||
import {
|
||||
attachmentHint,
|
||||
extensionOf,
|
||||
isAllowedAttachmentName,
|
||||
precheckAttachment,
|
||||
type AttachedSummary,
|
||||
type AttachmentLimits,
|
||||
} from './mail-attachment'
|
||||
|
||||
const LIMIT = 20 * BYTES_PER_MB
|
||||
const TOTAL = 25 * BYTES_PER_MB
|
||||
const UPLOAD: AttachmentLimits = { maxBytes: LIMIT, totalMaxBytes: TOTAL, maxCount: MAIL_MAX_ATTACHMENTS }
|
||||
/** 전달하는 원본 첨부 — 파일당 한도는 보지 않는다 */
|
||||
const FORWARDED: AttachmentLimits = { maxBytes: null, totalMaxBytes: TOTAL, maxCount: MAIL_MAX_ATTACHMENTS }
|
||||
const NONE: AttachedSummary = { count: 0, bytes: 0 }
|
||||
const bytes = (value: number): AttachedSummary => ({ count: 1, bytes: value })
|
||||
|
||||
describe('precheckAttachment', () => {
|
||||
it('허용하지 않는 확장자는 FILE_001', () => {
|
||||
expect(precheckAttachment({ name: '설치.exe', size: 10 }, UPLOAD, NONE)).toBe('FILE_001')
|
||||
expect(precheckAttachment({ name: '확장자없음', size: 10 }, UPLOAD, NONE)).toBe('FILE_001')
|
||||
expect(precheckAttachment({ name: '설치.exe', size: 10 }, FORWARDED, NONE)).toBe('FILE_001')
|
||||
})
|
||||
|
||||
it('파일당 한도를 넘으면 FILE_002', () => {
|
||||
expect(precheckAttachment({ name: '현장사진_원본.zip', size: LIMIT + 1 }, UPLOAD, NONE)).toBe(
|
||||
'FILE_002',
|
||||
)
|
||||
})
|
||||
|
||||
it('허용 형식·크기면 통과 (대소문자 무시)', () => {
|
||||
expect(precheckAttachment({ name: '견적_비교표.XLSX', size: LIMIT }, UPLOAD, NONE)).toBeNull()
|
||||
})
|
||||
|
||||
it('이미 붙은 첨부와 합쳐 합계 한도를 넘으면 FILE_002 — 딱 맞으면 통과', () => {
|
||||
const file = { name: '회의록.pdf', size: 10 * BYTES_PER_MB }
|
||||
|
||||
expect(precheckAttachment(file, UPLOAD, bytes(15 * BYTES_PER_MB))).toBeNull()
|
||||
expect(precheckAttachment(file, UPLOAD, bytes(15 * BYTES_PER_MB + 1))).toBe('FILE_002')
|
||||
})
|
||||
|
||||
it('전달하는 원본 첨부는 파일당 한도 없이 합계만 본다', () => {
|
||||
const original = { name: '현장사진.zip', size: 22 * BYTES_PER_MB }
|
||||
|
||||
expect(precheckAttachment(original, FORWARDED, NONE)).toBeNull()
|
||||
expect(precheckAttachment(original, UPLOAD, NONE)).toBe('FILE_002')
|
||||
expect(precheckAttachment(original, FORWARDED, bytes(4 * BYTES_PER_MB))).toBe('FILE_002')
|
||||
})
|
||||
|
||||
it('개수 — 20개째까지는 통과하고 21개째부터 MAIL_006 (계약 2절)', () => {
|
||||
const file = { name: '회의록.pdf', size: 10 }
|
||||
|
||||
expect(precheckAttachment(file, UPLOAD, { count: MAIL_MAX_ATTACHMENTS - 1, bytes: 0 })).toBeNull()
|
||||
expect(precheckAttachment(file, UPLOAD, { count: MAIL_MAX_ATTACHMENTS, bytes: 0 })).toBe('MAIL_006')
|
||||
expect(precheckAttachment(file, FORWARDED, { count: MAIL_MAX_ATTACHMENTS, bytes: 0 })).toBe(
|
||||
'MAIL_006',
|
||||
)
|
||||
})
|
||||
|
||||
it('개수를 형식·크기보다 먼저 본다 — 서버처럼, 허용 밖 형식이어도 한도를 넘으면 MAIL_006', () => {
|
||||
const full: AttachedSummary = { count: MAIL_MAX_ATTACHMENTS, bytes: TOTAL }
|
||||
|
||||
expect(precheckAttachment({ name: '설치.exe', size: 10 }, UPLOAD, full)).toBe('MAIL_006')
|
||||
expect(precheckAttachment({ name: '큰.zip', size: LIMIT + 1 }, UPLOAD, full)).toBe('MAIL_006')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedAttachmentName · extensionOf', () => {
|
||||
it('마지막 점 뒤를 소문자 확장자로 본다', () => {
|
||||
expect(extensionOf('보고서.final.HWPX')).toBe('hwpx')
|
||||
expect(isAllowedAttachmentName('보고서.final.HWPX')).toBe(true)
|
||||
expect(isAllowedAttachmentName('사진.svg')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachmentHint', () => {
|
||||
it('파일당 · 합계 · 개수 순서로 한도를 넣는다', () => {
|
||||
expect(attachmentHint(20 * BYTES_PER_MB, 25 * BYTES_PER_MB, MAIL_MAX_ATTACHMENTS)).toBe(
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일',
|
||||
)
|
||||
expect(attachmentHint(10 * BYTES_PER_MB, 30 * BYTES_PER_MB, 5)).toBe(
|
||||
'파일당 최대 10MB · 합계 30MB · 최대 5개 · 문서, 이미지, 압축 파일',
|
||||
)
|
||||
})
|
||||
|
||||
it('MB 표기는 format.ts 규칙을 따른다 (정수가 아니면 소수 한 자리)', () => {
|
||||
expect(attachmentHint(2.5 * BYTES_PER_MB, 7.25 * BYTES_PER_MB, MAIL_MAX_ATTACHMENTS)).toBe(
|
||||
'파일당 최대 2.5MB · 합계 7.3MB · 최대 20개 · 문서, 이미지, 압축 파일',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// 첨부 사전 검사·안내 — 순수 함수만 둔다
|
||||
// 서버 검사(확장자 + MIME + 크기)를 미리 흉내 내 큰 파일을 끝까지 올리는 낭비를 줄인다.
|
||||
// 최종 판단은 서버다 — 통과해도 서버가 FILE_001 · FILE_002 로 거절할 수 있다.
|
||||
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_ATTACHMENT_EXTENSIONS, MAIL_ATTACHMENT_KINDS_LABEL } from '@/shared/constants/mail'
|
||||
import { formatMegabytes } from '@/shared/utils/format'
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
/** 허용 형식(확장자)인지 — 계약 2절 "첨부 업로드 한도" 의 허용 목록 */
|
||||
export function isAllowedAttachmentName(name: string): boolean {
|
||||
return MAIL_ATTACHMENT_EXTENSIONS.includes(extensionOf(name))
|
||||
}
|
||||
|
||||
/** 첨부 한도 — 크기는 서버가 알려 준 값(GET /mail-quota), 개수는 계약 상수(MAIL_MAX_ATTACHMENTS)다 */
|
||||
export interface AttachmentLimits {
|
||||
/**
|
||||
* 파일당 한도(attachmentMaxBytes) — 새로 올리는 파일에만 있다.
|
||||
* 전달하는 원본 첨부는 서버가 확장자와 합계만 보므로 null 로 건너뛴다 (계약 2절 "전달")
|
||||
*/
|
||||
maxBytes: number | null
|
||||
/** 메일 한 통의 첨부 합계 한도(attachmentTotalMaxBytes) */
|
||||
totalMaxBytes: number
|
||||
/** 메일 한 통의 첨부 개수 한도 */
|
||||
maxCount: number
|
||||
}
|
||||
|
||||
/** 이미 붙어 있는 첨부(올리는 중·올린 것·전달하는 원본) — 실패한 첨부는 보내지 않으므로 세지 않는다 */
|
||||
export interface AttachedSummary {
|
||||
count: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부를 붙이기 전 사전 검사 — 걸리면 그 첨부는 붙이지 않는다. 서버와 같은 순서로 본다.
|
||||
* 1) 개수(MAIL_006) — 서버는 개수를 형식보다 먼저 보므로, 허용 밖 형식이어도 한도를 넘으면 MAIL_006 이다
|
||||
* 2) 형식(FILE_001) 3) 파일당 크기 4) 합계 크기(둘 다 FILE_002 — 서버도 합계 초과를 413 FILE_002 로 거절한다)
|
||||
* 같은 첨부를 두 번 붙였으면 개수·크기 모두 두 번 센다 (서버가 발송 때 그렇게 센다).
|
||||
*/
|
||||
export function precheckAttachment(
|
||||
file: Pick<File, 'name' | 'size'>,
|
||||
limits: AttachmentLimits,
|
||||
attached: AttachedSummary,
|
||||
): ErrorCode | null {
|
||||
if (attached.count + 1 > limits.maxCount) return 'MAIL_006'
|
||||
if (!isAllowedAttachmentName(file.name)) return 'FILE_001'
|
||||
if (limits.maxBytes !== null && file.size > limits.maxBytes) return 'FILE_002'
|
||||
if (attached.bytes + file.size > limits.totalMaxBytes) return 'FILE_002'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 안내 문구 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일".
|
||||
* 크기 한도는 서버 값(GET /mail-quota)을, 개수는 계약 상수를 넘긴다 — 붙이기 전에 한도가 보이게 한다.
|
||||
*/
|
||||
export function attachmentHint(maxBytes: number, totalMaxBytes: number, maxCount: number): string {
|
||||
return [
|
||||
`파일당 최대 ${formatMegabytes(maxBytes)}`,
|
||||
`합계 ${formatMegabytes(totalMaxBytes)}`,
|
||||
`최대 ${maxCount}개`,
|
||||
MAIL_ATTACHMENT_KINDS_LABEL,
|
||||
].join(' · ')
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { fitDraftBody, fitDraftSubject, truncateText } from './mail-draft'
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('한도 안이면 그대로, 넘으면 앞에서부터 한도까지 남긴다', () => {
|
||||
expect(truncateText('가나다', 3)).toBe('가나다')
|
||||
expect(truncateText('가나다라', 3)).toBe('가나다')
|
||||
})
|
||||
|
||||
it('이모지(서로게이트 쌍) 가운데를 자르지 않는다 — 그 글자를 통째로 뺀다', () => {
|
||||
const text = `ab${'😀'}cd` // 😀 는 길이 2
|
||||
expect(truncateText(text, 3)).toBe('ab')
|
||||
expect(truncateText(text, 4)).toBe(`ab${'😀'}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitDraftSubject — 제목 255자 (계약 2절)', () => {
|
||||
it('255자면 그대로, 256자면 255자로 자른다 ("Re: " + 252자)', () => {
|
||||
const exact = `Re: ${'가'.repeat(MAIL_SUBJECT_MAX_LENGTH - 4)}`
|
||||
const over = `Re: ${'가'.repeat(252)}`
|
||||
|
||||
expect(exact).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
expect(fitDraftSubject(exact)).toBe(exact)
|
||||
expect(over).toHaveLength(MAIL_SUBJECT_MAX_LENGTH + 1)
|
||||
expect(fitDraftSubject(over)).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
expect(fitDraftSubject(over).startsWith('Re: ')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitDraftBody — 본문 100,000자 (계약 2절)', () => {
|
||||
const head = '\n\n──────────\n2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:\n'
|
||||
|
||||
it('100,000자면 그대로 둔다', () => {
|
||||
const exact = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length)
|
||||
|
||||
expect(fitDraftBody(exact)).toBe(exact)
|
||||
})
|
||||
|
||||
it('100,001자면 뒤쪽 원문을 줄이고 끝에 생략 표시를 둔다 — 앞쪽(쓸 자리·구분선·원문 머리)은 그대로', () => {
|
||||
const over = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length + 1)
|
||||
|
||||
const fitted = fitDraftBody(over)
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(fitted.startsWith(head)).toBe(true)
|
||||
expect(fitted.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('답장은 생략 표시도 인용 줄 머리("> ")를 붙여 인용 안에 둔다', () => {
|
||||
const over = head + '> 원문 줄\n'.repeat(20_000)
|
||||
|
||||
const fitted = fitDraftBody(over, '> ')
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(fitted.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('아주 긴 원문(1MB 넘게)도 한도 안으로 줄인다 — 앞단 프록시의 413 으로 가지 않게', () => {
|
||||
const huge = head + '가'.repeat(1_100_000)
|
||||
|
||||
expect(fitDraftBody(huge).length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// 답장·전달 초안을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
|
||||
// 서버는 제목 255자 · 본문 100,000자를 넘으면 VAL_001 로 거절한다. 본문이 1MB 를 넘으면 앞단 프록시가
|
||||
// 413(FILE_002)으로 끊어 첨부 문제처럼 보이므로, 초안을 만들 때 미리 맞춘다.
|
||||
// 길이는 JS 문자열 길이(UTF-16 단위)로 센다 — 서버(글자 수)보다 같거나 길게 세므로 이 한도 안이면 서버도 통과한다.
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
|
||||
/** UTF-16 서로게이트 쌍의 앞쪽 범위 — 여기서 자르면 이모지 등이 깨진다 */
|
||||
const HIGH_SURROGATE_START = 0xd800
|
||||
const HIGH_SURROGATE_END = 0xdbff
|
||||
|
||||
/** 앞에서부터 maxLength 까지 남긴다 — 서로게이트 쌍(이모지 등) 가운데는 자르지 않는다 */
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
let end = Math.max(0, maxLength)
|
||||
const last = text.charCodeAt(end - 1)
|
||||
if (last >= HIGH_SURROGATE_START && last <= HIGH_SURROGATE_END) end -= 1
|
||||
return text.slice(0, end)
|
||||
}
|
||||
|
||||
/** 제목 — 255자를 넘으면 자른다 ("Re: " · "Fwd: " 를 붙여 넘친 경우) */
|
||||
export function fitDraftSubject(subject: string): string {
|
||||
return truncateText(subject, MAIL_SUBJECT_MAX_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 — 100,000자를 넘으면 뒤쪽(인용한 원문)을 줄이고 끝에 생략 표시를 둔다. 앞쪽(내가 쓸 자리 · 구분선 · 원문 머리)은 남는다.
|
||||
* linePrefix: 답장은 인용 줄 머리("> ")를 붙여 생략 표시도 인용 안에 둔다.
|
||||
*/
|
||||
export function fitDraftBody(body: string, linePrefix = ''): string {
|
||||
if (body.length <= MAIL_BODY_MAX_LENGTH) return body
|
||||
const mark = `\n${linePrefix}${MAIL_QUOTE_OMITTED_MARK}`
|
||||
return `${truncateText(body, MAIL_BODY_MAX_LENGTH - mark.length).trimEnd()}${mark}`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { buildForwardBody, buildForwardDraft, buildForwardSubject } from './mail-forward'
|
||||
|
||||
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
|
||||
function message(partial: Partial<MessageDetail> = {}): MessageDetail {
|
||||
return {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
size: 1024,
|
||||
cc: [{ name: '인프라팀', email: 'infra@arasystem.it.kr' }],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: '2026-10-26T01:41:00Z',
|
||||
messageId: '<m1@arasystem.it.kr>',
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '김아라님, 안녕하세요.\n\n금요일 저녁에 전환하겠습니다.',
|
||||
hasBlockedImages: false,
|
||||
attachments: [ATTACHMENT],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildForwardSubject', () => {
|
||||
it('"Fwd: " 를 붙인다', () => {
|
||||
expect(buildForwardSubject('메일 서버 이전 일정 공유')).toBe('Fwd: 메일 서버 이전 일정 공유')
|
||||
})
|
||||
|
||||
it.each(['Fwd: 일정', 'FW: 일정', 'fwd : 일정', '전달: 일정'])('이미 전달 접두어가 있으면 그대로 (%s)', (subject) => {
|
||||
expect(buildForwardSubject(subject)).toBe(subject)
|
||||
})
|
||||
|
||||
it('제목이 없어도 접두어만 남는다', () => {
|
||||
expect(buildForwardSubject('')).toBe('Fwd:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardBody', () => {
|
||||
it('구분선 + 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) + 원문 그대로', () => {
|
||||
expect(buildForwardBody(message(), 'Asia/Seoul')).toBe(
|
||||
[
|
||||
'',
|
||||
'',
|
||||
'──────────',
|
||||
'전달된 메일',
|
||||
'보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>',
|
||||
'날짜: 2026. 10. 26. (월) 오전 10:41',
|
||||
'제목: 메일 서버 이전 일정 공유',
|
||||
'받는 사람: 김아라 <ara.kim@arasystem.it.kr>',
|
||||
'참조: 인프라팀 <infra@arasystem.it.kr>',
|
||||
'',
|
||||
'김아라님, 안녕하세요.',
|
||||
'',
|
||||
'금요일 저녁에 전환하겠습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
it('참조가 없으면 참조 줄을 빼고, 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다', () => {
|
||||
const body = buildForwardBody(message({ cc: [], textBody: null, htmlBody: '<p>첫 줄<br>둘째 줄</p>' }), 'Asia/Seoul')
|
||||
|
||||
expect(body).not.toContain('참조:')
|
||||
expect(body.endsWith('\n\n첫 줄\n둘째 줄')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardDraft', () => {
|
||||
it('원본 첨부를 그대로 넘긴다 (발송 때 id 를 다시 보낸다)', () => {
|
||||
const draft = buildForwardDraft(message(), 'Asia/Seoul')
|
||||
|
||||
expect(draft.subject).toBe('Fwd: 메일 서버 이전 일정 공유')
|
||||
expect(draft.attachments).toEqual([ATTACHMENT])
|
||||
expect(draft.body).toContain('전달된 메일')
|
||||
})
|
||||
|
||||
it('계약 한도에 맞춘다 — 원래 제목 250자면 "Fwd: " 를 붙여 255자, 251자면 255자에서 자른다', () => {
|
||||
const fits = buildForwardDraft(message({ subject: '가'.repeat(250) }))
|
||||
const over = buildForwardDraft(message({ subject: '가'.repeat(251) }))
|
||||
|
||||
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
|
||||
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
})
|
||||
|
||||
it('원문이 본문 한도(100,000자)를 넘으면 원문을 줄이고 끝에 생략 표시를 둔다 — 원문 머리는 남는다', () => {
|
||||
const draft = buildForwardDraft(message({ textBody: '가'.repeat(150_000) }))
|
||||
|
||||
expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(draft.body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(draft.body.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
// 전달 초안 만들기 — 순수 함수만 둔다 (계약 2절 "전달")
|
||||
// 제목 "Fwd:" 와 원문 본문은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
|
||||
|
||||
import { MAIL_FORWARD_HEADING, MAIL_FORWARD_PREFIX, MAIL_QUOTE_SEPARATOR } from '@/shared/constants/mail'
|
||||
import type { Attachment, MessageDetail } from '@/shared/types/mail'
|
||||
import { formatMailDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
|
||||
import { messageBodyText } from '@/shared/utils/mail-html'
|
||||
|
||||
export interface ForwardDraft {
|
||||
subject: string
|
||||
body: string
|
||||
/** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
|
||||
attachments: Attachment[]
|
||||
}
|
||||
|
||||
/** 이미 전달 접두어가 있으면 그대로 둔다 ("Fwd:" · "FW:" · "전달:") */
|
||||
const FORWARD_PREFIX_PATTERN = /^\s*(?:fwd?|전달)\s*:/i
|
||||
|
||||
export function buildForwardSubject(subject: string): string {
|
||||
const trimmed = subject.trim()
|
||||
if (FORWARD_PREFIX_PATTERN.test(trimmed)) return trimmed
|
||||
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
|
||||
}
|
||||
|
||||
type ForwardSource = Pick<
|
||||
MessageDetail,
|
||||
'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'
|
||||
>
|
||||
|
||||
/**
|
||||
* 전달 본문 — 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) 뒤에 원문을 그대로 붙인다.
|
||||
*
|
||||
* (빈 줄 — 여기에 전달하는 말을 쓴다)
|
||||
*
|
||||
* ──────────
|
||||
* 전달된 메일
|
||||
* 보낸 사람: 이도윤 <doyun.lee@…>
|
||||
* 날짜: 2026. 10. 26. (월) 오전 10:41
|
||||
* 제목: 메일 서버 이전 일정 공유
|
||||
* 받는 사람: 김아라 <ara.kim@…>
|
||||
*
|
||||
* 원문 …
|
||||
*/
|
||||
export function buildForwardBody(message: ForwardSource, timeZone?: string): string {
|
||||
const header = [
|
||||
MAIL_FORWARD_HEADING,
|
||||
message.from ? `보낸 사람: ${formatAddress(message.from)}` : null,
|
||||
`날짜: ${formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)}`,
|
||||
`제목: ${message.subject || '(제목 없음)'}`,
|
||||
message.to.length > 0 ? `받는 사람: ${formatAddressList(message.to)}` : null,
|
||||
message.cc.length > 0 ? `참조: ${formatAddressList(message.cc)}` : null,
|
||||
].filter((line): line is string => line !== null)
|
||||
|
||||
return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header.join('\n')}\n\n${messageBodyText(message)}`
|
||||
}
|
||||
|
||||
/** 전달 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 원문을 줄이고 끝에 생략 표시를 둔다 */
|
||||
export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
|
||||
return {
|
||||
subject: fitDraftSubject(buildForwardSubject(message.subject)),
|
||||
body: fitDraftBody(buildForwardBody(message, timeZone)),
|
||||
attachments: [...message.attachments],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, htmlToText, sanitizeMailHtml } from './mail-html'
|
||||
|
||||
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
|
||||
describe('sanitizeMailHtml', () => {
|
||||
it('스크립트·이벤트 핸들러를 지운다', () => {
|
||||
const html = sanitizeMailHtml(
|
||||
'<p onclick="steal()">안녕하세요</p><script>alert(1)</script><img src=x onerror="alert(2)">',
|
||||
)
|
||||
|
||||
expect(html).toContain('안녕하세요')
|
||||
expect(html).not.toMatch(/<script|onclick|onerror|alert/i)
|
||||
})
|
||||
|
||||
it('스타일 태그·폼·입력·iframe·object·embed·svg 를 지운다', () => {
|
||||
const html = sanitizeMailHtml(
|
||||
[
|
||||
'<style>body{display:none}</style>',
|
||||
'<form action="https://evil.example"><input name="pw"><button>보내기</button></form>',
|
||||
'<iframe src="https://evil.example"></iframe>',
|
||||
'<object data="x.swf"></object><embed src="x.swf">',
|
||||
'<svg><circle r="1" /></svg>',
|
||||
'<p>본문</p>',
|
||||
].join(''),
|
||||
)
|
||||
|
||||
// 요소는 사라지고, 버튼 문구 같은 글자만 평범한 텍스트로 남는다 (style·svg·iframe 은 내용까지 버린다)
|
||||
expect(html).not.toMatch(/<(style|form|input|button|iframe|object|embed|svg|circle)\b/i)
|
||||
expect(html).not.toContain('display:none')
|
||||
expect(html).not.toContain('evil.example')
|
||||
expect(html).toContain('<p>본문</p>')
|
||||
})
|
||||
|
||||
it('javascript: · data: · 상대 경로 링크는 주소를 지운다', () => {
|
||||
const html = sanitizeMailHtml(
|
||||
[
|
||||
'<a href="javascript:alert(1)">a</a>',
|
||||
'<a href="data:text/html,<script>alert(1)</script>">b</a>',
|
||||
'<a href="/api/auth/logout">c</a>',
|
||||
'<a href="#top">d</a>',
|
||||
].join(''),
|
||||
)
|
||||
|
||||
expect(html).not.toContain('href')
|
||||
})
|
||||
|
||||
it('http·https·mailto 링크는 남기고 새 창 + opener·referrer 차단을 붙인다', () => {
|
||||
const html = sanitizeMailHtml('<a href="https://arasystem.it.kr/notice">공지</a>')
|
||||
const link = new DOMParser().parseFromString(html, 'text/html').querySelector('a')
|
||||
|
||||
expect(link?.getAttribute('href')).toBe('https://arasystem.it.kr/notice')
|
||||
expect(link?.getAttribute('target')).toBe('_blank')
|
||||
expect(link?.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(sanitizeMailHtml('<a href="mailto:hr@arasystem.it.kr">인사팀</a>')).toContain(
|
||||
'href="mailto:hr@arasystem.it.kr"',
|
||||
)
|
||||
})
|
||||
|
||||
it('외부 이미지 주소는 지우고 data: 이미지는 남긴다', () => {
|
||||
const tracker = sanitizeMailHtml('<img src="https://tracker.example/pixel.gif" alt="">')
|
||||
const inline = sanitizeMailHtml('<img src="data:image/png;base64,iVBORw0KGgo=" alt="로고">')
|
||||
|
||||
expect(tracker).not.toContain('tracker.example')
|
||||
expect(inline).toContain('src="data:image/png;base64,iVBORw0KGgo="')
|
||||
})
|
||||
|
||||
it('srcset·background 처럼 외부 자원을 부르는 속성을 지운다', () => {
|
||||
const html = sanitizeMailHtml(
|
||||
'<table background="https://evil.example/bg.png"><tr><td><img srcset="https://evil.example/a.png 2x"></td></tr></table>',
|
||||
)
|
||||
|
||||
expect(html).not.toContain('evil.example')
|
||||
})
|
||||
|
||||
it('메일 레이아웃에 쓰는 일반 속성(width·align·style 등)은 지우지 않는다', () => {
|
||||
const html = sanitizeMailHtml(
|
||||
'<table width="600" align="center" bgcolor="#ffffff" style="border:1px solid #eee"><tr><td colspan="2">표</td></tr></table>',
|
||||
)
|
||||
|
||||
expect(html).toContain('width="600"')
|
||||
expect(html).toContain('align="center"')
|
||||
expect(html).toContain('bgcolor="#ffffff"')
|
||||
expect(html).toContain('style="border:1px solid #eee"')
|
||||
expect(html).toContain('colspan="2"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMailDocument', () => {
|
||||
it('문서 안에 CSP 와 <base target="_blank"> 를 넣는다', () => {
|
||||
const doc = buildMailDocument({ htmlBody: '<p>안녕하세요</p>', textBody: null })
|
||||
const parsed = new DOMParser().parseFromString(doc, 'text/html')
|
||||
|
||||
expect(MAIL_FRAME_CSP).toBe("default-src 'none'; img-src data:; style-src 'unsafe-inline'")
|
||||
expect(
|
||||
parsed.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content'),
|
||||
).toBe(MAIL_FRAME_CSP)
|
||||
expect(parsed.querySelector('base')?.getAttribute('target')).toBe('_blank')
|
||||
expect(parsed.body.innerHTML).toBe('<p>안녕하세요</p>')
|
||||
})
|
||||
|
||||
it('CSP 는 본문보다 먼저 나온다 (뒤에 오면 앞선 내용에 적용되지 않는다)', () => {
|
||||
const doc = buildMailDocument({ htmlBody: '<p>본문</p>', textBody: null })
|
||||
|
||||
expect(doc.indexOf('Content-Security-Policy')).toBeLessThan(doc.indexOf('<body>'))
|
||||
})
|
||||
|
||||
it('HTML 본문도 한 번 더 정화한 뒤 넣는다', () => {
|
||||
const doc = buildMailDocument({ htmlBody: '<p>본문</p><script>alert(1)</script>', textBody: null })
|
||||
|
||||
expect(doc).not.toContain('alert(1)')
|
||||
})
|
||||
|
||||
it('HTML 이 없으면 텍스트를 이스케이프해서 넣는다', () => {
|
||||
const doc = buildMailDocument({ htmlBody: null, textBody: '<script>alert(1)</script>\n두 번째 줄' })
|
||||
const parsed = new DOMParser().parseFromString(doc, 'text/html')
|
||||
|
||||
expect(parsed.querySelector('script')).toBeNull()
|
||||
expect(parsed.querySelector('.mail-text')?.textContent).toBe('<script>alert(1)</script>\n두 번째 줄')
|
||||
})
|
||||
|
||||
it('빈 HTML 은 텍스트로 대신 보인다', () => {
|
||||
const doc = buildMailDocument({ htmlBody: ' ', textBody: '텍스트 본문' })
|
||||
|
||||
expect(doc).toContain('<div class="mail-text">텍스트 본문</div>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('특수 문자를 모두 바꾼다', () => {
|
||||
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('<a href="x">'&'</a>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('htmlToText (답장 인용용)', () => {
|
||||
it('문단·줄바꿈을 살려 텍스트로 바꾼다', () => {
|
||||
expect(htmlToText('<p>김아라님,</p>\n <p>안녕하세요.<br>확인 부탁드립니다.</p>')).toBe(
|
||||
'김아라님,\n\n안녕하세요.\n확인 부탁드립니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('스크립트 내용은 텍스트로 새지 않는다', () => {
|
||||
expect(htmlToText('<p>본문</p><script>alert(1)</script>')).toBe('본문')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
// 메일 본문 → 격리된 iframe(srcdoc) 문서 — 보안 핵심
|
||||
//
|
||||
// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다.
|
||||
// 1) 서버가 정화한 htmlBody (docs/mail/api-contract.md 2절 "htmlBody 정화 규칙")
|
||||
// 2) 여기서 DOMPurify 로 한 번 더 정화 — 서버 정화에 빈틈이 있어도 스크립트·폼·외부 자원을 걷어 낸다
|
||||
// 3) MailBodyFrame 의 sandbox(스크립트·폼 불허) + 이 문서 안의 CSP(외부 자원 전면 차단)
|
||||
//
|
||||
// DOMPurify 는 DOM(window)에 의존하지만 입력이 같으면 결과가 같다 — 부수효과 없는 변환으로 취급한다.
|
||||
|
||||
import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
|
||||
|
||||
/**
|
||||
* iframe 문서 안에만 적용하는 CSP.
|
||||
* 스크립트·글꼴·네트워크 요청을 전부 막고 data: 이미지(본문 안 인라인 이미지)와 인라인 스타일만 허용한다.
|
||||
* (부모 화면의 CSP 도 srcdoc 문서에 상속되어 함께 적용된다 — 둘 다 만족해야 한다)
|
||||
*/
|
||||
export const MAIL_FRAME_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'"
|
||||
|
||||
/** 링크로 남기는 주소 — 계약과 같다 (http · https · mailto). 상대 경로·javascript: 등은 지운다 */
|
||||
const SAFE_LINK_PATTERN = /^(?:https?|mailto):/i
|
||||
/** 이미지는 서버가 넣어 준 data: 이미지만 남긴다 (외부 이미지는 추적 픽셀이 될 수 있다) */
|
||||
const DATA_IMAGE_PATTERN = /^data:image\//i
|
||||
|
||||
/** 서버 제거 목록(스크립트·스타일·폼·iframe·object·embed·svg)에 입력·미디어 요소를 더했다 */
|
||||
const FORBIDDEN_TAGS = [
|
||||
'style',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'select',
|
||||
'option',
|
||||
'optgroup',
|
||||
'textarea',
|
||||
'datalist',
|
||||
'output',
|
||||
'template',
|
||||
'slot',
|
||||
'dialog',
|
||||
'audio',
|
||||
'video',
|
||||
'source',
|
||||
'track',
|
||||
'picture',
|
||||
'iframe',
|
||||
'frame',
|
||||
'frameset',
|
||||
'object',
|
||||
'embed',
|
||||
'link',
|
||||
'meta',
|
||||
'base',
|
||||
]
|
||||
/** 외부 자원을 부르거나 폼 동작을 바꾸는 속성 */
|
||||
const FORBIDDEN_ATTRS = ['srcset', 'background', 'action', 'formaction', 'ping', 'poster']
|
||||
|
||||
const SANITIZE_CONFIG: Config = {
|
||||
// HTML 만 — SVG·MathML 은 받지 않는다 (서버도 svg 를 지운다)
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: FORBIDDEN_TAGS,
|
||||
FORBID_ATTR: FORBIDDEN_ATTRS,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// DOMPurify 는 이 정규식을 width·align 같은 일반 속성 값에도 적용한다.
|
||||
// 그래서 기본 정규식의 구조(스킴이 아닌 값 허용)는 유지하고 낯선 스킴(tel·sms·cid 등)만 뺐다.
|
||||
// 링크·이미지 주소는 아래 훅에서 한 번 더 좁힌다.
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,
|
||||
}
|
||||
|
||||
function createPurifier(): Purifier {
|
||||
// 이 모듈 전용 인스턴스 — 아래 훅이 다른 곳의 DOMPurify 사용에 번지지 않게 한다
|
||||
const purifier = DOMPurify(window)
|
||||
purifier.addHook('afterSanitizeAttributes', (node) => {
|
||||
const tag = node.tagName
|
||||
if (tag === 'A' || tag === 'AREA') {
|
||||
const href = node.getAttribute('href')?.trim() ?? ''
|
||||
if (href && SAFE_LINK_PATTERN.test(href)) {
|
||||
// 링크는 항상 새 창 — 원래 창(우리 앱)에 접근하지 못하게 opener·referrer 를 끊는다
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'noopener noreferrer')
|
||||
} else {
|
||||
// 상대 경로는 iframe 기준 주소(우리 앱 출처)로 풀리므로 남기지 않는다
|
||||
node.removeAttribute('href')
|
||||
}
|
||||
}
|
||||
if (tag === 'IMG') {
|
||||
const src = node.getAttribute('src')?.trim() ?? ''
|
||||
if (!DATA_IMAGE_PATTERN.test(src)) node.removeAttribute('src')
|
||||
}
|
||||
})
|
||||
return purifier
|
||||
}
|
||||
|
||||
let purifier: Purifier | null = null
|
||||
|
||||
/** 메일 HTML 정화 — 결과는 <body> 안에 넣을 조각 */
|
||||
export function sanitizeMailHtml(html: string): string {
|
||||
purifier ??= createPurifier()
|
||||
return purifier.sanitize(html, SANITIZE_CONFIG)
|
||||
}
|
||||
|
||||
/** HTML 특수 문자 이스케이프 — 텍스트 본문을 문서에 넣을 때 쓴다 */
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe 안의 기본 모양. 부모 화면의 CSS·변수는 iframe 문서로 들어가지 않으므로
|
||||
* tokens.css 의 값(본문 글자색·강조색·인용선)을 여기 따로 적는다 — tokens.css 를 바꾸면 함께 바꾼다.
|
||||
* 글꼴은 CSP 로 내려받을 수 없어 설치된 글꼴 목록만 쓴다.
|
||||
*/
|
||||
const MAIL_FRAME_STYLE = [
|
||||
// 높이는 부모(MailBodyFrame)가 내용에 맞춰 늘리므로 세로 스크롤은 숨기고, 넓은 표만 가로로 넘긴다
|
||||
'html{overflow-x:auto;overflow-y:hidden}',
|
||||
"body{margin:0;display:flow-root;font-family:'Pretendard Variable',Pretendard,-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo','Malgun Gothic',system-ui,sans-serif;font-size:0.875rem;line-height:1.75;color:#1F2733;overflow-wrap:anywhere}",
|
||||
'img{max-width:100%;height:auto}',
|
||||
// 주소를 지운 외부 이미지는 깨진 그림 대신 아예 숨긴다 (차단 사실은 본문 위 안내가 알린다)
|
||||
'img:not([src]){display:none}',
|
||||
'a{color:#0E7C86}',
|
||||
'pre{white-space:pre-wrap}',
|
||||
'blockquote{margin:0 0 0 0.25rem;padding-left:0.75rem;border-left:0.125rem solid #98A2B3;color:#4A5363}',
|
||||
'.mail-text{white-space:pre-wrap}',
|
||||
].join('')
|
||||
|
||||
export interface MailBodySource {
|
||||
htmlBody: string | null
|
||||
textBody: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 → iframe srcdoc 문서.
|
||||
* HTML 이 있으면 정화해서, 없으면 텍스트를 이스케이프해서 넣는다.
|
||||
* <base target="_blank"> 로 남은 링크도 모두 새 창으로 연다.
|
||||
*/
|
||||
export function buildMailDocument(source: MailBodySource): string {
|
||||
const html = source.htmlBody?.trim()
|
||||
const content = html
|
||||
? sanitizeMailHtml(html)
|
||||
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html lang="ko"><head>',
|
||||
'<meta charset="utf-8">',
|
||||
`<meta http-equiv="Content-Security-Policy" content="${MAIL_FRAME_CSP}">`,
|
||||
'<meta name="referrer" content="no-referrer">',
|
||||
'<base target="_blank">',
|
||||
`<style>${MAIL_FRAME_STYLE}</style>`,
|
||||
'</head>',
|
||||
`<body>${content}</body>`,
|
||||
'</html>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'P',
|
||||
'DIV',
|
||||
'LI',
|
||||
'TR',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'BLOCKQUOTE',
|
||||
'PRE',
|
||||
'TABLE',
|
||||
'UL',
|
||||
'OL',
|
||||
'SECTION',
|
||||
'ARTICLE',
|
||||
'HEADER',
|
||||
'FOOTER',
|
||||
])
|
||||
|
||||
/**
|
||||
* 답장 인용용 — HTML 본문을 일반 텍스트로 바꾼다.
|
||||
* 정화한 뒤 DOMParser 로 읽는다 (DOMParser 문서는 스크립트를 실행하지 않고 이미지도 내려받지 않는다).
|
||||
*/
|
||||
export function htmlToText(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(sanitizeMailHtml(html), 'text/html')
|
||||
const chunks: string[] = []
|
||||
|
||||
const walk = (node: Node, preformatted: boolean) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue ?? ''
|
||||
chunks.push(preformatted ? text : text.replace(/\s+/g, ' '))
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
|
||||
const element = node as Element
|
||||
if (element.tagName === 'BR') {
|
||||
chunks.push('\n')
|
||||
return
|
||||
}
|
||||
const isBlock = BLOCK_TAGS.has(element.tagName)
|
||||
if (isBlock) chunks.push('\n')
|
||||
const childPreformatted = preformatted || element.tagName === 'PRE'
|
||||
element.childNodes.forEach((child) => walk(child, childPreformatted))
|
||||
if (isBlock) chunks.push('\n')
|
||||
}
|
||||
walk(doc.body, false)
|
||||
|
||||
return chunks
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** 답장·전달에 넣을 원문 텍스트 — 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다 */
|
||||
export function messageBodyText(source: MailBodySource): string {
|
||||
if (source.textBody !== null) return source.textBody.replace(/\r\n?/g, '\n')
|
||||
return source.htmlBody ? htmlToText(source.htmlBody) : ''
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import {
|
||||
buildQuotedBody,
|
||||
buildReplyDraft,
|
||||
buildReplyRecipients,
|
||||
buildReplySubject,
|
||||
} from './mail-reply'
|
||||
|
||||
const ME = 'ara.kim@arasystem.it.kr'
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }
|
||||
const INFRA = { name: '인프라팀', email: 'infra@arasystem.it.kr' }
|
||||
const JUNHO = { name: '박준호', email: 'junho.park@arasystem.it.kr' }
|
||||
|
||||
function message(partial: Partial<MessageDetail> = {}): MessageDetail {
|
||||
return {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: DOYUN,
|
||||
to: [{ name: '김아라', email: ME }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1024,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: '<m1@arasystem.it.kr>',
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '김아라님, 안녕하세요.\n\n금요일 저녁에 전환하겠습니다.',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildReplySubject', () => {
|
||||
it('"Re: " 를 붙인다', () => {
|
||||
expect(buildReplySubject('메일 서버 이전 일정 공유')).toBe('Re: 메일 서버 이전 일정 공유')
|
||||
})
|
||||
|
||||
it.each(['Re: 일정', 'RE: 일정', 're : 일정', '회신: 일정'])('이미 회신 접두어가 있으면 그대로 (%s)', (subject) => {
|
||||
expect(buildReplySubject(subject)).toBe(subject)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildReplyRecipients', () => {
|
||||
it('답장 — 보낸 사람에게', () => {
|
||||
expect(buildReplyRecipients(message(), ME, 'reply')).toEqual({ to: [DOYUN], cc: [] })
|
||||
})
|
||||
|
||||
it('답장 — Reply-To 가 있으면 그 주소로', () => {
|
||||
expect(buildReplyRecipients(message({ replyTo: [INFRA] }), ME, 'reply').to).toEqual([INFRA])
|
||||
})
|
||||
|
||||
it('전체 답장 — 보낸 사람 + 원래 받는 사람(나 제외) + 참조', () => {
|
||||
const original = message({
|
||||
to: [{ name: '김아라', email: ME }, JUNHO],
|
||||
cc: [INFRA, { name: '김아라', email: 'ARA.KIM@arasystem.it.kr' }],
|
||||
})
|
||||
|
||||
expect(buildReplyRecipients(original, ME, 'replyAll')).toEqual({
|
||||
to: [DOYUN, JUNHO],
|
||||
cc: [INFRA],
|
||||
})
|
||||
})
|
||||
|
||||
it('내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다', () => {
|
||||
const sent = message({ from: { name: '김아라', email: ME }, to: [JUNHO] })
|
||||
|
||||
expect(buildReplyRecipients(sent, ME, 'reply').to).toEqual([JUNHO])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildQuotedBody', () => {
|
||||
it('구분선 + "날짜, 보낸 사람 작성:" + 인용 (디자인 원본 Compose)', () => {
|
||||
expect(buildQuotedBody(message(), 'Asia/Seoul')).toBe(
|
||||
[
|
||||
'',
|
||||
'',
|
||||
'──────────',
|
||||
'2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:',
|
||||
'> 김아라님, 안녕하세요.',
|
||||
'>',
|
||||
'> 금요일 저녁에 전환하겠습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
it('텍스트 본문이 없으면 HTML 을 텍스트로 바꿔 인용한다', () => {
|
||||
const body = buildQuotedBody(
|
||||
message({ textBody: null, htmlBody: '<p>첫 줄<br>둘째 줄</p>' }),
|
||||
'Asia/Seoul',
|
||||
)
|
||||
|
||||
expect(body.endsWith('> 첫 줄\n> 둘째 줄')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildReplyDraft', () => {
|
||||
it('받는 사람 · Re: 제목 · 인용 본문 · inReplyTo(메일 id) 를 채운다', () => {
|
||||
const draft = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
|
||||
|
||||
expect(draft.to).toEqual([DOYUN])
|
||||
expect(draft.subject).toBe('Re: 메일 서버 이전 일정 공유')
|
||||
expect(draft.body).toContain('> 금요일 저녁에 전환하겠습니다.')
|
||||
expect(draft.inReplyTo).toBe('m1')
|
||||
})
|
||||
|
||||
it('계약 한도에 맞춘다 — 원래 제목 251자면 "Re: " 를 붙여 255자, 252자면 255자에서 자른다', () => {
|
||||
const fits = buildReplyDraft(message({ subject: '가'.repeat(251) }), ME, 'reply')
|
||||
const over = buildReplyDraft(message({ subject: '가'.repeat(252) }), ME, 'reply')
|
||||
|
||||
expect(fits.subject).toBe(`Re: ${'가'.repeat(251)}`)
|
||||
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
})
|
||||
|
||||
it('인용이 본문 한도(100,000자)를 넘으면 인용을 줄이고 인용 안에 생략 표시를 둔다', () => {
|
||||
const longText = '긴 원문 줄입니다.\n'.repeat(20_000)
|
||||
const draft = buildReplyDraft(message({ textBody: longText }), ME, 'reply')
|
||||
|
||||
expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(draft.body.startsWith('\n\n──────────\n')).toBe(true)
|
||||
expect(draft.body.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
// 답장 초안 만들기 — 순수 함수만 둔다 (htmlToText 는 DOMParser 를 쓰지만 결과는 입력에만 의존한다)
|
||||
|
||||
import {
|
||||
MAIL_QUOTE_LINE_PREFIX,
|
||||
MAIL_QUOTE_SEPARATOR,
|
||||
MAIL_REPLY_PREFIX,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { Address, MessageDetail } from '@/shared/types/mail'
|
||||
import { formatMailDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, normalizeEmail, sameAddress, uniqueAddresses } from '@/shared/utils/mail-address'
|
||||
import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
|
||||
import { messageBodyText } from '@/shared/utils/mail-html'
|
||||
|
||||
export type ReplyMode = 'reply' | 'replyAll'
|
||||
|
||||
export interface ReplyDraft {
|
||||
to: Address[]
|
||||
cc: Address[]
|
||||
subject: string
|
||||
body: string
|
||||
/** 발송 본문의 inReplyTo — 답장할 메일의 id */
|
||||
inReplyTo: string
|
||||
}
|
||||
|
||||
/** 이미 회신 접두어가 있으면 그대로 둔다 ("Re:" · "RE :" · "회신:") */
|
||||
const REPLY_PREFIX_PATTERN = /^\s*(?:re|회신)\s*:/i
|
||||
|
||||
export function buildReplySubject(subject: string): string {
|
||||
const trimmed = subject.trim()
|
||||
if (REPLY_PREFIX_PATTERN.test(trimmed)) return trimmed
|
||||
return `${MAIL_REPLY_PREFIX}${trimmed}`.trimEnd()
|
||||
}
|
||||
|
||||
type ReplySource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'replyTo'>
|
||||
|
||||
/**
|
||||
* 답장 받는 사람.
|
||||
* - 답장: Reply-To 가 있으면 그 주소, 없으면 보낸 사람
|
||||
* - 전체 답장: 위 주소 + 원래 받는 사람(받는 사람 칸) + 원래 참조(참조 칸)
|
||||
* 어느 쪽이든 나 자신은 뺀다. 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다.
|
||||
*/
|
||||
export function buildReplyRecipients(
|
||||
message: ReplySource,
|
||||
myEmail: string,
|
||||
mode: ReplyMode,
|
||||
): { to: Address[]; cc: Address[] } {
|
||||
const me = normalizeEmail(myEmail)
|
||||
const notMe = (address: Address) => normalizeEmail(address.email) !== me
|
||||
|
||||
const primary = message.replyTo.length > 0 ? message.replyTo : message.from ? [message.from] : []
|
||||
const others = primary.filter(notMe)
|
||||
// 내가 보낸 메일이면(보낸 사람이 나) 원래 받는 사람에게 답장한다
|
||||
const base = others.length > 0 ? others : message.to.filter(notMe)
|
||||
|
||||
if (mode === 'reply') return { to: uniqueAddresses(base), cc: [] }
|
||||
|
||||
const to = uniqueAddresses([...base, ...message.to]).filter(notMe)
|
||||
const cc = uniqueAddresses(message.cc).filter(
|
||||
(address) => notMe(address) && !to.some((target) => sameAddress(target, address)),
|
||||
)
|
||||
return { to, cc }
|
||||
}
|
||||
|
||||
type QuoteSource = Pick<MessageDetail, 'from' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'>
|
||||
|
||||
/**
|
||||
* 인용 본문 (디자인 원본 Compose):
|
||||
*
|
||||
* (빈 줄 — 여기에 답장을 쓴다)
|
||||
*
|
||||
* ──────────
|
||||
* 2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@…> 작성:
|
||||
* > 원문 첫 줄
|
||||
*/
|
||||
export function buildQuotedBody(message: QuoteSource, timeZone?: string): string {
|
||||
const author = message.from ? formatAddress(message.from) : ''
|
||||
const date = formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)
|
||||
const header = [date, author].filter(Boolean).join(', ')
|
||||
|
||||
const quoted = messageBodyText(message)
|
||||
.split('\n')
|
||||
.map((line) => (line ? `${MAIL_QUOTE_LINE_PREFIX}${line}` : MAIL_QUOTE_LINE_PREFIX.trimEnd()))
|
||||
.join('\n')
|
||||
|
||||
return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header} 작성:\n${quoted}`
|
||||
}
|
||||
|
||||
/** 답장 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 인용을 줄이고 인용 안에 생략 표시를 둔다 */
|
||||
export function buildReplyDraft(
|
||||
message: MessageDetail,
|
||||
myEmail: string,
|
||||
mode: ReplyMode,
|
||||
timeZone?: string,
|
||||
): ReplyDraft {
|
||||
const { to, cc } = buildReplyRecipients(message, myEmail, mode)
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
subject: fitDraftSubject(buildReplySubject(message.subject)),
|
||||
body: fitDraftBody(buildQuotedBody(message, timeZone), MAIL_QUOTE_LINE_PREFIX),
|
||||
inReplyTo: message.id,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { emptyMailboxText, mailboxIcon, orderMailboxes } from './mailbox'
|
||||
|
||||
function mailbox(partial: Partial<Mailbox> & Pick<Mailbox, 'id' | 'name'>): Mailbox {
|
||||
return {
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('orderMailboxes (계약 2절 메일함 정렬 — 확정 디자인 기준)', () => {
|
||||
it('받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외 순서', () => {
|
||||
const list = [
|
||||
mailbox({ id: 'x', name: '프로젝트' }),
|
||||
mailbox({ id: 't', name: '휴지통', role: 'trash' }),
|
||||
mailbox({ id: 's', name: '보낸편지함', role: 'sent' }),
|
||||
mailbox({ id: 'a', name: '보관함', role: 'archive' }),
|
||||
mailbox({ id: 'j', name: '스팸메일함', role: 'junk' }),
|
||||
mailbox({ id: 'd', name: '임시보관함', role: 'drafts' }),
|
||||
mailbox({ id: 'i', name: '받은편지함', role: 'inbox' }),
|
||||
]
|
||||
|
||||
expect(orderMailboxes(list).map((node) => node.mailbox.id)).toEqual([
|
||||
'i',
|
||||
's',
|
||||
'd',
|
||||
'j',
|
||||
't',
|
||||
'a',
|
||||
'x',
|
||||
])
|
||||
})
|
||||
|
||||
it('역할 없는 메일함은 sortOrder, 같으면 이름 순', () => {
|
||||
const list = [
|
||||
mailbox({ id: 'b', name: '나', sortOrder: 1 }),
|
||||
mailbox({ id: 'c', name: '다', sortOrder: 0 }),
|
||||
mailbox({ id: 'a', name: '가', sortOrder: 1 }),
|
||||
]
|
||||
|
||||
expect(orderMailboxes(list).map((node) => node.mailbox.id)).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('하위 메일함은 부모 바로 아래에 한 단계 들여 놓는다', () => {
|
||||
const list = [
|
||||
mailbox({ id: 'child', name: '2026', parentId: 'parent' }),
|
||||
mailbox({ id: 'inbox', name: '받은편지함', role: 'inbox' }),
|
||||
mailbox({ id: 'parent', name: '프로젝트' }),
|
||||
]
|
||||
|
||||
expect(orderMailboxes(list).map((node) => [node.mailbox.id, node.depth])).toEqual([
|
||||
['inbox', 0],
|
||||
['parent', 0],
|
||||
['child', 1],
|
||||
])
|
||||
})
|
||||
|
||||
it('부모가 없거나 순환 참조여도 빠뜨리지 않는다', () => {
|
||||
const list = [
|
||||
mailbox({ id: 'orphan', name: '고아', parentId: 'missing' }),
|
||||
mailbox({ id: 'a', name: '가', parentId: 'b' }),
|
||||
mailbox({ id: 'b', name: '나', parentId: 'a' }),
|
||||
]
|
||||
|
||||
expect(orderMailboxes(list).map((node) => node.mailbox.id).sort()).toEqual(['a', 'b', 'orphan'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('emptyMailboxText', () => {
|
||||
it('메일함 이름과 받침에 맞는 조사로 안내한다', () => {
|
||||
expect(emptyMailboxText({ name: '임시보관함', role: 'drafts' })).toEqual({
|
||||
title: '임시보관함이 비어 있습니다',
|
||||
hint: '임시 저장한 메일이 여기에 표시됩니다.',
|
||||
})
|
||||
expect(emptyMailboxText({ name: '프로젝트', role: null }).title).toBe('프로젝트가 비어 있습니다')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mailboxIcon', () => {
|
||||
it('역할별 아이콘, 역할 없으면 폴더', () => {
|
||||
expect(mailboxIcon({ role: 'sent' })).toBe('send')
|
||||
expect(mailboxIcon({ role: null })).toBe('folder')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
// 메일함 정렬·표시 — 순수 함수만 둔다
|
||||
|
||||
import {
|
||||
CUSTOM_MAILBOX_KEY,
|
||||
MAILBOX_EMPTY_HINTS,
|
||||
MAILBOX_ICONS,
|
||||
MAILBOX_ROLE_ORDER,
|
||||
type MailboxKind,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import type { Mailbox, MailboxRole } from '@/shared/types/mail'
|
||||
import { subjectParticle } from '@/shared/utils/korean'
|
||||
|
||||
/** 메일함 패널에 그릴 한 줄 — depth 는 하위 메일함 들여쓰기 단계 */
|
||||
export interface MailboxNode {
|
||||
mailbox: Mailbox
|
||||
depth: number
|
||||
}
|
||||
|
||||
function roleRank(role: MailboxRole | null): number {
|
||||
if (!role) return MAILBOX_ROLE_ORDER.length
|
||||
const index = MAILBOX_ROLE_ORDER.indexOf(role)
|
||||
return index === -1 ? MAILBOX_ROLE_ORDER.length : index
|
||||
}
|
||||
|
||||
/** 계약 2절 "메일함 정렬": 역할 순서 → sortOrder → 이름 */
|
||||
export function compareMailboxes(a: Mailbox, b: Mailbox): number {
|
||||
const byRole = roleRank(a.role) - roleRank(b.role)
|
||||
if (byRole !== 0) return byRole
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.name.localeCompare(b.name, 'ko')
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모-자식 관계를 지키며 정렬해 한 줄로 편다.
|
||||
* 부모를 찾을 수 없거나 순환 참조인 메일함은 최상위로 올려 빠뜨리지 않는다.
|
||||
*/
|
||||
export function orderMailboxes(list: readonly Mailbox[]): MailboxNode[] {
|
||||
const ids = new Set(list.map((mailbox) => mailbox.id))
|
||||
const childrenOf = new Map<string | null, Mailbox[]>()
|
||||
for (const mailbox of list) {
|
||||
const parentKey = mailbox.parentId && ids.has(mailbox.parentId) ? mailbox.parentId : null
|
||||
const siblings = childrenOf.get(parentKey) ?? []
|
||||
siblings.push(mailbox)
|
||||
childrenOf.set(parentKey, siblings)
|
||||
}
|
||||
|
||||
const result: MailboxNode[] = []
|
||||
const visited = new Set<string>()
|
||||
const visit = (parentKey: string | null, depth: number) => {
|
||||
const children = [...(childrenOf.get(parentKey) ?? [])].sort(compareMailboxes)
|
||||
for (const mailbox of children) {
|
||||
if (visited.has(mailbox.id)) continue
|
||||
visited.add(mailbox.id)
|
||||
result.push({ mailbox, depth })
|
||||
visit(mailbox.id, depth + 1)
|
||||
}
|
||||
}
|
||||
visit(null, 0)
|
||||
|
||||
// 순환 참조(a→b→a)로 어느 뿌리에도 닿지 않은 메일함
|
||||
const orphans = list.filter((mailbox) => !visited.has(mailbox.id)).sort(compareMailboxes)
|
||||
for (const mailbox of orphans) {
|
||||
if (visited.has(mailbox.id)) continue
|
||||
visited.add(mailbox.id)
|
||||
result.push({ mailbox, depth: 0 })
|
||||
visit(mailbox.id, 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMailboxByRole(list: readonly Mailbox[], role: MailboxRole): Mailbox | null {
|
||||
return list.find((mailbox) => mailbox.role === role) ?? null
|
||||
}
|
||||
|
||||
function kindOf(mailbox: Pick<Mailbox, 'role'>): MailboxKind {
|
||||
return mailbox.role ?? CUSTOM_MAILBOX_KEY
|
||||
}
|
||||
|
||||
export function mailboxIcon(mailbox: Pick<Mailbox, 'role'>): IconName {
|
||||
return MAILBOX_ICONS[kindOf(mailbox)]
|
||||
}
|
||||
|
||||
/** 빈 메일함 안내 — "받은편지함이 비어 있습니다" + 메일함별 안내 */
|
||||
export function emptyMailboxText(mailbox: Pick<Mailbox, 'role' | 'name'>): {
|
||||
title: string
|
||||
hint: string
|
||||
} {
|
||||
return {
|
||||
title: `${mailbox.name}${subjectParticle(mailbox.name)} 비어 있습니다`,
|
||||
hint: MAILBOX_EMPTY_HINTS[kindOf(mailbox)],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toSafeRedirect } from './redirect'
|
||||
|
||||
describe('toSafeRedirect (로그인 후 돌아갈 주소)', () => {
|
||||
it('앱 안의 경로는 그대로 돌려준다', () => {
|
||||
expect(toSafeRedirect('/mail/inbox/m1?x=1')).toBe('/mail/inbox/m1?x=1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example',
|
||||
'//evil.example',
|
||||
'/\\evil.example',
|
||||
'mail',
|
||||
'/login',
|
||||
'/login?redirect=/mail',
|
||||
])('%s 는 거부한다', (value) => {
|
||||
expect(toSafeRedirect(value)).toBeNull()
|
||||
})
|
||||
|
||||
it('문자열이 아니면 null, 배열이면 첫 값만 본다', () => {
|
||||
expect(toSafeRedirect(undefined)).toBeNull()
|
||||
expect(toSafeRedirect(['/mail', '/other'])).toBe('/mail')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// 로그인 후 돌아갈 주소(?redirect=) 검증 — 순수 함수만 둔다
|
||||
|
||||
/**
|
||||
* 앱 안의 경로만 허용한다. 아니면 null.
|
||||
* - "/" 로 시작해야 한다
|
||||
* - "//evil.example" · "/\evil.example" 처럼 다른 출처로 해석될 수 있는 값은 거부한다
|
||||
* - 로그인 화면 자신으로 돌아가는 값은 무한 반복이 되므로 거부한다
|
||||
*/
|
||||
export function toSafeRedirect(value: unknown): string | null {
|
||||
const candidate = Array.isArray(value) ? value[0] : value
|
||||
if (typeof candidate !== 'string') return null
|
||||
|
||||
const path = candidate.trim()
|
||||
if (!path.startsWith('/')) return null
|
||||
if (path.startsWith('//') || path.startsWith('/\\')) return null
|
||||
if (/^\/login(?:[/?#]|$)/.test(path)) return null
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from './auth.store'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
describe('auth.store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('세션 복원은 여러 번 불러도 조회를 한 번만 한다', async () => {
|
||||
const store = useAuthStore()
|
||||
const loadUser = vi.fn().mockResolvedValue(USER)
|
||||
|
||||
await Promise.all([store.restore(loadUser), store.restore(loadUser)])
|
||||
await store.restore(loadUser)
|
||||
|
||||
expect(loadUser).toHaveBeenCalledTimes(1)
|
||||
expect(store.user).toEqual(USER)
|
||||
expect(store.restored).toBe(true)
|
||||
})
|
||||
|
||||
it('복원 조회가 실패하면 로그인 전 상태로 시작한다', async () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.restore(() => Promise.reject(new Error('down')))
|
||||
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.restored).toBe(true)
|
||||
})
|
||||
|
||||
it('세션 만료는 로그인 상태일 때 한 번만 대화상자 상태를 켠다', () => {
|
||||
const store = useAuthStore()
|
||||
store.markAuthenticated(USER)
|
||||
|
||||
store.expireSession()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.sessionExpired).toBe(true)
|
||||
|
||||
store.acknowledgeSessionExpired()
|
||||
store.expireSession()
|
||||
expect(store.sessionExpired).toBe(false)
|
||||
})
|
||||
|
||||
it('로그아웃(clearAuth)은 세션 만료로 보지 않고, 다시 로그인할 때까지 로그아웃 상태로 둔다', () => {
|
||||
const store = useAuthStore()
|
||||
store.markAuthenticated(USER)
|
||||
|
||||
store.clearAuth()
|
||||
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.sessionExpired).toBe(false)
|
||||
expect(store.signedOut).toBe(true)
|
||||
|
||||
store.markAuthenticated(USER)
|
||||
expect(store.signedOut).toBe(false)
|
||||
})
|
||||
|
||||
it('세션 만료 대화상자가 떠 있어도 로그아웃이 우선한다', () => {
|
||||
const store = useAuthStore()
|
||||
store.markAuthenticated(USER)
|
||||
store.expireSession()
|
||||
|
||||
store.clearAuth()
|
||||
|
||||
expect(store.sessionExpired).toBe(false)
|
||||
expect(store.signedOut).toBe(true)
|
||||
})
|
||||
|
||||
it('복원하는 사이 (다른 탭이) 로그아웃하면 늦게 온 복원 결과를 쓰지 않는다', async () => {
|
||||
const store = useAuthStore()
|
||||
let finish: (user: typeof USER) => void = () => undefined
|
||||
const restoring = store.restore(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
store.clearAuth()
|
||||
finish(USER)
|
||||
await restoring
|
||||
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.restored).toBe(true)
|
||||
})
|
||||
|
||||
it('세션 세대는 로그아웃·세션 만료·새 로그인 때만 오르고, 복원·갱신(같은 세션)에는 그대로다', () => {
|
||||
const store = useAuthStore()
|
||||
const start = store.generation
|
||||
|
||||
store.markAuthenticated(USER)
|
||||
expect(store.generation).toBe(start)
|
||||
|
||||
store.clearAuth()
|
||||
store.startSession(USER)
|
||||
store.expireSession()
|
||||
store.expireSession() // 이미 정리됐으면 아무것도 하지 않는다
|
||||
expect(store.generation).toBe(start + 3)
|
||||
})
|
||||
|
||||
it('다시 로그인하면 세션 만료 상태가 풀린다', () => {
|
||||
const store = useAuthStore()
|
||||
store.markAuthenticated(USER)
|
||||
store.expireSession()
|
||||
|
||||
store.markAuthenticated(USER)
|
||||
|
||||
expect(store.sessionExpired).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,113 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { AuthUser } from '@/shared/types/auth'
|
||||
|
||||
// 인증 세션 상태 스토어
|
||||
// 웹은 JWT 를 HttpOnly·Secure 쿠키로 보관하므로(보안 규칙), JS 에서 토큰을 직접
|
||||
// 저장/조회하지 않는다. 여기서는 "현재 인증된 세션인지" 여부만 관리한다.
|
||||
// (사용자 프로필 데이터는 user.store.ts 에서 별도 관리)
|
||||
// 웹은 JWT 를 HttpOnly 쿠키로만 주고받으므로(보안 규칙) JS 에서 토큰을 저장·조회하지 않는다.
|
||||
// 여기서는 "누가 로그인했는지"와 세션 복원·만료 상태만 관리한다.
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 로그인 성공 또는 세션 확인 API 응답으로 갱신되는 인증 여부
|
||||
const authenticated = ref<boolean>(false)
|
||||
const isLoggedIn = computed<boolean>(() => authenticated.value)
|
||||
/** 로그인한 직원 (email·name). null 이면 로그인 전이거나 세션이 정리된 상태 */
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const isLoggedIn = computed<boolean>(() => user.value !== null)
|
||||
|
||||
// 로그인 성공 시 호출 (실제 로그인 API 연동 시 useAuth 에서 사용)
|
||||
function markAuthenticated() {
|
||||
authenticated.value = true
|
||||
/** 세션 만료(AUTH_002)로 정리됨 — SessionExpiredDialog 가 이 값을 보고 안내한다 */
|
||||
const sessionExpired = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* 사용자가 로그아웃했다 — 이 탭에서 눌렀든 다른 탭의 알림(BroadcastChannel)이든 같다. 다시 로그인하면 풀린다.
|
||||
* 이 동안에는 늦게 도착한 /auth/refresh 성공을 쓰지 않는다 (계약 1절 "클라이언트 처리 규칙", useApi.refreshSession).
|
||||
*/
|
||||
const signedOut = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* 세션 세대 — 세션이 끝나거나(로그아웃·세션 만료) 새로 시작할 때(로그인) 하나씩 올라간다.
|
||||
* 진행 중이던 일(갱신·복원)은 시작할 때 세대를 잡아 두고, 끝났을 때 바뀌었으면 성공이어도 결과를 버린다.
|
||||
* signedOut 만으로는 "로그아웃 → 다시 로그인" 사이에 늦게 온 응답을 가려낼 수 없다 (다시 로그인하면 풀리므로).
|
||||
*/
|
||||
const generation = ref<number>(0)
|
||||
|
||||
/** 앱 시작 시 세션 복원을 마쳤는지 */
|
||||
const restored = ref<boolean>(false)
|
||||
let restoring: Promise<void> | null = null
|
||||
|
||||
// 세션 복원·토큰 갱신 때 호출 — 같은 세션이 이어지므로 세대는 그대로다
|
||||
function markAuthenticated(next: AuthUser) {
|
||||
user.value = next
|
||||
sessionExpired.value = false
|
||||
signedOut.value = false
|
||||
}
|
||||
|
||||
// 로그아웃/세션 만료 시 호출
|
||||
// 로그인 성공 때 호출 — 새 세션이다
|
||||
function startSession(next: AuthUser) {
|
||||
generation.value += 1
|
||||
markAuthenticated(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 — 이 탭에서 눌렀거나 다른 탭이 로그아웃을 알려 왔을 때 호출한다.
|
||||
* 세션 만료가 아니므로 대화상자를 띄우지 않는다 (이미 떠 있었다면 로그아웃이 우선한다).
|
||||
*/
|
||||
function clearAuth() {
|
||||
authenticated.value = false
|
||||
generation.value += 1
|
||||
user.value = null
|
||||
sessionExpired.value = false
|
||||
signedOut.value = true
|
||||
}
|
||||
|
||||
return { isLoggedIn, markAuthenticated, clearAuth }
|
||||
/**
|
||||
* 세션 만료 — 로그인 상태에서 401 을 받고 토큰 갱신도 실패했을 때 useApi 가 호출한다.
|
||||
* 여러 요청이 동시에 실패해도 대화상자는 한 번만 뜨도록, 이미 정리됐으면 아무것도 하지 않는다.
|
||||
*/
|
||||
function expireSession() {
|
||||
if (!user.value) return
|
||||
generation.value += 1
|
||||
user.value = null
|
||||
sessionExpired.value = true
|
||||
}
|
||||
|
||||
// 세션 만료 안내를 확인했을 때 (로그인 화면으로 이동하며 호출)
|
||||
function acknowledgeSessionExpired() {
|
||||
sessionExpired.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 시작 시 세션 복원 — 첫 네비게이션 판정 전에 router 가 기다린다.
|
||||
* 몇 번을 불러도 실제 조회는 한 번만 한다.
|
||||
*
|
||||
* 조회 함수는 useAuth 가 넘긴다. 스토어가 직접 useApi 를 부르면
|
||||
* useApi → auth.store → useApi 순환 import 가 생기기 때문이다.
|
||||
*/
|
||||
function restore(loadUser: () => Promise<AuthUser | null>): Promise<void> {
|
||||
if (!restoring) {
|
||||
const startedAt = generation.value
|
||||
restoring = loadUser()
|
||||
.then((loaded) => {
|
||||
// 복원하는 사이 세대가 바뀌었으면(다른 탭의 로그아웃 알림 등) 늦게 온 결과를 쓰지 않는다
|
||||
if (loaded && generation.value === startedAt) markAuthenticated(loaded)
|
||||
})
|
||||
.catch(() => {
|
||||
// 복원 실패 = 로그인 전 상태로 시작한다 (알림은 조회 쪽에서 이미 처리했다)
|
||||
})
|
||||
.finally(() => {
|
||||
restored.value = true
|
||||
})
|
||||
}
|
||||
return restoring
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
sessionExpired,
|
||||
signedOut,
|
||||
generation,
|
||||
restored,
|
||||
markAuthenticated,
|
||||
startSession,
|
||||
clearAuth,
|
||||
expireSession,
|
||||
acknowledgeSessionExpired,
|
||||
restore,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useAuthStore } from './auth.store'
|
||||
import {
|
||||
DEFAULT_MAIL_VIEW_MODE,
|
||||
mailViewModeStorageKey,
|
||||
parseMailViewMode,
|
||||
useMailViewStore,
|
||||
} from './mail-view.store'
|
||||
|
||||
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const DOYUN = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
|
||||
|
||||
describe('mail-view.store (보기 방식 — 사용자별 기억)', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('기본값은 분할 보기다', () => {
|
||||
expect(DEFAULT_MAIL_VIEW_MODE).toBe('split')
|
||||
expect(useMailViewStore().mode).toBe('split')
|
||||
})
|
||||
|
||||
it('고른 보기를 사용자 메일 주소가 든 키로 저장한다', () => {
|
||||
useAuthStore().markAuthenticated(ARA)
|
||||
const store = useMailViewStore()
|
||||
|
||||
store.setMode('list')
|
||||
|
||||
expect(window.localStorage.getItem(mailViewModeStorageKey(ARA.email))).toBe('list')
|
||||
expect(mailViewModeStorageKey('Ara.Kim@ArASystem.test')).toContain('ara.kim@arasystem.test')
|
||||
})
|
||||
|
||||
it('로그인한 사용자가 바뀌면 그 사용자의 선택을 다시 읽는다', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(ARA.email), 'list')
|
||||
const authStore = useAuthStore()
|
||||
const store = useMailViewStore()
|
||||
|
||||
authStore.markAuthenticated(ARA)
|
||||
await nextTick()
|
||||
expect(store.mode).toBe('list')
|
||||
|
||||
authStore.markAuthenticated(DOYUN)
|
||||
await nextTick()
|
||||
expect(store.mode).toBe('split')
|
||||
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
expect(store.mode).toBe('split')
|
||||
})
|
||||
|
||||
it('저장소를 쓸 수 없어도(사생활 보호 모드 등) 기본값으로 동작하고, 바꾼 보기는 이번 화면에서 유지한다', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('차단됨')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('차단됨')
|
||||
})
|
||||
useAuthStore().markAuthenticated(ARA)
|
||||
const store = useMailViewStore()
|
||||
await nextTick()
|
||||
|
||||
expect(store.mode).toBe('split')
|
||||
expect(() => store.setMode('list')).not.toThrow()
|
||||
expect(store.mode).toBe('list')
|
||||
})
|
||||
|
||||
it('알 수 없는 저장값은 기본값으로 읽는다', () => {
|
||||
expect(parseMailViewMode('grid')).toBe('split')
|
||||
expect(parseMailViewMode(null)).toBe('split')
|
||||
expect(parseMailViewMode('list')).toBe('list')
|
||||
})
|
||||
|
||||
it('표 위치는 같은 메일함으로 돌아왔을 때만 한 번 돌려준다', () => {
|
||||
const store = useMailViewStore()
|
||||
store.rememberListPosition({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
|
||||
|
||||
expect(store.takeListPosition('sent')).toBeNull()
|
||||
|
||||
store.rememberListPosition({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
|
||||
expect(store.takeListPosition('inbox')).toEqual({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
|
||||
expect(store.takeListPosition('inbox')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 메일 보기 방식 — "분할 | 목록" 전환 (디자인 README "보기 방식", 사용자별로 기억)
|
||||
// 서버 설정 API 가 생기기 전까지는 브라우저 localStorage 에 둔다. 키에 사용자 메일 주소를 넣어 계정마다 따로 기억한다.
|
||||
|
||||
export type MailViewMode = 'split' | 'list'
|
||||
|
||||
export const DEFAULT_MAIL_VIEW_MODE: MailViewMode = 'split'
|
||||
const MAIL_VIEW_MODES: readonly MailViewMode[] = ['split', 'list']
|
||||
const STORAGE_KEY_PREFIX = 'arasystem.mail.viewMode:'
|
||||
|
||||
export function mailViewModeStorageKey(email: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${email.trim().toLowerCase()}`
|
||||
}
|
||||
|
||||
/** 저장된 값이 없거나 알 수 없는 값이면 기본값('분할') */
|
||||
export function parseMailViewMode(value: string | null): MailViewMode {
|
||||
const known = MAIL_VIEW_MODES.find((mode) => mode === value)
|
||||
return known ?? DEFAULT_MAIL_VIEW_MODE
|
||||
}
|
||||
|
||||
// 사생활 보호 모드·저장소 차단·용량 초과 등으로 localStorage 가 실패해도 화면은 기본값으로 동작해야 한다
|
||||
function readStored(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: string) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// 기억하지 못해도 이번 화면에서는 바뀐 보기로 계속 쓴다
|
||||
}
|
||||
}
|
||||
|
||||
/** 목록 보기에서 메일을 열 때 기억해 둔 표 위치 — "목록으로" 돌아오면 되살린다 */
|
||||
export interface ListPosition {
|
||||
mailboxId: string
|
||||
/** 표 칸의 스크롤 (넓은 화면 — 칸마다 따로 스크롤) */
|
||||
scrollTop: number
|
||||
/** 화면 전체 스크롤 (좁은 화면 — 칸이 접히고 화면이 스크롤) */
|
||||
windowY: number
|
||||
}
|
||||
|
||||
export const useMailViewStore = defineStore('mail-view', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const mode = ref<MailViewMode>(DEFAULT_MAIL_VIEW_MODE)
|
||||
const listPosition = ref<ListPosition | null>(null)
|
||||
/** 목록으로 돌아왔을 때 초점을 돌려줄 메일 — 키보드 사용자가 보던 자리를 잃지 않게 */
|
||||
const returnFocusId = ref<string | null>(null)
|
||||
|
||||
function loadFor(email: string | null) {
|
||||
mode.value = email
|
||||
? parseMailViewMode(readStored(mailViewModeStorageKey(email)))
|
||||
: DEFAULT_MAIL_VIEW_MODE
|
||||
listPosition.value = null
|
||||
returnFocusId.value = null
|
||||
}
|
||||
|
||||
function setMode(next: MailViewMode) {
|
||||
mode.value = next
|
||||
const email = authStore.user?.email
|
||||
if (email) writeStored(mailViewModeStorageKey(email), next)
|
||||
}
|
||||
|
||||
function rememberListPosition(position: ListPosition) {
|
||||
listPosition.value = position
|
||||
}
|
||||
|
||||
/** 같은 메일함으로 돌아왔을 때만 위치를 돌려주고 비운다 */
|
||||
function takeListPosition(mailboxId: string): ListPosition | null {
|
||||
const saved = listPosition.value
|
||||
listPosition.value = null
|
||||
return saved && saved.mailboxId === mailboxId ? saved : null
|
||||
}
|
||||
|
||||
function setReturnFocus(messageId: string | null) {
|
||||
returnFocusId.value = messageId
|
||||
}
|
||||
|
||||
function takeReturnFocus(): string | null {
|
||||
const id = returnFocusId.value
|
||||
returnFocusId.value = null
|
||||
return id
|
||||
}
|
||||
|
||||
// 로그인한 사용자가 바뀌면 그 사용자의 선택을 다시 읽는다 (로그아웃이면 기본값)
|
||||
watch(() => authStore.user?.email ?? null, loadFor, { immediate: true })
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
rememberListPosition,
|
||||
takeListPosition,
|
||||
setReturnFocus,
|
||||
takeReturnFocus,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { useMailStore } from './mail.store'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
let clock = Date.UTC(2026, 9, 26, 3, 0, 0)
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 2,
|
||||
}
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', unreadCount: 0 }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '미리보기',
|
||||
receivedAt: '2026-10-26T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(id: string, partial: Partial<MessageDetail> = {}): MessageDetail {
|
||||
return {
|
||||
...summary(id),
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: MessageSummary[], nextCursor: string | null = null) {
|
||||
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total: items.length } })
|
||||
}
|
||||
|
||||
describe('mail.store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('메일함을 열면 첫 페이지를 mailboxId · limit 20 으로 불러온다', async () => {
|
||||
const requests = installMockApi(() => page([summary('m1'), summary('m2')], 'cursor-2'))
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
expect(requests[0]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m1', 'm2'])
|
||||
expect(store.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('첫 목록을 못 불러오면 토스트 대신 목록 영역 오류 상태(MAIL_001)로 둔다', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001'))
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
expect(store.listStatus).toBe('error')
|
||||
expect(store.listError?.code).toBe('MAIL_001')
|
||||
expect(store.listError?.traceId).toBeTruthy()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('더 보기는 nextCursor 를 그대로 보내고 겹치는 메일 없이 이어 붙인다', async () => {
|
||||
const requests = installMockApi(({ params }) =>
|
||||
params.cursor === 'cursor-2'
|
||||
? page([summary('m2'), summary('m3')])
|
||||
: page([summary('m1'), summary('m2')], 'cursor-2'),
|
||||
)
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
await store.loadMore()
|
||||
|
||||
expect(requests[1]?.params).toEqual({ mailboxId: 'inbox', cursor: 'cursor-2', limit: 20 })
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m1', 'm2', 'm3'])
|
||||
expect(store.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('메일함을 빠르게 바꾸면 늦게 온 이전 메일함 응답은 버린다', async () => {
|
||||
let releaseSlow: (result: MockResult) => void = () => undefined
|
||||
installMockApi(({ params }) =>
|
||||
params.mailboxId === 'slow'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
releaseSlow = resolve
|
||||
})
|
||||
: page([summary('fast-1')]),
|
||||
)
|
||||
const store = useMailStore()
|
||||
|
||||
const slow = store.openMailbox('slow')
|
||||
await store.openMailbox('fast')
|
||||
releaseSlow(page([summary('slow-1')]))
|
||||
await slow
|
||||
|
||||
expect(store.currentMailboxId).toBe('fast')
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['fast-1'])
|
||||
})
|
||||
|
||||
it('안 읽은 메일을 열면 PATCH {isRead:true} 를 보내고 안 읽은 개수를 줄인다', async () => {
|
||||
const requests = installMockApi(({ method, url }) => {
|
||||
if (url === '/mailboxes') return ok({ items: [INBOX], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
if (url === '/messages') return page([summary('m1', { isRead: false })])
|
||||
if (method === 'GET') return ok(detail('m1', { isRead: false }))
|
||||
return ok(summary('m1', { isRead: true }))
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
await store.openMessage('m1')
|
||||
await flushPromises()
|
||||
|
||||
const patch = requests.find((request) => request.method === 'PATCH')
|
||||
expect(patch?.url).toBe('/messages/m1')
|
||||
expect(patch?.data).toEqual({ isRead: true })
|
||||
expect(store.messages[0]?.isRead).toBe(true)
|
||||
expect(store.selected?.isRead).toBe(true)
|
||||
expect(store.mailboxes[0]?.unreadCount).toBe(1)
|
||||
})
|
||||
|
||||
it('메일함 목록보다 본문이 먼저 와도, 읽음 처리 뒤 개수를 서버 기준으로 다시 맞춘다', async () => {
|
||||
let releaseFirstMailboxes: (result: MockResult) => void = () => undefined
|
||||
let mailboxCalls = 0
|
||||
installMockApi(({ method, url }) => {
|
||||
if (url === '/mailboxes') {
|
||||
mailboxCalls += 1
|
||||
if (mailboxCalls === 1) {
|
||||
// 첫 응답은 읽음 처리 전 개수(2)를 들고 늦게 도착한다
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
releaseFirstMailboxes = resolve
|
||||
})
|
||||
}
|
||||
return ok({ items: [{ ...INBOX, unreadCount: 1 }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
}
|
||||
if (url === '/messages') return page([summary('m1', { isRead: false })])
|
||||
if (method === 'GET') return ok(detail('m1', { isRead: false }))
|
||||
return ok(summary('m1', { isRead: true }))
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
const firstLoad = store.loadMailboxes()
|
||||
await store.openMailbox('inbox')
|
||||
await store.openMessage('m1')
|
||||
await flushPromises()
|
||||
releaseFirstMailboxes(ok({ items: [INBOX], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }))
|
||||
await firstLoad
|
||||
await flushPromises()
|
||||
|
||||
expect(mailboxCalls).toBe(2)
|
||||
expect(store.mailboxes[0]?.unreadCount).toBe(1)
|
||||
})
|
||||
|
||||
it('읽음 처리가 실패하면 안 읽음과 개수를 되돌린다', async () => {
|
||||
installMockApi(({ method, url }) => {
|
||||
if (url === '/mailboxes') return ok({ items: [INBOX], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
if (url === '/messages') return page([summary('m1', { isRead: false })])
|
||||
if (method === 'GET') return ok(detail('m1', { isRead: false }))
|
||||
return fail(503, 'MAIL_001')
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
await store.openMessage('m1')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages[0]?.isRead).toBe(false)
|
||||
expect(store.mailboxes[0]?.unreadCount).toBe(2)
|
||||
})
|
||||
|
||||
it('중요 표시는 화면을 먼저 바꾸고, 실패하면 되돌린다', async () => {
|
||||
let patchResult: MockResult = ok(summary('m1', { isFlagged: true }))
|
||||
installMockApi(({ method }) => (method === 'PATCH' ? patchResult : page([summary('m1')])))
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
await store.toggleFlag('m1')
|
||||
expect(store.messages[0]?.isFlagged).toBe(true)
|
||||
|
||||
patchResult = fail(503, 'MAIL_001')
|
||||
await store.toggleFlag('m1')
|
||||
expect(store.messages[0]?.isFlagged).toBe(true)
|
||||
})
|
||||
|
||||
it('삭제하면 목록에서 빼고 다음 메일(없으면 이전 메일) id 를 돌려준다', async () => {
|
||||
const requests = installMockApi(({ method, url }) => {
|
||||
if (method === 'DELETE') return { status: 204 }
|
||||
if (url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
return page([summary('m1'), summary('m2'), summary('m3')])
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
await expect(store.removeMessage('m2')).resolves.toBe('m3')
|
||||
await expect(store.removeMessage('m3')).resolves.toBe('m1')
|
||||
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m1'])
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/messages/m2',
|
||||
'/messages/m3',
|
||||
])
|
||||
})
|
||||
|
||||
describe('사용량 (GET /mail-quota)', () => {
|
||||
const QUOTA = {
|
||||
usedBytes: 966_367_641,
|
||||
limitBytes: 5 * 1024 ** 3,
|
||||
attachmentMaxBytes: 25 * 1024 ** 2,
|
||||
attachmentTotalMaxBytes: 30 * 1024 ** 2,
|
||||
}
|
||||
|
||||
it('사용량을 불러오고, 첨부 한도는 서버 값(파일당 attachmentMaxBytes · 합계 attachmentTotalMaxBytes)을 쓴다', async () => {
|
||||
const requests = installMockApi(() => ok(QUOTA))
|
||||
const store = useMailStore()
|
||||
|
||||
await store.ensureQuota()
|
||||
await store.ensureQuota()
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(['/mail-quota'])
|
||||
expect(store.quota).toEqual(QUOTA)
|
||||
expect(store.attachmentMaxBytes).toBe(25 * 1024 ** 2)
|
||||
expect(store.attachmentTotalMaxBytes).toBe(30 * 1024 ** 2)
|
||||
})
|
||||
|
||||
it('못 받으면 알리지 않고 첨부 한도는 대체값(파일당 20MB · 합계 25MB)을 쓴다', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001'))
|
||||
const store = useMailStore()
|
||||
|
||||
await store.ensureQuota()
|
||||
|
||||
expect(store.quota).toBeNull()
|
||||
expect(store.attachmentMaxBytes).toBe(20 * 1024 ** 2)
|
||||
expect(store.attachmentTotalMaxBytes).toBe(25 * 1024 ** 2)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('합계 한도를 아직 주지 않는 서버면 합계만 대체값(25MB)을 쓴다', async () => {
|
||||
installMockApi(() =>
|
||||
ok({ usedBytes: QUOTA.usedBytes, limitBytes: QUOTA.limitBytes, attachmentMaxBytes: 10 * 1024 ** 2 }),
|
||||
)
|
||||
const store = useMailStore()
|
||||
|
||||
await store.ensureQuota()
|
||||
|
||||
expect(store.attachmentMaxBytes).toBe(10 * 1024 ** 2)
|
||||
expect(store.attachmentTotalMaxBytes).toBe(MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES)
|
||||
})
|
||||
|
||||
it('삭제한 뒤에는 메일함 개수와 사용량을 함께 다시 맞춘다', async () => {
|
||||
const requests = installMockApi(({ method, url }) => {
|
||||
if (method === 'DELETE') return { status: 204 }
|
||||
if (url === '/mail-quota') return ok(QUOTA)
|
||||
if (url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
return page([summary('m1'), summary('m2')])
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('trash')
|
||||
|
||||
await store.removeMessage('m1')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(
|
||||
expect.arrayContaining(['/messages/m1', '/mailboxes', '/mail-quota']),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('로그아웃·세션 만료로 사용자가 바뀌면 이전 사용자의 메일을 비운다', async () => {
|
||||
installMockApi(() => page([summary('m1')]))
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await nextTick()
|
||||
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.currentMailboxId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,417 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import {
|
||||
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
|
||||
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
|
||||
} from '@/shared/constants/mail'
|
||||
import type {
|
||||
MailQuota,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
} from '@/shared/types/mail'
|
||||
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
|
||||
import { findMailboxByRole, orderMailboxes } from '@/shared/utils/mailbox'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** 서버가 준 한도가 양수면 그 값, 없거나 이상하면 대체값 */
|
||||
function positiveOr(value: number | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
// 메일 도메인 스토어 — 메일함 · 사용량 · 목록 · 선택한 메일
|
||||
//
|
||||
// 오류 처리 원칙:
|
||||
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
|
||||
// - 사용량은 보조 정보라 silent 로 부르고, 못 받으면 패널 하단을 숨긴다 (첨부 한도는 대체값)
|
||||
// - 그 밖의 동작(더 보기·읽음·중요·삭제)은 인터셉터 알림에 맡기고 화면 상태만 되돌린다
|
||||
// - 메일함을 빠르게 바꿀 때 늦게 온 응답이 덮어쓰지 않도록 요청마다 순번을 매긴다
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── 메일함 ─────────────────────────────────────────
|
||||
const mailboxes = ref<Mailbox[]>([])
|
||||
const mailboxesStatus = ref<LoadStatus>('idle')
|
||||
const mailboxesError = ref<ApiError | null>(null)
|
||||
|
||||
// ── 사용량 (GET /mail-quota) ──────────────────────────
|
||||
const quota = ref<MailQuota | null>(null)
|
||||
|
||||
// ── 목록 ──────────────────────────────────────────
|
||||
const currentMailboxId = ref<string | null>(null)
|
||||
const messages = ref<MessageSummary[]>([])
|
||||
const pageInfo = ref<PageInfo | null>(null)
|
||||
const listStatus = ref<LoadStatus>('idle')
|
||||
const listError = ref<ApiError | null>(null)
|
||||
/** 목록을 보이는 채로 다시 불러오는 중 (새로고침) */
|
||||
const isRefreshing = ref<boolean>(false)
|
||||
const isLoadingMore = ref<boolean>(false)
|
||||
|
||||
// ── 선택한 메일 ────────────────────────────────────
|
||||
const selectedId = ref<string | null>(null)
|
||||
const selected = ref<MessageDetail | null>(null)
|
||||
const selectedStatus = ref<LoadStatus>('idle')
|
||||
const selectedError = ref<ApiError | null>(null)
|
||||
|
||||
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일) */
|
||||
const lastBrowseLocation = ref<string | null>(null)
|
||||
|
||||
let mailboxSeq = 0
|
||||
let quotaSeq = 0
|
||||
let listSeq = 0
|
||||
let detailSeq = 0
|
||||
|
||||
/**
|
||||
* 첨부 파일당 한도(바이트) — 서버 값(attachmentMaxBytes).
|
||||
* 사용량을 아직 못 받았거나 값이 이상하면 대체값(20MB)으로 사전 검사·안내한다.
|
||||
*/
|
||||
const attachmentMaxBytes = computed<number>(() =>
|
||||
positiveOr(quota.value?.attachmentMaxBytes, MAIL_ATTACHMENT_FALLBACK_MAX_BYTES),
|
||||
)
|
||||
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — 서버 값(attachmentTotalMaxBytes). 발송 때 서버가 검사하는 값과 같다.
|
||||
* 못 받았거나(이 값을 아직 주지 않는 서버 포함) 값이 이상하면 대체값(25MB)을 쓴다.
|
||||
*/
|
||||
const attachmentTotalMaxBytes = computed<number>(() =>
|
||||
positiveOr(quota.value?.attachmentTotalMaxBytes, MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES),
|
||||
)
|
||||
|
||||
const orderedMailboxes = computed(() => orderMailboxes(mailboxes.value))
|
||||
const currentMailbox = computed<Mailbox | null>(
|
||||
() => mailboxes.value.find((mailbox) => mailbox.id === currentMailboxId.value) ?? null,
|
||||
)
|
||||
/** /mail 로 들어왔을 때 열 메일함 — 받은편지함, 없으면 첫 메일함 */
|
||||
const defaultMailbox = computed<Mailbox | null>(
|
||||
() =>
|
||||
findMailboxByRole(mailboxes.value, 'inbox') ?? orderedMailboxes.value[0]?.mailbox ?? null,
|
||||
)
|
||||
const hasMore = computed<boolean>(
|
||||
() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor),
|
||||
)
|
||||
|
||||
function mailboxByRole(role: MailboxRole): Mailbox | null {
|
||||
return findMailboxByRole(mailboxes.value, role)
|
||||
}
|
||||
|
||||
/** 응답을 기다리는 메일함 목록 요청 수 — 그 응답이 화면에서 바꾼 개수를 덮어쓸 수 있는지 판단한다 */
|
||||
let mailboxLoadsInFlight = 0
|
||||
|
||||
/**
|
||||
* 메일함 목록.
|
||||
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
|
||||
*/
|
||||
async function loadMailboxes(options: { background?: boolean } = {}): Promise<void> {
|
||||
mailboxSeq += 1
|
||||
const seq = mailboxSeq
|
||||
if (!options.background) {
|
||||
mailboxesStatus.value = 'loading'
|
||||
mailboxesError.value = null
|
||||
}
|
||||
mailboxLoadsInFlight += 1
|
||||
try {
|
||||
const result = await mailApi.listMailboxes({ silent: true })
|
||||
if (seq !== mailboxSeq) return
|
||||
mailboxes.value = result.items
|
||||
mailboxesStatus.value = 'ready'
|
||||
mailboxesError.value = null
|
||||
} catch (error) {
|
||||
if (seq !== mailboxSeq) return
|
||||
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
|
||||
if (options.background && mailboxesStatus.value === 'ready') return
|
||||
mailboxesStatus.value = 'error'
|
||||
mailboxesError.value = toApiError(error)
|
||||
} finally {
|
||||
mailboxLoadsInFlight -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 보이는 개수가 곧 도착할(또는 아직 없는) 메일함 응답에 덮어쓰일 수 있는지 */
|
||||
function mailboxCountsMayBeOverwritten(): boolean {
|
||||
return mailboxLoadsInFlight > 0 || mailboxesStatus.value !== 'ready'
|
||||
}
|
||||
|
||||
/** 아직 불러오지 않았거나 실패했을 때만 불러온다 */
|
||||
async function ensureMailboxes(): Promise<void> {
|
||||
if (mailboxesStatus.value === 'idle' || mailboxesStatus.value === 'error') {
|
||||
await loadMailboxes()
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용량 · 첨부 한도 — 실패해도 알리지 않고 이전 값을 둔다 (보조 정보) */
|
||||
async function loadQuota(): Promise<void> {
|
||||
quotaSeq += 1
|
||||
const seq = quotaSeq
|
||||
try {
|
||||
const result = await mailApi.getQuota({ silent: true })
|
||||
if (seq === quotaSeq) quota.value = result
|
||||
} catch {
|
||||
// 못 받으면 패널 하단 사용량을 숨기고, 첨부 한도는 대체값을 쓴다
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQuota(): Promise<void> {
|
||||
if (!quota.value) await loadQuota()
|
||||
}
|
||||
|
||||
/** 보내기·삭제·새로고침 뒤 — 메일함 개수와 사용량을 서버 기준으로 다시 맞춘다 (화면은 그대로 둔 채) */
|
||||
function syncCounts() {
|
||||
void loadMailboxes({ background: true })
|
||||
void loadQuota()
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 열기 — 첫 페이지를 불러온다.
|
||||
* 같은 메일함으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
|
||||
*/
|
||||
async function openMailbox(mailboxId: string): Promise<void> {
|
||||
const keepVisible =
|
||||
mailboxId === currentMailboxId.value && listStatus.value === 'ready'
|
||||
currentMailboxId.value = mailboxId
|
||||
listSeq += 1
|
||||
const seq = listSeq
|
||||
|
||||
if (keepVisible) {
|
||||
isRefreshing.value = true
|
||||
} else {
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'loading'
|
||||
listError.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await mailApi.listMessages(
|
||||
{ mailboxId, limit: DEFAULT_PAGE_SIZE },
|
||||
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
|
||||
{ silent: keepVisible ? undefined : true },
|
||||
)
|
||||
if (seq !== listSeq) return
|
||||
messages.value = page.items
|
||||
pageInfo.value = page.pageInfo
|
||||
listStatus.value = 'ready'
|
||||
listError.value = null
|
||||
} catch (error) {
|
||||
if (seq !== listSeq || keepVisible) return
|
||||
listStatus.value = 'error'
|
||||
listError.value = toApiError(error)
|
||||
} finally {
|
||||
if (seq === listSeq) isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 새로고침 — 메일함 개수 · 사용량 · 지금 목록을 함께 다시 불러온다 */
|
||||
async function refresh(): Promise<void> {
|
||||
syncCounts()
|
||||
if (currentMailboxId.value) await openMailbox(currentMailboxId.value)
|
||||
}
|
||||
|
||||
/** 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다 */
|
||||
async function loadMore(): Promise<void> {
|
||||
const cursor = pageInfo.value?.nextCursor
|
||||
const mailboxId = currentMailboxId.value
|
||||
if (!cursor || !mailboxId || isLoadingMore.value) return
|
||||
|
||||
const seq = listSeq
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
const page = await mailApi.listMessages({ mailboxId, cursor, limit: DEFAULT_PAGE_SIZE })
|
||||
if (seq !== listSeq) return
|
||||
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
|
||||
const known = new Set(messages.value.map((message) => message.id))
|
||||
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
|
||||
pageInfo.value = page.pageInfo
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setLocalFlags(id: string, changes: Partial<Pick<MessageSummary, 'isRead' | 'isFlagged'>>) {
|
||||
messages.value = messages.value.map((message) =>
|
||||
message.id === id ? { ...message, ...changes } : message,
|
||||
)
|
||||
if (selected.value?.id === id) selected.value = { ...selected.value, ...changes }
|
||||
}
|
||||
|
||||
function adjustUnread(mailboxIds: readonly string[], delta: number) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) =>
|
||||
mailboxIds.includes(mailbox.id)
|
||||
? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount + delta) }
|
||||
: mailbox,
|
||||
)
|
||||
}
|
||||
|
||||
/** 서버가 돌려준 요약으로 목록·선택 메일을 맞춘다 */
|
||||
function applySummary(summary: MessageSummary) {
|
||||
messages.value = messages.value.map((message) => (message.id === summary.id ? summary : message))
|
||||
if (selected.value?.id === summary.id) selected.value = { ...selected.value, ...summary }
|
||||
}
|
||||
|
||||
/** 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
|
||||
async function markRead(message: Pick<MessageSummary, 'id' | 'isRead' | 'mailboxIds'>) {
|
||||
if (message.isRead) return
|
||||
// 주소로 바로 들어오면 메일함 목록보다 본문이 먼저 올 수 있다 — 그러면 아래 개수 조정이
|
||||
// 늦게 온 메일함 응답에 덮어쓰이므로, 읽음 처리가 끝난 뒤 서버 기준으로 다시 맞춘다
|
||||
const resync = mailboxCountsMayBeOverwritten()
|
||||
setLocalFlags(message.id, { isRead: true })
|
||||
adjustUnread(message.mailboxIds, -1)
|
||||
try {
|
||||
applySummary(await mailApi.updateMessage(message.id, { isRead: true }))
|
||||
if (resync || mailboxCountsMayBeOverwritten()) void loadMailboxes({ background: true })
|
||||
} catch {
|
||||
setLocalFlags(message.id, { isRead: false })
|
||||
adjustUnread(message.mailboxIds, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다 */
|
||||
async function openMessage(id: string): Promise<void> {
|
||||
selectedId.value = id
|
||||
if (selected.value?.id === id && selectedStatus.value === 'ready') return
|
||||
|
||||
detailSeq += 1
|
||||
const seq = detailSeq
|
||||
selected.value = null
|
||||
selectedStatus.value = 'loading'
|
||||
selectedError.value = null
|
||||
try {
|
||||
const detail = await mailApi.getMessage(id, { silent: true })
|
||||
if (seq !== detailSeq) return
|
||||
selected.value = detail
|
||||
selectedStatus.value = 'ready'
|
||||
if (!detail.isRead) void markRead(detail)
|
||||
} catch (error) {
|
||||
if (seq !== detailSeq) return
|
||||
selectedStatus.value = 'error'
|
||||
selectedError.value = toApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function closeMessage() {
|
||||
detailSeq += 1
|
||||
selectedId.value = null
|
||||
selected.value = null
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
}
|
||||
|
||||
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
|
||||
async function toggleFlag(id: string): Promise<void> {
|
||||
const current =
|
||||
messages.value.find((message) => message.id === id) ??
|
||||
(selected.value?.id === id ? selected.value : null)
|
||||
if (!current) return
|
||||
|
||||
const next = !current.isFlagged
|
||||
setLocalFlags(id, { isFlagged: next })
|
||||
try {
|
||||
applySummary(await mailApi.updateMessage(id, { isFlagged: next }))
|
||||
} catch {
|
||||
setLocalFlags(id, { isFlagged: !next })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 — 휴지통이 아니면 휴지통으로, 휴지통이면 영구 삭제 (서버가 판단).
|
||||
* 성공하면 이어서 보여 줄 메일 id(다음 메일, 없으면 이전 메일)를 돌려준다.
|
||||
* 실패하면 예외가 그대로 올라간다 (알림은 인터셉터가 띄웠다).
|
||||
*/
|
||||
async function removeMessage(id: string): Promise<string | null> {
|
||||
await mailApi.deleteMessage(id)
|
||||
|
||||
const index = messages.value.findIndex((message) => message.id === id)
|
||||
const rest = messages.value.filter((message) => message.id !== id)
|
||||
const next = index === -1 ? null : (rest[index] ?? rest[index - 1] ?? null)
|
||||
messages.value = rest
|
||||
if (pageInfo.value && pageInfo.value.total !== null) {
|
||||
pageInfo.value = { ...pageInfo.value, total: Math.max(0, pageInfo.value.total - 1) }
|
||||
}
|
||||
if (selectedId.value === id) closeMessage()
|
||||
// 휴지통 개수 · 사용량(영구 삭제)까지 서버 기준으로 다시 맞춘다
|
||||
syncCounts()
|
||||
return next?.id ?? null
|
||||
}
|
||||
|
||||
function rememberBrowseLocation(fullPath: string) {
|
||||
lastBrowseLocation.value = fullPath
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mailboxSeq += 1
|
||||
quotaSeq += 1
|
||||
listSeq += 1
|
||||
detailSeq += 1
|
||||
mailboxes.value = []
|
||||
mailboxesStatus.value = 'idle'
|
||||
mailboxesError.value = null
|
||||
quota.value = null
|
||||
currentMailboxId.value = null
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'idle'
|
||||
listError.value = null
|
||||
isRefreshing.value = false
|
||||
isLoadingMore.value = false
|
||||
selectedId.value = null
|
||||
selected.value = null
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
lastBrowseLocation.value = null
|
||||
}
|
||||
|
||||
// 로그아웃·세션 만료·다른 계정 로그인 때 이전 사용자의 메일이 화면에 남지 않게 비운다
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return {
|
||||
mailboxes,
|
||||
mailboxesStatus,
|
||||
mailboxesError,
|
||||
quota,
|
||||
attachmentMaxBytes,
|
||||
attachmentTotalMaxBytes,
|
||||
currentMailboxId,
|
||||
messages,
|
||||
pageInfo,
|
||||
listStatus,
|
||||
listError,
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
selectedId,
|
||||
selected,
|
||||
selectedStatus,
|
||||
selectedError,
|
||||
lastBrowseLocation,
|
||||
orderedMailboxes,
|
||||
currentMailbox,
|
||||
defaultMailbox,
|
||||
hasMore,
|
||||
mailboxByRole,
|
||||
loadMailboxes,
|
||||
ensureMailboxes,
|
||||
loadQuota,
|
||||
ensureQuota,
|
||||
syncCounts,
|
||||
openMailbox,
|
||||
refresh,
|
||||
loadMore,
|
||||
openMessage,
|
||||
closeMessage,
|
||||
markRead,
|
||||
toggleFlag,
|
||||
removeMessage,
|
||||
rememberBrowseLocation,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
|
||||
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
|
||||
|
||||
export type ToastKind = 'error' | 'success'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
/** 사용자 문구 — 오류는 에러 코드 사전에서만 온다 */
|
||||
message: string
|
||||
/** 서버 추적 ID — 화면에는 앞 8자리를 "문의 코드"로 보인다 */
|
||||
traceId: string | null
|
||||
action: ToastAction | null
|
||||
}
|
||||
|
||||
export interface ToastInput {
|
||||
kind: ToastKind
|
||||
message: string
|
||||
traceId?: string | null
|
||||
action?: ToastAction | null
|
||||
}
|
||||
|
||||
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 */
|
||||
export const MAX_VISIBLE_TOASTS = 3
|
||||
|
||||
/** 자동으로 닫히기까지의 시간 — 오류는 문의 코드를 옮겨 적을 수 있게 더 오래 둔다 */
|
||||
export const TOAST_DURATION_MS: Readonly<Record<ToastKind, number>> = {
|
||||
error: 8000,
|
||||
success: 5000,
|
||||
}
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<Toast[]>([])
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
let nextId = 1
|
||||
|
||||
function stopTimer(id: number) {
|
||||
const timer = timers.get(id)
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
timers.delete(id)
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
stopTimer(id)
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
}
|
||||
|
||||
function show(input: ToastInput): number {
|
||||
const id = nextId
|
||||
nextId += 1
|
||||
|
||||
const next: Toast = {
|
||||
id,
|
||||
kind: input.kind,
|
||||
message: input.message,
|
||||
traceId: input.traceId ?? null,
|
||||
action: input.action ?? null,
|
||||
}
|
||||
const all = [...toasts.value, next]
|
||||
const overflow = all.slice(0, Math.max(0, all.length - MAX_VISIBLE_TOASTS))
|
||||
overflow.forEach((toast) => stopTimer(toast.id))
|
||||
toasts.value = all.slice(-MAX_VISIBLE_TOASTS)
|
||||
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), TOAST_DURATION_MS[input.kind]),
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
function clear() {
|
||||
timers.forEach((timer) => clearTimeout(timer))
|
||||
timers.clear()
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
return { toasts, show, dismiss, clear }
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 전역 기본 스타일 — 요소 기본값과 공용 유틸만 둔다.
|
||||
* 값은 tokens.css 의 변수만 쓴다. 화면별 모양은 각 컴포넌트의 <style scoped> 에 둔다.
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-brand);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: var(--space-1);
|
||||
height: var(--space-1);
|
||||
margin: calc(var(--space-1) * -1);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 디자인 토큰 — 색 · 간격 · 글자 크기는 이 파일 하나에만 둔다.
|
||||
* 기준: docs/design/webmail (확정본) — 브랜드 "바다 청록" 바탕 #0B3640 · 강조 #0E7C86
|
||||
* 컴포넌트는 <style scoped> 에서 var(--…) 로만 쓰고, 치수는 rem(16px = 1rem)으로 적는다.
|
||||
*
|
||||
* 화면 폭 기준 (CSS 변수는 미디어 쿼리에 쓸 수 없어 값으로 적는다):
|
||||
* 70rem(1120px) 이상 — 레일 · 메일함 · 목록 · 본문이 한 줄에 놓이고 칸마다 따로 스크롤된다.
|
||||
* 그보다 좁으면 칸이 아래로 접히고 화면 전체가 스크롤된다.
|
||||
* 목록 보기 표(MessageTable) — 68.6875rem(1099px) 이하에서 크기 칸 숨김, 37.4375rem(599px) 이하에서 칸 축소.
|
||||
*/
|
||||
:root {
|
||||
/* ── 글꼴 ─────────────────────────────── */
|
||||
--font-family-base:
|
||||
'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
|
||||
'Malgun Gothic', system-ui, sans-serif;
|
||||
|
||||
--font-size-caption: 0.6875rem; /* 11px — 앱 레일 이름 */
|
||||
--font-size-small: 0.75rem; /* 12px — 시각·개수·보조 설명 */
|
||||
--font-size-label: 0.78125rem; /* 12.5px — 입력 이름·안내 */
|
||||
--font-size-base: 0.8125rem; /* 13px — 기본 */
|
||||
--font-size-emphasis: 0.84375rem; /* 13.5px — 주요 버튼·보낸 사람 */
|
||||
--font-size-body: 0.875rem; /* 14px — 본문·입력 */
|
||||
--font-size-title-sm: 0.9375rem; /* 15px — 영역 제목 */
|
||||
--font-size-title: 1rem; /* 16px — 상단 바 모듈 이름 */
|
||||
--font-size-heading: 1.25rem; /* 20px — 메일 제목 */
|
||||
--font-size-heading-lg: 1.375rem; /* 22px — 로그인 제목 */
|
||||
--font-size-display: 1.875rem; /* 30px — 로그인 소개 문구 */
|
||||
|
||||
--line-height-base: 1.5;
|
||||
--line-height-body: 1.75;
|
||||
--line-height-tight: 1.3;
|
||||
--line-height-heading: 1.4;
|
||||
|
||||
--letter-spacing-tight: -0.01em;
|
||||
--letter-spacing-tighter: -0.015em;
|
||||
--letter-spacing-heading: -0.02em;
|
||||
--letter-spacing-display: -0.025em;
|
||||
|
||||
/* ── 색: 브랜드 ─────────────────────────── */
|
||||
--color-brand-surface: #0b3640; /* 레일 · 로그인 패널 · 성공 알림 바탕 */
|
||||
--color-brand-surface-active: #155a64; /* 레일 현재 모듈 */
|
||||
--color-brand: #0e7c86; /* 강조 버튼 · 링크 · 진행률 */
|
||||
--color-brand-strong: #0a5f67; /* 강조 글자 · 눌린 상태 */
|
||||
--color-brand-subtle: #e3f2f3; /* 선택한 행 · 현재 메일함 · 아바타 */
|
||||
--color-on-brand: #ffffff;
|
||||
--color-on-brand-text: #e6eff0;
|
||||
--color-on-brand-muted: #a3c2c6;
|
||||
--color-on-brand-link: #9ed6db;
|
||||
--color-on-brand-success: #9fe0b5;
|
||||
|
||||
/* ── 색: 글자 ───────────────────────────── */
|
||||
--color-text: #171c26;
|
||||
--color-text-body: #1f2733; /* 메일 본문 */
|
||||
--color-text-strong: #344054; /* 입력 이름 · 도구 버튼 */
|
||||
--color-text-secondary: #4a5363;
|
||||
--color-text-muted: #667085;
|
||||
--color-icon-muted: #98a2b3;
|
||||
|
||||
/* ── 색: 면 · 선 ────────────────────────── */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-panel: #f8f9fb; /* 메일함 패널 */
|
||||
--color-surface-muted: #f2f4f7; /* 받는 사람 토큰 · 파일 아이콘 */
|
||||
--color-surface-sunken: #fbfbfc; /* 목록 표 머리 줄 */
|
||||
--color-row-hover: #f3f8f8; /* 목록 표 행 hover */
|
||||
--color-border: #e6e8ec;
|
||||
--color-border-subtle: #eef0f3;
|
||||
--color-border-strong: #d0d5dd; /* 보기 전환 세그먼트 테두리 */
|
||||
--color-border-input: #8a94a3;
|
||||
--color-skeleton: #e4e7ec; /* 불러오는 중 막대 · 진행률 바탕 */
|
||||
|
||||
/* ── 색: 상태 ───────────────────────────── */
|
||||
--color-danger: #b42318;
|
||||
--color-danger-surface: #fef3f2;
|
||||
--color-danger-border: #fbd7d3;
|
||||
--color-warning-surface: #fff7e6;
|
||||
--color-warning-text: #7a5200;
|
||||
--color-star: #b7791f;
|
||||
|
||||
/* ── 간격 (이름의 숫자 = px) ─────────────── */
|
||||
--space-1: 0.0625rem;
|
||||
--space-2: 0.125rem;
|
||||
--space-3: 0.1875rem;
|
||||
--space-4: 0.25rem;
|
||||
--space-6: 0.375rem;
|
||||
--space-8: 0.5rem;
|
||||
--space-9: 0.5625rem;
|
||||
--space-10: 0.625rem;
|
||||
--space-12: 0.75rem;
|
||||
--space-14: 0.875rem;
|
||||
--space-16: 1rem;
|
||||
--space-18: 1.125rem;
|
||||
--space-20: 1.25rem;
|
||||
--space-22: 1.375rem;
|
||||
--space-24: 1.5rem;
|
||||
--space-28: 1.75rem;
|
||||
--space-36: 2.25rem;
|
||||
--space-40: 2.5rem;
|
||||
--space-44: 2.75rem;
|
||||
--space-48: 3rem;
|
||||
--space-56: 3.5rem;
|
||||
--space-80: 5rem;
|
||||
|
||||
/* ── 모서리 ─────────────────────────────── */
|
||||
--radius-xs: 0.25rem; /* 받는 사람 토큰 */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-item: 0.4375rem; /* 메일함 항목 */
|
||||
--radius-md: 0.5rem; /* 버튼 · 입력 */
|
||||
--radius-lg: 0.625rem; /* 레일 항목 · 알림 */
|
||||
--radius-xl: 0.75rem; /* 대화상자 */
|
||||
--radius-round: 50%;
|
||||
|
||||
/* ── 선 · 그림자 ────────────────────────── */
|
||||
--border-width: 0.0625rem;
|
||||
--focus-ring-width: 0.125rem;
|
||||
--focus-ring-offset: 0.125rem;
|
||||
--shadow-toast: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.12);
|
||||
--shadow-toast-strong: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.22);
|
||||
--shadow-dialog: 0 0.875rem 2.25rem rgba(16, 24, 28, 0.14);
|
||||
--color-backdrop: rgba(11, 54, 64, 0.32);
|
||||
|
||||
/* ── 크기 ───────────────────────────────── */
|
||||
--size-touch: 2.75rem; /* 44px — 누를 수 있는 최소 영역 */
|
||||
/*
|
||||
* 밀도를 위한 작은 조작 영역 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "디자인 원칙", 사용자 확정).
|
||||
* 목록 표의 별 버튼(폭 32px · 행 높이를 채움)과 "분할 | 목록" 전환(바깥 36px)에만 쓴다.
|
||||
*/
|
||||
--size-compact-control: 2rem; /* 32px */
|
||||
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
|
||||
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 버튼 폭 (높이는 44px) */
|
||||
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
|
||||
--size-rail-item: 3.75rem; /* 60px */
|
||||
--size-row-min: 2.5rem; /* 40px — 메일함 항목 */
|
||||
--size-header-min: 3.25rem; /* 52px — 목록 · 본문 머리 */
|
||||
--size-topbar-min: 3.5rem; /* 56px — 상단 바 */
|
||||
|
||||
/* ── 메일 목록 행 ───────────────────────── */
|
||||
--size-list-row: 3.125rem; /* 50px — 분할 보기 2줄 행 */
|
||||
--size-table-head: 2rem; /* 32px — 목록 보기 표 머리 줄 */
|
||||
--size-table-row: 2.375rem; /* 38px — 목록 보기 표 행 */
|
||||
--table-gap: 0.75rem; /* 12px — 표 칸 사이 */
|
||||
--table-col-star: 2rem; /* 32px */
|
||||
--table-col-who: 10.5rem; /* 168px */
|
||||
--table-col-who-narrow: 7rem; /* 112px — 600px 미만 */
|
||||
--table-col-date: 5.25rem; /* 84px */
|
||||
--table-col-date-narrow: 4.5rem; /* 72px — 600px 미만 */
|
||||
--table-col-size: 4rem; /* 64px */
|
||||
--table-min-width: 25rem; /* 400px — 이보다 좁으면 가로 스크롤 */
|
||||
|
||||
/*
|
||||
* ── 콘텐츠 열 (읽기 · 쓰기 공통) ─────────────
|
||||
* 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 세 곳이 이 값만 쓴다 (디자인 README "콘텐츠 열").
|
||||
*/
|
||||
--content-max-width: 50rem; /* 800px */
|
||||
--content-inline: 2rem; /* 32px — 좌우 여백. 제목·입력·툴바 첫 아이콘이 모두 이 선에서 시작한다 */
|
||||
--content-block-start: 1.25rem; /* 20px */
|
||||
--content-block-end: 2.25rem; /* 36px */
|
||||
--content-measure: 42.5rem; /* 680px — 본문 글줄 최대 폭 */
|
||||
--toolbar-button-inline: 0.625rem; /* 10px — 툴바 버튼 안쪽 좌우 여백 */
|
||||
/* 22px — 툴바 안쪽 여백. 첫 버튼의 안쪽 여백(10px)을 더하면 아이콘이 32px 선에 온다 */
|
||||
--content-toolbar-start: calc(var(--content-inline) - var(--toolbar-button-inline));
|
||||
/* 744px — 쓰기 본문 입력칸 = 글줄 680 + 좌우 여백 */
|
||||
--compose-body-max-width: calc(var(--content-measure) + var(--content-inline) * 2);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 단위 테스트 전용 — BroadcastChannel 대역. 같은 이름의 채널끼리 메시지를 주고받는다.
|
||||
// 실제 브라우저처럼 보낸 객체 자신에게는 보내지 않고, 받는 쪽에는 다음 작업(task)으로 전달한다.
|
||||
// 사용: vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel) — "다른 탭"은 new FakeBroadcastChannel(이름) 으로 만든다.
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
export class FakeBroadcastChannel extends EventTarget {
|
||||
private static readonly open = new Set<FakeBroadcastChannel>()
|
||||
|
||||
readonly name: string
|
||||
private closed = false
|
||||
|
||||
constructor(name: string) {
|
||||
super()
|
||||
this.name = name
|
||||
FakeBroadcastChannel.open.add(this)
|
||||
}
|
||||
|
||||
postMessage(data: unknown): void {
|
||||
if (this.closed) throw new DOMException('채널이 닫혔다', 'InvalidStateError')
|
||||
for (const other of FakeBroadcastChannel.open) {
|
||||
if (other === this || other.name !== this.name) continue
|
||||
setTimeout(() => {
|
||||
if (!other.closed) other.dispatchEvent(new MessageEvent('message', { data }))
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true
|
||||
FakeBroadcastChannel.open.delete(this)
|
||||
}
|
||||
|
||||
/** 테스트가 끝날 때 남은 채널을 모두 닫는다 */
|
||||
static closeAll(): void {
|
||||
FakeBroadcastChannel.open.forEach((channel) => channel.close())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 단위 테스트 전용 — 중앙 axios 인스턴스의 어댑터(실제 전송부)만 바꿔 끼운다.
|
||||
// 인터셉터(언래핑·에러 코드·401 재시도)는 그대로 거치므로 실제 흐름을 검증할 수 있다.
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
import {
|
||||
AxiosError,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosProgressEvent,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
import api from '@/composables/useApi'
|
||||
|
||||
export interface MockReply {
|
||||
status: number
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/** 'network' — 응답 없음(네트워크 단절) / 'cancel' — 호출부가 취소한 요청 */
|
||||
export type MockResult = MockReply | 'network' | 'cancel'
|
||||
|
||||
export type MockHandler = (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
|
||||
export interface MockRequest {
|
||||
method: string
|
||||
/** baseURL 을 뺀 경로 (예: /messages/m1) */
|
||||
url: string
|
||||
params: Record<string, unknown>
|
||||
data: unknown
|
||||
config: InternalAxiosRequestConfig
|
||||
}
|
||||
|
||||
/** 표준 성공 응답 { success: true, data } */
|
||||
export function ok(data: unknown, status = 200): MockReply {
|
||||
return { status, data: { success: true, data } }
|
||||
}
|
||||
|
||||
/** 표준 실패 응답 { success: false, error: { code, message, traceId } } */
|
||||
export function fail(status: number, code: string, traceId = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'): MockReply {
|
||||
return { status, data: { success: false, error: { code, message: '서버 메시지', traceId } } }
|
||||
}
|
||||
|
||||
function parseBody(data: unknown): unknown {
|
||||
if (typeof data !== 'string') return data
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/** 업로드 진행 이벤트를 흉내 낸다 (onUploadProgress) */
|
||||
export function emitUploadProgress(request: MockRequest, loaded: number, total: number) {
|
||||
const event: AxiosProgressEvent = { loaded, total, bytes: loaded, lengthComputable: true }
|
||||
request.config.onUploadProgress?.(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 가짜 어댑터를 끼우고 보낸 요청 목록을 돌려준다.
|
||||
* 테스트가 끝나면 restoreApiAdapter() 로 원래 어댑터를 되돌린다.
|
||||
*/
|
||||
const originalAdapter = api.defaults.adapter
|
||||
|
||||
export function installMockApi(handler: MockHandler): MockRequest[] {
|
||||
const requests: MockRequest[] = []
|
||||
|
||||
const adapter: AxiosAdapter = async (config) => {
|
||||
const request: MockRequest = {
|
||||
method: (config.method ?? 'get').toUpperCase(),
|
||||
url: config.url ?? '',
|
||||
params: (config.params as Record<string, unknown> | undefined) ?? {},
|
||||
data: parseBody(config.data),
|
||||
config,
|
||||
}
|
||||
requests.push(request)
|
||||
|
||||
const result = await handler(request)
|
||||
if (result === 'cancel') throw new CanceledError(undefined, undefined, config)
|
||||
if (result === 'network') {
|
||||
throw new AxiosError('Network Error', AxiosError.ERR_NETWORK, config)
|
||||
}
|
||||
|
||||
const response: AxiosResponse = {
|
||||
data: result.data ?? '',
|
||||
status: result.status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
if (result.status >= 200 && result.status < 300) return response
|
||||
throw new AxiosError(
|
||||
`Request failed with status code ${result.status}`,
|
||||
AxiosError.ERR_BAD_RESPONSE,
|
||||
config,
|
||||
null,
|
||||
response,
|
||||
)
|
||||
}
|
||||
|
||||
api.defaults.adapter = adapter
|
||||
return requests
|
||||
}
|
||||
|
||||
export function restoreApiAdapter() {
|
||||
api.defaults.adapter = originalAdapter
|
||||
}
|
||||
|
||||
/** 대기 중인 Promise 들이 끝나도록 한 번 양보한다 */
|
||||
export function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user