diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-06 18:30:04 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-06 18:30:04 +0900 |
| commit | 3923eb2f86c304bbd90c4eae9a338f7bc21c9e90 (patch) | |
| tree | afa2a4053cc91eb8379e26a08538cfd58c1479bc /src/client/stores/auth.test.tsx | |
| parent | e367c698e03c41c292c3dd5c07bad0a870c3ebc4 (diff) | |
| download | kioku-3923eb2f86c304bbd90c4eae9a338f7bc21c9e90.tar.gz kioku-3923eb2f86c304bbd90c4eae9a338f7bc21c9e90.tar.zst kioku-3923eb2f86c304bbd90c4eae9a338f7bc21c9e90.zip | |
feat(client): add auth store with React context
Implements token management via AuthProvider context that wraps the app.
Provides useAuth hook for components to access auth state and actions
(login, register, logout). Includes comprehensive tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'src/client/stores/auth.test.tsx')
| -rw-r--r-- | src/client/stores/auth.test.tsx | 205 |
1 files changed, 205 insertions, 0 deletions
diff --git a/src/client/stores/auth.test.tsx b/src/client/stores/auth.test.tsx new file mode 100644 index 0000000..ab6b554 --- /dev/null +++ b/src/client/stores/auth.test.tsx @@ -0,0 +1,205 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClient } from "../api/client"; +import { AuthProvider, useAuth } from "./auth"; + +// Mock the apiClient +vi.mock("../api/client", () => ({ + apiClient: { + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn(), + getTokens: vi.fn(), + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + <AuthProvider>{children}</AuthProvider> +); + +describe("useAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.getTokens).mockReturnValue(null); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("throws error when used outside AuthProvider", () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + renderHook(() => useAuth()); + }).toThrow("useAuth must be used within an AuthProvider"); + + consoleSpy.mockRestore(); + }); + + it("returns initial unauthenticated state", async () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it("returns authenticated state when tokens exist", async () => { + vi.mocked(apiClient.getTokens).mockReturnValue({ + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAuthenticated).toBe(true); + }); + + describe("login", () => { + it("logs in and sets user", async () => { + const mockUser = { id: "user-1", username: "testuser" }; + vi.mocked(apiClient.login).mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: mockUser, + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.login("testuser", "password123"); + }); + + expect(apiClient.login).toHaveBeenCalledWith("testuser", "password123"); + expect(result.current.user).toEqual(mockUser); + }); + + it("propagates login errors", async () => { + vi.mocked(apiClient.login).mockRejectedValue( + new Error("Invalid credentials"), + ); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect( + act(async () => { + await result.current.login("testuser", "wrongpassword"); + }), + ).rejects.toThrow("Invalid credentials"); + }); + }); + + describe("register", () => { + it("registers and logs in automatically", async () => { + const mockUser = { id: "user-1", username: "newuser" }; + vi.mocked(apiClient.register).mockResolvedValue({ user: mockUser }); + vi.mocked(apiClient.login).mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: mockUser, + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.register("newuser", "password123"); + }); + + expect(apiClient.register).toHaveBeenCalledWith("newuser", "password123"); + expect(apiClient.login).toHaveBeenCalledWith("newuser", "password123"); + expect(result.current.user).toEqual(mockUser); + }); + + it("propagates registration errors", async () => { + vi.mocked(apiClient.register).mockRejectedValue( + new Error("Username taken"), + ); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect( + act(async () => { + await result.current.register("existinguser", "password123"); + }), + ).rejects.toThrow("Username taken"); + }); + }); + + describe("logout", () => { + it("logs out and clears user", async () => { + const mockUser = { id: "user-1", username: "testuser" }; + vi.mocked(apiClient.login).mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + user: mockUser, + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Login first + await act(async () => { + await result.current.login("testuser", "password123"); + }); + + expect(result.current.user).toEqual(mockUser); + + // Now logout + vi.mocked(apiClient.isAuthenticated).mockReturnValue(false); + act(() => { + result.current.logout(); + }); + + expect(apiClient.logout).toHaveBeenCalled(); + expect(result.current.user).toBeNull(); + }); + }); +}); |
