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.tsx159
-rw-r--r--src/client/stores/auth.tsx83
-rw-r--r--src/client/stores/index.ts15
-rw-r--r--src/client/stores/sync.test.tsx231
-rw-r--r--src/client/stores/sync.tsx314
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;
-}