Initial Commit
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user