aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/stores/sync.test.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/stores/sync.test.tsx')
-rw-r--r--src/client/stores/sync.test.tsx209
1 files changed, 209 insertions, 0 deletions
diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx
new file mode 100644
index 0000000..9c4e5c2
--- /dev/null
+++ b/src/client/stores/sync.test.tsx
@@ -0,0 +1,209 @@
+/**
+ * @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: [],
+ conflicts: { decks: [], cards: [] },
+ 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: [],
+ conflicts: { decks: [], cards: [] },
+ 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);
+ });
+ });
+});