diff options
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/main.tsx | 5 | ||||
| -rw-r--r-- | src/client/stores/auth.test.tsx | 205 | ||||
| -rw-r--r-- | src/client/stores/auth.tsx | 94 | ||||
| -rw-r--r-- | src/client/stores/index.ts | 7 |
4 files changed, 310 insertions, 1 deletions
diff --git a/src/client/main.tsx b/src/client/main.tsx index 1e185be..5a12f68 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { AuthProvider } from "./stores"; const rootElement = document.getElementById("root"); if (!rootElement) { @@ -9,6 +10,8 @@ if (!rootElement) { createRoot(rootElement).render( <StrictMode> - <App /> + <AuthProvider> + <App /> + </AuthProvider> </StrictMode>, ); 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(); + }); + }); +}); diff --git a/src/client/stores/auth.tsx b/src/client/stores/auth.tsx new file mode 100644 index 0000000..cca314a --- /dev/null +++ b/src/client/stores/auth.tsx @@ -0,0 +1,94 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { ApiClientError, apiClient } from "../api/client"; +import type { User } from "../api/types"; + +export interface AuthState { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export interface AuthActions { + login: (username: string, password: string) => Promise<void>; + register: (username: string, password: string) => Promise<void>; + logout: () => void; +} + +export type AuthContextValue = AuthState & AuthActions; + +const AuthContext = createContext<AuthContextValue | null>(null); + +export interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState<User | null>(null); + const [isLoading, setIsLoading] = useState(true); + + // Check for existing auth on mount + useEffect(() => { + const tokens = apiClient.getTokens(); + if (tokens) { + // We have tokens stored, but we don't have user info cached + // For now, just set authenticated state. User info will be fetched when needed. + // In a full implementation, we'd decode the JWT or call an API endpoint + setIsLoading(false); + } else { + setIsLoading(false); + } + }, []); + + const login = useCallback(async (username: string, password: string) => { + const response = await apiClient.login(username, password); + setUser(response.user); + }, []); + + const register = useCallback( + async (username: string, password: string) => { + await apiClient.register(username, password); + // After registration, log in automatically + await login(username, password); + }, + [login], + ); + + const logout = useCallback(() => { + apiClient.logout(); + setUser(null); + }, []); + + const isAuthenticated = apiClient.isAuthenticated(); + + const value = useMemo<AuthContextValue>( + () => ({ + user, + isAuthenticated, + isLoading, + login, + register, + logout, + }), + [user, isAuthenticated, isLoading, login, register, logout], + ); + + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +export { ApiClientError }; diff --git a/src/client/stores/index.ts b/src/client/stores/index.ts new file mode 100644 index 0000000..7117bd6 --- /dev/null +++ b/src/client/stores/index.ts @@ -0,0 +1,7 @@ +export type { + AuthActions, + AuthContextValue, + AuthProviderProps, + AuthState, +} from "./auth"; +export { ApiClientError, AuthProvider, useAuth } from "./auth"; |
