diff options
Diffstat (limited to 'src/client/stores')
| -rw-r--r-- | src/client/stores/auth.test.tsx | 159 | ||||
| -rw-r--r-- | src/client/stores/auth.tsx | 83 | ||||
| -rw-r--r-- | src/client/stores/index.ts | 15 | ||||
| -rw-r--r-- | src/client/stores/sync.test.tsx | 231 | ||||
| -rw-r--r-- | src/client/stores/sync.tsx | 314 |
5 files changed, 0 insertions, 802 deletions
diff --git a/src/client/stores/auth.test.tsx b/src/client/stores/auth.test.tsx deleted file mode 100644 index 72ab9e3..0000000 --- a/src/client/stores/auth.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @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(), - 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("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 deleted file mode 100644 index 58e9d40..0000000 --- a/src/client/stores/auth.tsx +++ /dev/null @@ -1,83 +0,0 @@ -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>; - 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 logout = useCallback(() => { - apiClient.logout(); - setUser(null); - }, []); - - const isAuthenticated = apiClient.isAuthenticated(); - - const value = useMemo<AuthContextValue>( - () => ({ - user, - isAuthenticated, - isLoading, - login, - logout, - }), - [user, isAuthenticated, isLoading, login, 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 deleted file mode 100644 index c7f6241..0000000 --- a/src/client/stores/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type { - AuthActions, - AuthContextValue, - AuthProviderProps, - AuthState, -} from "./auth"; -export { ApiClientError, AuthProvider, useAuth } from "./auth"; - -export type { - SyncActions, - SyncContextValue, - SyncProviderProps, - SyncState, -} from "./sync"; -export { SyncProvider, useSync } from "./sync"; diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx deleted file mode 100644 index fee79d7..0000000 --- a/src/client/stores/sync.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import "fake-indexeddb/auto"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { db } from "../db/index"; -import { SyncProvider, useSync } from "./sync"; - -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -// Mock apiClient -vi.mock("../api/client", () => ({ - apiClient: { - getAuthHeader: vi.fn(() => ({ Authorization: "Bearer token" })), - }, -})); - -const wrapper = ({ children }: { children: ReactNode }) => ( - <SyncProvider>{children}</SyncProvider> -); - -describe("useSync", () => { - beforeEach(async () => { - vi.clearAllMocks(); - localStorage.clear(); - await db.decks.clear(); - await db.cards.clear(); - await db.reviewLogs.clear(); - - // Default mock for fetch - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - decks: [], - cards: [], - reviewLogs: [], - noteTypes: [], - noteFieldTypes: [], - notes: [], - noteFieldValues: [], - conflicts: { - decks: [], - cards: [], - noteTypes: [], - noteFieldTypes: [], - notes: [], - noteFieldValues: [], - }, - currentSyncVersion: 0, - }), - }); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - localStorage.clear(); - await db.decks.clear(); - await db.cards.clear(); - await db.reviewLogs.clear(); - }); - - it("throws error when used outside SyncProvider", () => { - // Suppress console.error for this test - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - expect(() => { - renderHook(() => useSync()); - }).toThrow("useSync must be used within a SyncProvider"); - - consoleSpy.mockRestore(); - }); - - it("returns initial state", async () => { - const { result } = renderHook(() => useSync(), { wrapper }); - - await waitFor(() => { - expect(result.current.isOnline).toBe(true); - expect(result.current.isSyncing).toBe(false); - expect(result.current.pendingCount).toBe(0); - expect(result.current.lastSyncAt).toBeNull(); - expect(result.current.lastError).toBeNull(); - expect(result.current.status).toBe("idle"); - }); - }); - - it("provides sync function", async () => { - const { result } = renderHook(() => useSync(), { wrapper }); - - await waitFor(() => { - expect(typeof result.current.sync).toBe("function"); - }); - }); - - it("updates isSyncing during sync", async () => { - // Make the sync take some time - mockFetch.mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - ok: true, - json: async () => ({ - decks: [], - cards: [], - reviewLogs: [], - conflicts: { decks: [], cards: [] }, - currentSyncVersion: 0, - }), - }), - 50, - ), - ), - ); - - const { result } = renderHook(() => useSync(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSyncing).toBe(false); - }); - - // Start sync - let syncPromise: Promise<unknown>; - act(() => { - syncPromise = result.current.sync(); - }); - - // Check that isSyncing becomes true - await waitFor(() => { - expect(result.current.isSyncing).toBe(true); - }); - - // Wait for sync to complete - await act(async () => { - await syncPromise; - }); - - expect(result.current.isSyncing).toBe(false); - }); - - it("updates lastSyncAt after successful sync", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - decks: [], - cards: [], - reviewLogs: [], - noteTypes: [], - noteFieldTypes: [], - notes: [], - noteFieldValues: [], - conflicts: { - decks: [], - cards: [], - noteTypes: [], - noteFieldTypes: [], - notes: [], - noteFieldValues: [], - }, - currentSyncVersion: 1, - }), - }); - - const { result } = renderHook(() => useSync(), { wrapper }); - - await waitFor(() => { - expect(result.current.lastSyncAt).toBeNull(); - }); - - await act(async () => { - await result.current.sync(); - }); - - await waitFor(() => { - expect(result.current.lastSyncAt).not.toBeNull(); - }); - }); - - it("updates lastError on sync failure", async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }); - - const { result } = renderHook(() => useSync(), { wrapper }); - - await waitFor(() => { - expect(result.current.lastError).toBeNull(); - }); - - await act(async () => { - await result.current.sync(); - }); - - await waitFor(() => { - expect(result.current.lastError).toBe("Server error"); - expect(result.current.status).toBe("error"); - }); - }); - - it("responds to online/offline events", async () => { - const { result } = renderHook(() => useSync(), { wrapper }); - - await waitFor(() => { - expect(result.current.isOnline).toBe(true); - }); - - // Simulate going offline - act(() => { - window.dispatchEvent(new Event("offline")); - }); - - await waitFor(() => { - expect(result.current.isOnline).toBe(false); - }); - - // Simulate going online - act(() => { - window.dispatchEvent(new Event("online")); - }); - - await waitFor(() => { - expect(result.current.isOnline).toBe(true); - }); - }); -}); diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx deleted file mode 100644 index aea5c16..0000000 --- a/src/client/stores/sync.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { apiClient } from "../api/client"; -import { - conflictResolver, - createPullService, - createPushService, - createSyncManager, - type SyncManagerEvent, - type SyncQueueState, - type SyncResult, - SyncStatus, - syncQueue, -} from "../sync"; -import type { - ServerCard, - ServerDeck, - ServerNote, - ServerNoteFieldType, - ServerNoteFieldValue, - ServerNoteType, - ServerReviewLog, - SyncPullResult, -} from "../sync/pull"; -import type { SyncPushData, SyncPushResult } from "../sync/push"; - -export interface SyncState { - isOnline: boolean; - isSyncing: boolean; - pendingCount: number; - lastSyncAt: Date | null; - lastError: string | null; - status: SyncQueueState["status"]; -} - -export interface SyncActions { - sync: () => Promise<SyncResult>; -} - -export type SyncContextValue = SyncState & SyncActions; - -const SyncContext = createContext<SyncContextValue | null>(null); - -export interface SyncProviderProps { - children: ReactNode; -} - -interface PullResponse { - decks: Array< - Omit<ServerDeck, "createdAt" | "updatedAt" | "deletedAt"> & { - createdAt: string; - updatedAt: string; - deletedAt: string | null; - } - >; - cards: Array< - Omit< - ServerCard, - "due" | "lastReview" | "createdAt" | "updatedAt" | "deletedAt" - > & { - due: string; - lastReview: string | null; - createdAt: string; - updatedAt: string; - deletedAt: string | null; - } - >; - reviewLogs: Array< - Omit<ServerReviewLog, "reviewedAt"> & { - reviewedAt: string; - } - >; - noteTypes: Array< - Omit<ServerNoteType, "createdAt" | "updatedAt" | "deletedAt"> & { - createdAt: string; - updatedAt: string; - deletedAt: string | null; - } - >; - noteFieldTypes: Array< - Omit<ServerNoteFieldType, "createdAt" | "updatedAt" | "deletedAt"> & { - createdAt: string; - updatedAt: string; - deletedAt: string | null; - } - >; - notes: Array< - Omit<ServerNote, "createdAt" | "updatedAt" | "deletedAt"> & { - createdAt: string; - updatedAt: string; - deletedAt: string | null; - } - >; - noteFieldValues: Array< - Omit<ServerNoteFieldValue, "createdAt" | "updatedAt"> & { - createdAt: string; - updatedAt: string; - } - >; - currentSyncVersion: number; -} - -async function pushToServer(data: SyncPushData): Promise<SyncPushResult> { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new Error("Not authenticated"); - } - - const res = await fetch("/api/sync/push", { - method: "POST", - headers: { - ...authHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!res.ok) { - const errorBody = (await res.json().catch(() => ({}))) as { - error?: string; - }; - throw new Error(errorBody.error || `Push failed with status ${res.status}`); - } - - return res.json() as Promise<SyncPushResult>; -} - -async function pullFromServer( - lastSyncVersion: number, -): Promise<SyncPullResult> { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new Error("Not authenticated"); - } - - const res = await fetch(`/api/sync/pull?lastSyncVersion=${lastSyncVersion}`, { - headers: authHeader, - }); - - if (!res.ok) { - const errorBody = (await res.json().catch(() => ({}))) as { - error?: string; - }; - throw new Error(errorBody.error || `Pull failed with status ${res.status}`); - } - - const data = (await res.json()) as PullResponse; - - return { - decks: data.decks.map((d) => ({ - ...d, - createdAt: new Date(d.createdAt), - updatedAt: new Date(d.updatedAt), - deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, - })), - cards: data.cards.map((c) => ({ - ...c, - due: new Date(c.due), - lastReview: c.lastReview ? new Date(c.lastReview) : null, - createdAt: new Date(c.createdAt), - updatedAt: new Date(c.updatedAt), - deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, - })), - reviewLogs: data.reviewLogs.map((r) => ({ - ...r, - reviewedAt: new Date(r.reviewedAt), - })), - noteTypes: data.noteTypes.map((n) => ({ - ...n, - createdAt: new Date(n.createdAt), - updatedAt: new Date(n.updatedAt), - deletedAt: n.deletedAt ? new Date(n.deletedAt) : null, - })), - noteFieldTypes: data.noteFieldTypes.map((f) => ({ - ...f, - createdAt: new Date(f.createdAt), - updatedAt: new Date(f.updatedAt), - deletedAt: f.deletedAt ? new Date(f.deletedAt) : null, - })), - notes: data.notes.map((n) => ({ - ...n, - createdAt: new Date(n.createdAt), - updatedAt: new Date(n.updatedAt), - deletedAt: n.deletedAt ? new Date(n.deletedAt) : null, - })), - noteFieldValues: data.noteFieldValues.map((v) => ({ - ...v, - createdAt: new Date(v.createdAt), - updatedAt: new Date(v.updatedAt), - })), - currentSyncVersion: data.currentSyncVersion, - }; -} - -const pushService = createPushService({ - syncQueue, - pushToServer, -}); - -const pullService = createPullService({ - syncQueue, - pullFromServer, -}); - -const syncManager = createSyncManager({ - syncQueue, - pushService, - pullService, - conflictResolver, -}); - -export function SyncProvider({ children }: SyncProviderProps) { - const [isOnline, setIsOnline] = useState( - typeof navigator !== "undefined" ? navigator.onLine : true, - ); - const [isSyncing, setIsSyncing] = useState(false); - const [pendingCount, setPendingCount] = useState(0); - const [lastSyncAt, setLastSyncAt] = useState<Date | null>(null); - const [lastError, setLastError] = useState<string | null>(null); - const [status, setStatus] = useState<SyncQueueState["status"]>( - SyncStatus.Idle, - ); - - useEffect(() => { - syncManager.start(); - - const unsubscribeManager = syncManager.subscribe( - (event: SyncManagerEvent) => { - switch (event.type) { - case "online": - setIsOnline(true); - break; - case "offline": - setIsOnline(false); - break; - case "sync_start": - setIsSyncing(true); - setLastError(null); - setStatus(SyncStatus.Syncing); - break; - case "sync_complete": - setIsSyncing(false); - setLastSyncAt(new Date()); - setStatus(SyncStatus.Idle); - break; - case "sync_error": - setIsSyncing(false); - setLastError(event.error); - setStatus(SyncStatus.Error); - break; - } - }, - ); - - const unsubscribeQueue = syncQueue.subscribe((state: SyncQueueState) => { - setPendingCount(state.pendingCount); - if (state.lastSyncAt) { - setLastSyncAt(state.lastSyncAt); - } - if (state.lastError) { - setLastError(state.lastError); - } - setStatus(state.status); - }); - - // Initialize state from queue - syncQueue.getState().then((state) => { - setPendingCount(state.pendingCount); - setLastSyncAt(state.lastSyncAt); - setLastError(state.lastError); - setStatus(state.status); - }); - - return () => { - unsubscribeManager(); - unsubscribeQueue(); - syncManager.stop(); - }; - }, []); - - const sync = useCallback(async () => { - return syncManager.sync(); - }, []); - - const value = useMemo<SyncContextValue>( - () => ({ - isOnline, - isSyncing, - pendingCount, - lastSyncAt, - lastError, - status, - sync, - }), - [isOnline, isSyncing, pendingCount, lastSyncAt, lastError, status, sync], - ); - - return <SyncContext.Provider value={value}>{children}</SyncContext.Provider>; -} - -export function useSync(): SyncContextValue { - const context = useContext(SyncContext); - if (!context) { - throw new Error("useSync must be used within a SyncProvider"); - } - return context; -} |
