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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user