import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStorage } from "unstorage"; import memoryDriver from "unstorage/drivers/memory"; import { AuthStorage } from "@/data/storage/auth"; import { deviceIdentifier, Result, SpAsyncUtil } from "@/utils"; import { ErrorCode } from "../api_result"; import { createHttpClient } from "../http_client"; function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" }, ...init, }); } function callUrl(call: Parameters): URL { const request = call[0]; return new URL(request instanceof Request ? request.url : String(request)); } describe("createHttpClient", () => { beforeEach(() => { process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test"; SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() })); AuthStorage._resetForTests(); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("refreshes a business token and replays the original request once after 401", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setLoginToken("old-login-token"); await authStorage.setRefreshToken("refresh-token"); const requestAuthHeaders: Array = []; const fetchMock = vi.fn(async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const headers = input instanceof Request ? input.headers : new Headers(init?.headers); requestAuthHeaders.push(headers.get("Authorization")); if (url.pathname === "/api/auth/refresh") { return jsonResponse({ success: true, data: { token: "new-login-token", refreshToken: "new-refresh-token", }, }); } if ( url.pathname === "/api/chat/history" && fetchMock.mock.calls.filter( (call) => callUrl(call).pathname === "/api/chat/history", ).length === 1 ) { return jsonResponse( { message: "expired" }, { status: 401, statusText: "Unauthorized" }, ); } return jsonResponse({ success: true, data: { ok: true } }); }); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).resolves.toEqual({ success: true, data: { ok: true }, }); expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ "/api/chat/history", "/api/auth/refresh", "/api/chat/history", ]); expect(requestAuthHeaders).toEqual([ "Bearer old-login-token", null, "Bearer new-login-token", ]); }); it("rejects with an API unauthorized error when no refresh token exists", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setLoginToken("old-login-token"); const fetchMock = vi.fn(async () => jsonResponse( { message: "expired" }, { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, ), ); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).rejects.toMatchObject({ name: "ApiError", code: "HTTP_UNAUTHORIZED", status: ErrorCode.httpUnauthorized, message: "No refresh token available", }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("rejects with API status context when token refresh fails", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setLoginToken("old-login-token"); await authStorage.setRefreshToken("refresh-token"); const fetchMock = vi.fn(async (input) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.pathname === "/api/auth/refresh") { return jsonResponse( { message: "refresh broken" }, { status: ErrorCode.httpServerError, statusText: "Server Error" }, ); } return jsonResponse( { message: "expired" }, { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, ); }); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).rejects.toMatchObject({ name: "ApiError", code: "HTTP_SERVER_ERROR", status: ErrorCode.httpServerError, message: "Refresh failed", }); expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ "/api/chat/history", "/api/auth/refresh", ]); await expect(authStorage.getLoginToken()).resolves.toEqual(Result.ok(null)); await expect(authStorage.getRefreshToken()).resolves.toEqual(Result.ok(null)); }); it("rejects instead of replaying when the refreshed token cannot be stored", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setLoginToken("old-login-token"); await authStorage.setRefreshToken("refresh-token"); vi.spyOn(authStorage, "setLoginToken").mockResolvedValue( Result.err(new Error("login token write failed")), ); const fetchMock = vi.fn(async (input) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.pathname === "/api/auth/refresh") { return jsonResponse({ success: true, data: { token: "new-login-token", refreshToken: "new-refresh-token", }, }); } return jsonResponse( { message: "expired" }, { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, ); }); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).rejects.toMatchObject({ message: "login token write failed", }); expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ "/api/chat/history", "/api/auth/refresh", ]); }); it("propagates refresh-token read failures instead of treating them as missing", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setLoginToken("old-login-token"); vi.spyOn(authStorage, "getRefreshToken").mockResolvedValue( Result.err(new Error("refresh token read failed")), ); const fetchMock = vi.fn(async () => jsonResponse( { message: "expired" }, { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, ), ); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).rejects.toMatchObject({ message: "refresh token read failed", }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("propagates login-state read failures without attempting a guest refresh", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setLoginToken("old-login-token"); vi.spyOn(authStorage, "hasLoginToken").mockResolvedValue( Result.err(new Error("login state read failed")), ); const fetchMock = vi.fn(async () => jsonResponse( { message: "expired" }, { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, ), ); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).rejects.toMatchObject({ message: "login state read failed", }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("rejects a guest refresh when the new guest token cannot be stored", async () => { const authStorage = AuthStorage.getInstance(); await authStorage.setGuestToken("old-guest-token"); vi.spyOn(deviceIdentifier, "getDeviceId").mockResolvedValue("device-1"); vi.spyOn(authStorage, "setGuestToken").mockResolvedValue( Result.err(new Error("guest token write failed")), ); const fetchMock = vi.fn(async (input) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.pathname === "/api/auth/guest") { return jsonResponse({ success: true, data: { token: "new-guest-token", deviceId: "device-1" }, }); } return jsonResponse( { message: "expired" }, { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, ); }); vi.stubGlobal("fetch", fetchMock); const http = createHttpClient(); await expect(http("/api/chat/history")).rejects.toMatchObject({ message: "guest token write failed", }); expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ "/api/chat/history", "/api/auth/guest", ]); await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null)); }); });