Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import axios from "axios"
import MockAdapter from "axios-mock-adapter"
import { apiClient, apiGet, apiPost, apiList } from "./client"
import { ApiError } from "./errors"
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
import { setAccessToken } from "@/lib/auth/tokenProvider"
const BASE = "http://localhost:8001/api/v1"
function envelope<T>(data: T, meta: unknown = null, counts: number | null = null) {
return {
success: true,
statusCode: 200,
code: null,
message: null,
data,
counts,
errors: [] as string[],
timestamp: "2026-01-01T00:00:00Z",
meta,
}
}
function errorEnvelope(opts: {
statusCode: number
message: string
code?: string
errors?: string[]
}) {
return {
success: false,
statusCode: opts.statusCode,
code: opts.code ?? null,
message: opts.message,
data: null,
counts: null,
errors: opts.errors ?? [opts.message],
timestamp: "2026-01-01T00:00:00Z",
meta: null,
}
}
let clientMock: MockAdapter
let globalMock: MockAdapter
beforeEach(() => {
clientMock = new MockAdapter(apiClient)
// refresh는 글로벌 axios.post로 호출 (인터셉터 우회 위해)
globalMock = new MockAdapter(axios)
useSessionExpiryStore.setState({ open: false, queue: [] })
})
afterEach(() => {
clientMock.restore()
globalMock.restore()
})
describe("apiGet / apiPost / unwrap", () => {
it("성공 envelope이면 data만 반환", async () => {
clientMock.onGet("/users/me").reply(200, envelope({ id: "u1", email: "x@x.com" }))
const result = await apiGet<{ id: string; email: string }>("/users/me")
expect(result).toEqual({ id: "u1", email: "x@x.com" })
})
it("withCredentials=true 가 인스턴스 default로 박힘", async () => {
expect(apiClient.defaults.withCredentials).toBe(true)
})
it("baseURL 이 env 값으로 설정됨", () => {
expect(apiClient.defaults.baseURL).toBe(BASE)
})
it("apiPost: body 직렬화 + 응답 unwrap", async () => {
clientMock.onPost("/users", { email: "x@x.com" }).reply(200, envelope({ id: 1 }))
const result = await apiPost<{ id: number }>("/users", { email: "x@x.com" })
expect(result).toEqual({ id: 1 })
})
it("응답 envelope success=false 면 ApiError reject (메시지·code·errors 추출)", async () => {
clientMock.onGet("/missing").reply(
404,
errorEnvelope({
statusCode: 404,
message: "메모를 찾을 수 없음",
code: "MEMO_NOT_FOUND",
})
)
await expect(apiGet("/missing")).rejects.toMatchObject({
name: "ApiError",
status: 404,
message: "메모를 찾을 수 없음",
code: "MEMO_NOT_FOUND",
})
})
it("200 응답에 envelope.success=false 박혀와도 ApiError reject", async () => {
clientMock
.onGet("/weird")
.reply(200, errorEnvelope({ statusCode: 400, message: "validation 실패" }))
await expect(apiGet("/weird")).rejects.toBeInstanceOf(ApiError)
})
})
describe("apiList", () => {
it("envelope에서 items / meta / counts 추출", async () => {
const meta = {
currentPage: 1,
pageSize: 10,
totalItems: 25,
totalPages: 3,
hasNextPage: true,
hasPreviousPage: false,
}
clientMock.onGet("/users").reply(200, envelope([{ id: "a" }, { id: "b" }], meta, 2))
const result = await apiList<{ id: string }>("/users")
expect(result.items).toEqual([{ id: "a" }, { id: "b" }])
expect(result.meta).toEqual(meta)
expect(result.counts).toBe(2)
})
it("data가 null이면 items 빈 배열로 fallback", async () => {
clientMock.onGet("/users").reply(200, envelope(null))
const result = await apiList<{ id: string }>("/users")
expect(result.items).toEqual([])
expect(result.counts).toBe(0)
})
it("query params 전달", async () => {
clientMock.onGet("/users").reply((config) => {
expect(config.params).toEqual({ page: 2, search: "abc" })
return [200, envelope([], null, 0)]
})
await apiList<{ id: string }>("/users", { params: { page: 2, search: "abc" } })
})
})
describe("401 인터셉터 — refresh 성공", () => {
it("401 → refresh 호출 → 원 요청 재시도", async () => {
clientMock
.onGet("/users/me")
.replyOnce(401, errorEnvelope({ statusCode: 401, message: "expired" }))
.onGet("/users/me")
.replyOnce(200, envelope({ id: "u1" }))
globalMock.onPost(`${BASE}/auth/refresh`).reply(200, envelope({ token: "new" }))
const result = await apiGet<{ id: string }>("/users/me")
expect(result).toEqual({ id: "u1" })
expect(globalMock.history.post.some((r) => r.url?.endsWith("/auth/refresh"))).toBe(true)
})
})
describe("401 인터셉터 — refresh 실패", () => {
it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => {
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" }))
globalMock
.onPost(`${BASE}/auth/refresh`)
.reply(401, errorEnvelope({ statusCode: 401, message: "bad refresh" }))
// pushFailure는 retry 함수를 큐에 보관 → resolve/reject는 closeAndFlush 시점.
// 여기선 promise를 띄워두기만.
const promise = apiGet("/users/me")
// 인터셉터 처리 시간 확보
await new Promise((r) => setTimeout(r, 50))
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(true)
expect(state.queue.length).toBe(1)
// dangling promise 정리 — cancel하면 큐가 폐기됨. 결과는 cancel로 reject되지 않으니 catch만.
state.cancel()
promise.catch(() => {})
})
})
describe("401 인터셉터 — 인증 초기화", () => {
it("refresh 실패를 세션 만료 큐에 넣지 않고 호출자에게 반환", async () => {
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "no auth" }))
globalMock
.onPost(`${BASE}/auth/refresh`)
.reply(401, errorEnvelope({ statusCode: 401, message: "no refresh" }))
await expect(apiGet("/users/me", { __skipSessionExpiry: true })).rejects.toBeInstanceOf(
ApiError
)
expect(useSessionExpiryStore.getState().open).toBe(false)
expect(useSessionExpiryStore.getState().queue).toHaveLength(0)
})
})
describe("__skipAuth — public 엔드포인트 401 시 모달 안 띄움", () => {
it("logout 같은 skipAuth 요청은 401에서도 sessionExpiryStore 안 건드림", async () => {
clientMock
.onPost("/auth/logout")
.reply(401, errorEnvelope({ statusCode: 401, message: "no session" }))
await expect(apiPost("/auth/logout", undefined, { __skipAuth: true })).rejects.toBeInstanceOf(
ApiError
)
expect(useSessionExpiryStore.getState().open).toBe(false)
expect(useSessionExpiryStore.getState().queue.length).toBe(0)
})
})
describe("apiClient Bearer 주입", () => {
afterEach(() => {
setAccessToken(null)
delete apiClient.defaults.adapter
})
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
setAccessToken("tok123")
let seen: unknown
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return {
data: { success: true, data: null },
status: 200,
statusText: "OK",
headers: {},
config,
} as never
}
await apiClient.get("/ping")
expect(seen).toBe("Bearer tok123")
})
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
setAccessToken(null)
let seen: unknown = "sentinel"
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return {
data: { success: true, data: null },
status: 200,
statusText: "OK",
headers: {},
config,
} as never
}
await apiClient.get("/ping")
expect(seen).toBeUndefined()
})
})