diff --git a/src/store/middleware/__tests__/autoSyncManager.test.ts b/src/store/middleware/__tests__/autoSyncManager.test.ts new file mode 100644 index 0000000..13018f7 --- /dev/null +++ b/src/store/middleware/__tests__/autoSyncManager.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AutoSyncManager } from '../autoSyncMiddleware' +import { syncToFlaskBulk } from '../../slices/syncSlice' + +vi.mock('../../slices/syncSlice', () => ({ + syncToFlaskBulk: vi.fn(() => ({ type: 'sync/syncToFlaskBulk' })), + checkFlaskConnection: vi.fn(() => ({ type: 'sync/checkConnection' })), +})) + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error?: unknown) => void +} + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { promise, resolve, reject } +} + +describe('AutoSyncManager', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('serializes syncs and runs one pending sync after completion', async () => { + const manager = new AutoSyncManager() + const deferreds = [createDeferred(), createDeferred()] + const dispatch = vi + .fn() + .mockImplementation(() => deferreds.shift()?.promise ?? Promise.resolve()) + + manager.setDispatch(dispatch) + + const firstSync = manager.syncNow() + const secondSync = manager.syncNow() + + expect(dispatch).toHaveBeenCalledTimes(1) + + deferreds[0].resolve() + await Promise.resolve() + + expect(dispatch).toHaveBeenCalledTimes(2) + + deferreds[1].resolve() + + await firstSync + await secondSync + }) + + it('resets changeCounter after a successful sync', async () => { + const manager = new AutoSyncManager() + const dispatch = vi.fn().mockResolvedValue(undefined) + + manager.setDispatch(dispatch) + manager.trackChange() + manager.trackChange() + + expect(manager.getStatus().changeCounter).toBe(2) + + await manager.syncNow() + + expect(manager.getStatus().changeCounter).toBe(0) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('coalesces multiple pending sync requests into one run', async () => { + const manager = new AutoSyncManager() + const deferreds = [createDeferred(), createDeferred()] + const dispatch = vi + .fn() + .mockImplementation(() => deferreds.shift()?.promise ?? Promise.resolve()) + + manager.setDispatch(dispatch) + + const firstSync = manager.syncNow() + const secondSync = manager.syncNow() + const thirdSync = manager.syncNow() + + expect(dispatch).toHaveBeenCalledTimes(1) + + deferreds[0].resolve() + await Promise.resolve() + + expect(dispatch).toHaveBeenCalledTimes(2) + + deferreds[1].resolve() + + await firstSync + await secondSync + await thirdSync + + expect(dispatch).toHaveBeenCalledTimes(2) + }) + + it('dispatches the sync thunk when performing a sync', async () => { + const manager = new AutoSyncManager() + const dispatch = vi.fn().mockResolvedValue(undefined) + + manager.setDispatch(dispatch) + + await manager.syncNow() + + expect(dispatch).toHaveBeenCalledWith(syncToFlaskBulk()) + }) +}) diff --git a/src/store/middleware/autoSyncMiddleware.ts b/src/store/middleware/autoSyncMiddleware.ts index 277769c..723a83a 100644 --- a/src/store/middleware/autoSyncMiddleware.ts +++ b/src/store/middleware/autoSyncMiddleware.ts @@ -9,7 +9,7 @@ interface AutoSyncConfig { maxQueueSize: number } -class AutoSyncManager { +export class AutoSyncManager { private config: AutoSyncConfig = { enabled: false, intervalMs: 30000, @@ -21,6 +21,8 @@ class AutoSyncManager { private lastSyncTime = 0 private changeCounter = 0 private dispatch: any = null + private syncInFlight: Promise | null = null + private pendingSync = false configure(config: Partial) { this.config = { ...this.config, ...config } @@ -69,12 +71,32 @@ class AutoSyncManager { private async performSync() { if (!this.dispatch) return + if (this.syncInFlight) { + this.pendingSync = true + return + } + + const syncPromise = (async () => { + try { + await this.dispatch(syncToFlaskBulk()) + this.lastSyncTime = Date.now() + this.changeCounter = 0 + } catch (error) { + console.error('[AutoSync] Sync failed:', error) + } + })() + + this.syncInFlight = syncPromise + try { - await this.dispatch(syncToFlaskBulk()) - this.lastSyncTime = Date.now() - this.changeCounter = 0 - } catch (error) { - console.error('[AutoSync] Sync failed:', error) + await syncPromise + } finally { + this.syncInFlight = null + } + + if (this.pendingSync) { + this.pendingSync = false + await this.performSync() } }