diff options
Diffstat (limited to 'src/client/sync/manager.test.ts')
| -rw-r--r-- | src/client/sync/manager.test.ts | 603 |
1 files changed, 603 insertions, 0 deletions
diff --git a/src/client/sync/manager.test.ts b/src/client/sync/manager.test.ts new file mode 100644 index 0000000..1e53bd4 --- /dev/null +++ b/src/client/sync/manager.test.ts @@ -0,0 +1,603 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; +import { db } from "../db/index"; +import { localDeckRepository } from "../db/repositories"; +import { ConflictResolver } from "./conflict"; +import { SyncManager, type SyncManagerEvent } from "./manager"; +import { PullService, type SyncPullResult } from "./pull"; +import { PushService, type SyncPushResult } from "./push"; +import { SyncQueue } from "./queue"; + +describe("SyncManager", () => { + let syncQueue: SyncQueue; + let conflictResolver: ConflictResolver; + let pushToServer: Mock; + let pullFromServer: Mock; + + function createServices() { + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + return { pushService, pullService }; + } + + /** + * Create a pending deck in the database that will need to be synced + */ + async function createPendingDeck(id = "deck-1") { + return localDeckRepository.create({ + id, + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + } + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + + syncQueue = new SyncQueue(); + + pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + } satisfies SyncPushResult); + + pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 0, + } satisfies SyncPullResult); + + conflictResolver = new ConflictResolver({ strategy: "server_wins" }); + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + }); + + describe("constructor", () => { + it("should create a sync manager with default options", () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + expect(manager.isAutoSyncEnabled()).toBe(true); + expect(manager.getOnlineStatus()).toBe(true); // jsdom defaults to online + }); + + it("should accept custom options", () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + debounceMs: 2000, + autoSync: false, + }); + + expect(manager.isAutoSyncEnabled()).toBe(false); + }); + }); + + describe("start/stop", () => { + it("should add event listeners when started", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const { pushService, pullService } = createServices(); + + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + manager.start(); + + expect(addSpy).toHaveBeenCalledWith("online", expect.any(Function)); + expect(addSpy).toHaveBeenCalledWith("offline", expect.any(Function)); + + manager.stop(); + addSpy.mockRestore(); + }); + + it("should remove event listeners when stopped", () => { + const removeSpy = vi.spyOn(window, "removeEventListener"); + const { pushService, pullService } = createServices(); + + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + manager.start(); + manager.stop(); + + expect(removeSpy).toHaveBeenCalledWith("online", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("offline", expect.any(Function)); + + removeSpy.mockRestore(); + }); + + it("should not add listeners multiple times if start called twice", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const { pushService, pullService } = createServices(); + + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + manager.start(); + manager.start(); + + // Should only be called once for each event type + expect( + addSpy.mock.calls.filter((c) => c[0] === "online").length, + ).toBe(1); + expect( + addSpy.mock.calls.filter((c) => c[0] === "offline").length, + ).toBe(1); + + manager.stop(); + addSpy.mockRestore(); + }); + }); + + describe("subscribe", () => { + it("should notify listeners of events", async () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const events: SyncManagerEvent[] = []; + manager.subscribe((event) => events.push(event)); + + await manager.sync(); + + expect(events).toContainEqual({ type: "sync_start" }); + expect(events).toContainEqual( + expect.objectContaining({ type: "sync_complete" }), + ); + }); + + it("should allow unsubscribing", async () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const events: SyncManagerEvent[] = []; + const unsubscribe = manager.subscribe((event) => events.push(event)); + + await manager.sync(); + const eventCount = events.length; + + unsubscribe(); + + await manager.sync(); + expect(events.length).toBe(eventCount); + }); + }); + + describe("sync", () => { + it("should perform push then pull", async () => { + // Create pending data so pushToServer will be called + await createPendingDeck(); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const result = await manager.sync(); + + expect(result.success).toBe(true); + expect(pushToServer).toHaveBeenCalled(); + expect(pullFromServer).toHaveBeenCalled(); + }); + + it("should return push and pull results", async () => { + // Create pending data so pushToServer will be called + await createPendingDeck(); + + const expectedPushResult: SyncPushResult = { + decks: [{ id: "deck-1", syncVersion: 1 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }; + pushToServer.mockResolvedValue(expectedPushResult); + + const expectedPullResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 5, + }; + pullFromServer.mockResolvedValue(expectedPullResult); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const result = await manager.sync(); + + expect(result.pushResult).toEqual(expectedPushResult); + expect(result.pullResult).toEqual(expectedPullResult); + }); + + it("should handle push errors", async () => { + // Create pending data so pushToServer will be called + await createPendingDeck(); + + pushToServer.mockRejectedValue(new Error("Network error")); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const result = await manager.sync(); + + expect(result.success).toBe(false); + expect(result.error).toBe("Network error"); + }); + + it("should handle pull errors", async () => { + pullFromServer.mockRejectedValue(new Error("Server error")); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const result = await manager.sync(); + + expect(result.success).toBe(false); + expect(result.error).toBe("Server error"); + }); + + it("should not sync if already syncing", async () => { + // Make push slow + pushToServer.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }), + 100, + ), + ), + ); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + // Start two syncs + const sync1 = manager.sync(); + const sync2 = manager.sync(); + + const [result1, result2] = await Promise.all([sync1, sync2]); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(false); + expect(result2.error).toBe("Sync already in progress"); + }); + + it("should resolve conflicts when present", async () => { + // Create pending data so pushToServer will be called + await createPendingDeck(); + + const pushResult: SyncPushResult = { + decks: [{ id: "deck-1", syncVersion: 1 }], + cards: [], + reviewLogs: [], + conflicts: { decks: ["deck-1"], cards: [] }, + }; + pushToServer.mockResolvedValue(pushResult); + + const pullResult: SyncPullResult = { + decks: [ + { + id: "deck-1", + userId: "user-1", + name: "Server Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 5, + }, + ], + cards: [], + reviewLogs: [], + currentSyncVersion: 5, + }; + pullFromServer.mockResolvedValue(pullResult); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const result = await manager.sync(); + + expect(result.success).toBe(true); + expect(result.conflictsResolved).toBe(1); + }); + + it("should notify sync_error on failure", async () => { + // Create pending data so pushToServer will be called + await createPendingDeck(); + + pushToServer.mockRejectedValue(new Error("Network error")); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const events: SyncManagerEvent[] = []; + manager.subscribe((event) => events.push(event)); + + await manager.sync(); + + expect(events).toContainEqual({ + type: "sync_error", + error: "Network error", + }); + }); + }); + + describe("online/offline handling", () => { + it("should notify listeners when going online", () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + autoSync: false, // Disable to avoid actual sync + }); + + const events: SyncManagerEvent[] = []; + manager.subscribe((event) => events.push(event)); + + manager.start(); + + // Simulate online event + window.dispatchEvent(new Event("online")); + + expect(events).toContainEqual({ type: "online" }); + expect(manager.getOnlineStatus()).toBe(true); + + manager.stop(); + }); + + it("should notify listeners when going offline", () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + autoSync: false, + }); + + const events: SyncManagerEvent[] = []; + manager.subscribe((event) => events.push(event)); + + manager.start(); + + // Simulate offline event + window.dispatchEvent(new Event("offline")); + + expect(events).toContainEqual({ type: "offline" }); + expect(manager.getOnlineStatus()).toBe(false); + + manager.stop(); + }); + + it("should not auto-sync when coming online if disabled", () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + autoSync: false, + debounceMs: 10, + }); + + manager.start(); + + // Go offline first + window.dispatchEvent(new Event("offline")); + + // Then back online + window.dispatchEvent(new Event("online")); + + // Since autoSync is disabled, pushToServer should not be scheduled + // We can't easily test the auto-sync behavior without fake timers + // but we can verify the setting works + expect(manager.isAutoSyncEnabled()).toBe(false); + + manager.stop(); + }); + }); + + describe("forceSync", () => { + it("should sync even if offline", async () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + manager.start(); + window.dispatchEvent(new Event("offline")); + + // forceSync calls sync which checks online status + // So this should fail because we're offline + const result = await manager.forceSync(); + + expect(result.success).toBe(false); + expect(result.error).toBe("Offline"); + + manager.stop(); + }); + }); + + describe("setAutoSync", () => { + it("should update auto-sync setting", () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + autoSync: true, + }); + + expect(manager.isAutoSyncEnabled()).toBe(true); + + manager.setAutoSync(false); + expect(manager.isAutoSyncEnabled()).toBe(false); + + manager.setAutoSync(true); + expect(manager.isAutoSyncEnabled()).toBe(true); + }); + }); + + describe("getState", () => { + it("should return current sync queue state", async () => { + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + const state = await manager.getState(); + + expect(state.status).toBe("idle"); + expect(state.pendingCount).toBe(0); + expect(state.lastSyncVersion).toBe(0); + }); + }); + + describe("isSyncing", () => { + it("should return true during sync", async () => { + // Make push slow + pushToServer.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }), + 100, + ), + ), + ); + + const { pushService, pullService } = createServices(); + const manager = new SyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, + }); + + expect(manager.isSyncing()).toBe(false); + + const syncPromise = manager.sync(); + expect(manager.isSyncing()).toBe(true); + + await syncPromise; + expect(manager.isSyncing()).toBe(false); + }); + }); +}); |
