From e367c698e03c41c292c3dd5c07bad0a870c3ebc4 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 6 Dec 2025 18:11:14 +0900 Subject: feat(client): add API client with auth header support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements fetch wrapper that handles JWT authentication, automatic token refresh on 401 responses, and provides typed methods for REST operations. Includes comprehensive tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/client/api/client.test.ts | 267 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 src/client/api/client.test.ts (limited to 'src/client/api/client.test.ts') diff --git a/src/client/api/client.test.ts b/src/client/api/client.test.ts new file mode 100644 index 0000000..3cfe190 --- /dev/null +++ b/src/client/api/client.test.ts @@ -0,0 +1,267 @@ +/** + * @vitest-environment jsdom + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import { + ApiClient, + ApiClientError, + localStorageTokenStorage, + type TokenStorage, +} from "./client"; + +function createMockTokenStorage(): TokenStorage & { + getTokens: Mock; + setTokens: Mock; + clearTokens: Mock; +} { + return { + getTokens: vi.fn(), + setTokens: vi.fn(), + clearTokens: vi.fn(), + }; +} + +function mockFetch(responses: Array<{ status: number; body?: unknown }>) { + let callIndex = 0; + return vi.fn(async () => { + const response = responses[callIndex++]; + if (!response) { + throw new Error("Unexpected fetch call"); + } + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + json: async () => response.body, + }; + }) as Mock; +} + +describe("ApiClient", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("register", () => { + it("sends registration request without auth header", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ + tokenStorage: mockStorage, + baseUrl: "http://localhost:3000", + }); + + const responseBody = { user: { id: "123", username: "testuser" } }; + global.fetch = mockFetch([{ status: 201, body: responseBody }]); + + const result = await client.register("testuser", "password123"); + + expect(result).toEqual(responseBody); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3000/api/auth/register", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + username: "testuser", + password: "password123", + }), + }), + ); + + const call = (global.fetch as Mock).mock.calls[0] as [ + string, + RequestInit, + ]; + const headers = call[1].headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + it("throws ApiClientError on registration failure", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + global.fetch = mockFetch([ + { + status: 409, + body: { error: "Username already exists", code: "USERNAME_EXISTS" }, + }, + ]); + + try { + await client.register("testuser", "password"); + expect.fail("Expected ApiClientError to be thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiClientError); + const error = e as ApiClientError; + expect(error.message).toBe("Username already exists"); + expect(error.status).toBe(409); + expect(error.code).toBe("USERNAME_EXISTS"); + } + }); + }); + + describe("login", () => { + it("sends login request and stores tokens", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + const responseBody = { + accessToken: "access-token", + refreshToken: "refresh-token", + user: { id: "123", username: "testuser" }, + }; + global.fetch = mockFetch([{ status: 200, body: responseBody }]); + + const result = await client.login("testuser", "password123"); + + expect(result).toEqual(responseBody); + expect(mockStorage.setTokens).toHaveBeenCalledWith({ + accessToken: "access-token", + refreshToken: "refresh-token", + }); + }); + + it("throws ApiClientError on invalid credentials", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + global.fetch = mockFetch([ + { + status: 401, + body: { + error: "Invalid username or password", + code: "INVALID_CREDENTIALS", + }, + }, + ]); + + try { + await client.login("testuser", "wrongpassword"); + expect.fail("Expected ApiClientError to be thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiClientError); + const error = e as ApiClientError; + expect(error.message).toBe("Invalid username or password"); + expect(error.status).toBe(401); + expect(error.code).toBe("INVALID_CREDENTIALS"); + } + }); + }); + + describe("logout", () => { + it("clears tokens from storage", () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + client.logout(); + + expect(mockStorage.clearTokens).toHaveBeenCalled(); + }); + }); + + describe("isAuthenticated", () => { + it("returns true when tokens exist", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue({ + accessToken: "token", + refreshToken: "refresh", + }); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.isAuthenticated()).toBe(true); + }); + + it("returns false when no tokens", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue(null); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.isAuthenticated()).toBe(false); + }); + }); + + describe("getAuthHeader", () => { + it("returns auth header when tokens exist", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue({ + accessToken: "my-access-token", + refreshToken: "my-refresh-token", + }); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.getAuthHeader()).toEqual({ + Authorization: "Bearer my-access-token", + }); + }); + + it("returns undefined when no tokens", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue(null); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.getAuthHeader()).toBeUndefined(); + }); + }); + + describe("rpc client", () => { + it("exposes typed RPC client", () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + // RPC client should have auth routes + expect(client.rpc.api.auth.login).toBeDefined(); + expect(client.rpc.api.auth.register).toBeDefined(); + expect(client.rpc.api.auth.refresh).toBeDefined(); + }); + }); +}); + +describe("localStorageTokenStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("stores and retrieves tokens", () => { + const tokens = { accessToken: "access", refreshToken: "refresh" }; + localStorageTokenStorage.setTokens(tokens); + + const retrieved = localStorageTokenStorage.getTokens(); + expect(retrieved).toEqual(tokens); + }); + + it("returns null when no tokens stored", () => { + expect(localStorageTokenStorage.getTokens()).toBeNull(); + }); + + it("clears tokens", () => { + localStorageTokenStorage.setTokens({ + accessToken: "a", + refreshToken: "r", + }); + localStorageTokenStorage.clearTokens(); + + expect(localStorageTokenStorage.getTokens()).toBeNull(); + }); + + it("returns null on invalid JSON", () => { + localStorage.setItem("kioku_tokens", "not-valid-json"); + expect(localStorageTokenStorage.getTokens()).toBeNull(); + }); +}); -- cgit v1.2.3-70-g09d2