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:
@@ -0,0 +1,37 @@
|
||||
// 단위 테스트 전용 — BroadcastChannel 대역. 같은 이름의 채널끼리 메시지를 주고받는다.
|
||||
// 실제 브라우저처럼 보낸 객체 자신에게는 보내지 않고, 받는 쪽에는 다음 작업(task)으로 전달한다.
|
||||
// 사용: vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel) — "다른 탭"은 new FakeBroadcastChannel(이름) 으로 만든다.
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
export class FakeBroadcastChannel extends EventTarget {
|
||||
private static readonly open = new Set<FakeBroadcastChannel>()
|
||||
|
||||
readonly name: string
|
||||
private closed = false
|
||||
|
||||
constructor(name: string) {
|
||||
super()
|
||||
this.name = name
|
||||
FakeBroadcastChannel.open.add(this)
|
||||
}
|
||||
|
||||
postMessage(data: unknown): void {
|
||||
if (this.closed) throw new DOMException('채널이 닫혔다', 'InvalidStateError')
|
||||
for (const other of FakeBroadcastChannel.open) {
|
||||
if (other === this || other.name !== this.name) continue
|
||||
setTimeout(() => {
|
||||
if (!other.closed) other.dispatchEvent(new MessageEvent('message', { data }))
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true
|
||||
FakeBroadcastChannel.open.delete(this)
|
||||
}
|
||||
|
||||
/** 테스트가 끝날 때 남은 채널을 모두 닫는다 */
|
||||
static closeAll(): void {
|
||||
FakeBroadcastChannel.open.forEach((channel) => channel.close())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 단위 테스트 전용 — 중앙 axios 인스턴스의 어댑터(실제 전송부)만 바꿔 끼운다.
|
||||
// 인터셉터(언래핑·에러 코드·401 재시도)는 그대로 거치므로 실제 흐름을 검증할 수 있다.
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
import {
|
||||
AxiosError,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosProgressEvent,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
import api from '@/composables/useApi'
|
||||
|
||||
export interface MockReply {
|
||||
status: number
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/** 'network' — 응답 없음(네트워크 단절) / 'cancel' — 호출부가 취소한 요청 */
|
||||
export type MockResult = MockReply | 'network' | 'cancel'
|
||||
|
||||
export type MockHandler = (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
|
||||
export interface MockRequest {
|
||||
method: string
|
||||
/** baseURL 을 뺀 경로 (예: /messages/m1) */
|
||||
url: string
|
||||
params: Record<string, unknown>
|
||||
data: unknown
|
||||
config: InternalAxiosRequestConfig
|
||||
}
|
||||
|
||||
/** 표준 성공 응답 { success: true, data } */
|
||||
export function ok(data: unknown, status = 200): MockReply {
|
||||
return { status, data: { success: true, data } }
|
||||
}
|
||||
|
||||
/** 표준 실패 응답 { success: false, error: { code, message, traceId } } */
|
||||
export function fail(status: number, code: string, traceId = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'): MockReply {
|
||||
return { status, data: { success: false, error: { code, message: '서버 메시지', traceId } } }
|
||||
}
|
||||
|
||||
function parseBody(data: unknown): unknown {
|
||||
if (typeof data !== 'string') return data
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/** 업로드 진행 이벤트를 흉내 낸다 (onUploadProgress) */
|
||||
export function emitUploadProgress(request: MockRequest, loaded: number, total: number) {
|
||||
const event: AxiosProgressEvent = { loaded, total, bytes: loaded, lengthComputable: true }
|
||||
request.config.onUploadProgress?.(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 가짜 어댑터를 끼우고 보낸 요청 목록을 돌려준다.
|
||||
* 테스트가 끝나면 restoreApiAdapter() 로 원래 어댑터를 되돌린다.
|
||||
*/
|
||||
const originalAdapter = api.defaults.adapter
|
||||
|
||||
export function installMockApi(handler: MockHandler): MockRequest[] {
|
||||
const requests: MockRequest[] = []
|
||||
|
||||
const adapter: AxiosAdapter = async (config) => {
|
||||
const request: MockRequest = {
|
||||
method: (config.method ?? 'get').toUpperCase(),
|
||||
url: config.url ?? '',
|
||||
params: (config.params as Record<string, unknown> | undefined) ?? {},
|
||||
data: parseBody(config.data),
|
||||
config,
|
||||
}
|
||||
requests.push(request)
|
||||
|
||||
const result = await handler(request)
|
||||
if (result === 'cancel') throw new CanceledError(undefined, undefined, config)
|
||||
if (result === 'network') {
|
||||
throw new AxiosError('Network Error', AxiosError.ERR_NETWORK, config)
|
||||
}
|
||||
|
||||
const response: AxiosResponse = {
|
||||
data: result.data ?? '',
|
||||
status: result.status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
if (result.status >= 200 && result.status < 300) return response
|
||||
throw new AxiosError(
|
||||
`Request failed with status code ${result.status}`,
|
||||
AxiosError.ERR_BAD_RESPONSE,
|
||||
config,
|
||||
null,
|
||||
response,
|
||||
)
|
||||
}
|
||||
|
||||
api.defaults.adapter = adapter
|
||||
return requests
|
||||
}
|
||||
|
||||
export function restoreApiAdapter() {
|
||||
api.defaults.adapter = originalAdapter
|
||||
}
|
||||
|
||||
/** 대기 중인 Promise 들이 끝나도록 한 번 양보한다 */
|
||||
export function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 단위 테스트 전용 — 앱과 같은 이름의 라우트를 가진 메모리 라우터 (화면은 빈 컴포넌트)
|
||||
import { h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
|
||||
|
||||
const Empty: FunctionalComponent = () => h('div')
|
||||
|
||||
export function createTestRouter(): Router {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: Empty },
|
||||
{ path: '/login', name: 'login', component: Empty },
|
||||
{ path: '/mail', name: 'mail', component: Empty, meta: { requiresAuth: true } },
|
||||
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { requiresAuth: true } },
|
||||
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/mail/:mailboxId/:messageId',
|
||||
name: 'mail-message',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user