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:
2026-10-27 01:31:59 +09:00
parent 71f2e01d83
commit 468a4377ee
104 changed files with 14136 additions and 747 deletions
+467 -2
View File
@@ -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)
})
})
+250 -19
View File
@@ -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,
}
}
+325
View File
@@ -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'])
})
})
})
})
+62 -18
View File
@@ -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)
}
+164
View File
@@ -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 }
}
+15
View File
@@ -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 }
}