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, setTokens, clearTokens, getAccessToken } from "@/lib/auth/tokenProvider" const BASE = "http://localhost:8001/api/v1" function envelope(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 인터셉터 — Bearer 모드 refresh", () => { afterEach(() => clearTokens()) it("refreshToken 있으면 body 로 보내고, 응답 토큰을 저장한 뒤 새 Bearer 로 재시도", async () => { setTokens({ token: "old", refreshToken: "ref" }) const authHeaders: string[] = [] clientMock.onGet("/users/me").reply((config) => { authHeaders.push(String(config.headers?.Authorization)) return authHeaders.length === 1 ? [401, errorEnvelope({ statusCode: 401, message: "expired" })] : [200, envelope({ id: "u1" })] }) globalMock .onPost(`${BASE}/auth/refresh`) .reply(200, envelope({ token: "new", refreshToken: "ref", tokenExpirationTime: 1 })) await apiGet<{ id: string }>("/users/me") const refreshReq = globalMock.history.post.find((r) => r.url?.endsWith("/auth/refresh")) expect(JSON.parse(refreshReq?.data as string)).toEqual({ refreshToken: "ref" }) expect(getAccessToken()).toBe("new") expect(authHeaders).toEqual(["Bearer old", "Bearer new"]) }) }) 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() }) })