Files
cozsweet-frontend-nextjs/src/data/services/api/__tests__/http_client.test.ts
T
admin 225d232763 fix(auth): handle logout and refresh results
Propagate storage Result failures through logout, guest restoration, token refresh, and user state actors. Clear invalid sessions after failed automatic refreshes and cover both business and guest flows.
2026-07-13 17:23:54 +08:00

266 lines
8.9 KiB
TypeScript

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<typeof fetch>): 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<string | null> = [];
const fetchMock = vi.fn<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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));
});
});