diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 23:34:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 23:34:03 +0900 |
| commit | 0c042ac89fc0822fcbe09c48702857faa5494ae1 (patch) | |
| tree | ea1f1d180f747613343040d441a07f92b2760840 /src/client/stores/sync.test.tsx | |
| parent | ae5a0bb97fbf013417a6962f7e077f0408b2a951 (diff) | |
| download | kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.tar.gz kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.tar.zst kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.zip | |
feat(client): add sync status indicator component
Add SyncStatusIndicator component to display current sync state in the
UI header. The component shows online/offline status, syncing progress,
pending changes count, and sync errors.
- Create SyncProvider context to wrap SyncManager for React components
- Add SyncStatusIndicator component with visual status indicators
- Integrate indicator into HomePage header
- Add comprehensive tests for SyncStatusIndicator and SyncProvider
- Update existing tests to include SyncProvider wrapper
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/stores/sync.test.tsx')
| -rw-r--r-- | src/client/stores/sync.test.tsx | 209 |
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); + }); + }); +}); |
