aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/stores
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/stores')
-rw-r--r--src/client/stores/auth.test.tsx205
-rw-r--r--src/client/stores/auth.tsx94
-rw-r--r--src/client/stores/index.ts7
3 files changed, 306 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();
+ });
+ });
+});
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";