diff --git a/e2e/tests/sync/supersync-vector-clock-max-size.spec.ts b/e2e/tests/sync/supersync-vector-clock-max-size.spec.ts new file mode 100644 index 0000000000..fe65482665 --- /dev/null +++ b/e2e/tests/sync/supersync-vector-clock-max-size.spec.ts @@ -0,0 +1,338 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + waitForTask, + markTaskDone, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; +import { + expectTaskOnAllClients, + expectEqualTaskCount, +} from '../../utils/supersync-assertions'; + +/** + * SuperSync Vector Clock Max Size & LWW Retry Limit E2E Tests + * + * These tests verify the fixes from commit cb36c09538: + * - LWW re-upload retry loop has a bounded maximum (prevents infinite sync) + * - TAG:TODAY concurrent edits converge without hanging + * - Multiple clients with many tasks produce consistent vector clocks + * + * Prerequisites: + * - super-sync-server running on localhost:1901 with TEST_MODE=true + * - Frontend running on localhost:4242 + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-vector-clock-max-size.spec.ts + */ + +test.describe.configure({ mode: 'serial' }); + +test.describe('@supersync @vector-clock-max-size Vector Clock Max Size and LWW Retry', () => { + /** + * Test 1: Sync completes without hanging during heavy LWW conflict (retry limit) + * + * Creates concurrent edits that produce many LWW conflicts. + * Without the retry limit fix, the sync would loop infinitely + * re-uploading local-win ops that keep producing new LWW ops. + * + * Steps: + * 1. Client A creates 5 tasks, syncs + * 2. Client B syncs, receives tasks + * 3. Both clients mark all tasks done (concurrent edits) + * 4. Client A syncs first + * 5. Client B syncs (many LWW conflicts from concurrent done-marking) + * 6. Assert: sync completes (doesn't hang), clients converge, no errors + */ + test('Sync completes without hanging during heavy LWW conflict (retry limit)', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ Setup clients ============ + console.log('[LWW Retry] Setting up clients'); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // ============ Client A creates tasks ============ + console.log('[LWW Retry] Client A creating tasks'); + const taskNames: string[] = []; + for (let i = 1; i <= 5; i++) { + const name = `LWW-Task${i}-${uniqueId}`; + taskNames.push(name); + await clientA.workView.addTask(name); + } + await clientA.sync.syncAndWait(); + console.log('[LWW Retry] Client A synced tasks'); + + // ============ Client B receives tasks ============ + console.log('[LWW Retry] Client B syncing to receive tasks'); + await clientB.sync.syncAndWait(); + for (const name of taskNames) { + await waitForTask(clientB.page, name); + } + console.log('[LWW Retry] Client B has all tasks'); + + // ============ Both mark all tasks done concurrently ============ + console.log('[LWW Retry] Both clients marking tasks done'); + for (const name of taskNames) { + await markTaskDone(clientA, name); + } + for (const name of taskNames) { + await markTaskDone(clientB, name); + } + console.log('[LWW Retry] Both clients marked all tasks done'); + + // ============ Sequential sync: A first, then B ============ + console.log('[LWW Retry] Client A syncing done state'); + await clientA.sync.syncAndWait(); + console.log('[LWW Retry] Client A synced'); + + // This is the critical step: B has many concurrent edits that produce LWW conflicts + // Without the retry limit fix, this would loop infinitely + console.log('[LWW Retry] Client B syncing (heavy LWW conflicts expected)'); + await clientB.sync.syncAndWait(); + console.log('[LWW Retry] Client B synced (did not hang!)'); + + // ============ Verify convergence ============ + console.log('[LWW Retry] Verifying convergence'); + + // Final sync to ensure full convergence + await clientA.sync.syncAndWait(); + + // Both clients should have same task count + await expectEqualTaskCount([clientA, clientB]); + + // No error snackbars + const errorSnackA = clientA.page.locator('simple-snack-bar.error'); + const errorSnackB = clientB.page.locator('simple-snack-bar.error'); + await expect(errorSnackA).not.toBeVisible({ timeout: 3000 }); + await expect(errorSnackB).not.toBeVisible({ timeout: 3000 }); + + console.log('[LWW Retry] Test PASSED - sync completed without hanging'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + /** + * Test 2: TAG:TODAY operations converge after concurrent edits (the bug scenario) + * + * This reproduces the exact bug scenario: two clients editing tasks that belong + * to TAG:TODAY (via dueDay). Concurrent edits on the same entity produce LWW + * conflicts that previously caused an infinite re-upload loop. + * + * Steps: + * 1. Client A creates a task with sd:today (adds to TODAY tag), syncs + * 2. Client B syncs, receives the task + * 3. Client A marks the task done + * 4. Client B concurrently renames the task + * 5. Both sync in sequence + * 6. Assert: sync completes, no hanging, consistent state + */ + test('TAG:TODAY operations converge after concurrent edits (the bug scenario)', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ Setup clients ============ + console.log('[TODAY] Setting up clients'); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Initial sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // ============ Client A creates task with sd:today ============ + console.log('[TODAY] Client A creating task with sd:today'); + const taskName = `TODAY-Task-${uniqueId}`; + // Using sd:today sets dueDay which makes the task appear in TODAY view + await clientA.workView.addTask(`${taskName} sd:today`); + await waitForTask(clientA.page, taskName); + + await clientA.sync.syncAndWait(); + console.log('[TODAY] Client A synced task'); + + // ============ Client B receives task ============ + console.log('[TODAY] Client B syncing to receive task'); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); + console.log('[TODAY] Client B has the task'); + + // ============ Concurrent edits ============ + console.log('[TODAY] Making concurrent edits'); + + // Client A marks the task as done + await markTaskDone(clientA, taskName); + console.log('[TODAY] Client A marked task done'); + + // Client B adds a second task (creates concurrent TAG:TODAY ordering changes) + const task2Name = `TODAY-Task2-${uniqueId}`; + await clientB.workView.addTask(`${task2Name} sd:today`); + await waitForTask(clientB.page, task2Name); + console.log('[TODAY] Client B created second today task'); + + // ============ Sequential sync ============ + console.log('[TODAY] Client A syncing done state'); + await clientA.sync.syncAndWait(); + + console.log('[TODAY] Client B syncing (concurrent edits)'); + await clientB.sync.syncAndWait(); + console.log('[TODAY] Client B synced (did not hang!)'); + + // ============ Final convergence sync ============ + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // ============ Verify ============ + console.log('[TODAY] Verifying state'); + + // No error snackbars + const errorSnackA = clientA.page.locator('simple-snack-bar.error'); + const errorSnackB = clientB.page.locator('simple-snack-bar.error'); + await expect(errorSnackA).not.toBeVisible({ timeout: 3000 }); + await expect(errorSnackB).not.toBeVisible({ timeout: 3000 }); + + // Both clients should see the second task + await expectTaskOnAllClients([clientA, clientB], task2Name); + + console.log('[TODAY] Test PASSED - TAG:TODAY concurrent edits converged'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + /** + * Test 3: Multiple clients creating many tasks produces consistent vector clocks after sync + * + * Three clients each create several tasks and sync in sequence. + * This builds up vector clock entries and verifies that pruning + * doesn't break comparison or cause sync failures. + * + * Steps: + * 1. Create 3 clients (A, B, C) + * 2. Each creates 3 tasks, syncs sequentially + * 3. All sync multiple rounds + * 4. Assert: all clients have identical task set, no errors + */ + test('Multiple clients creating many tasks produces consistent vector clocks after sync', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + let clientC: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ Setup 3 clients ============ + console.log('[3-Client] Setting up three clients'); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); + + // Initial sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); + + const allTaskNames: string[] = []; + + // ============ Round 1: Each client creates tasks ============ + console.log('[3-Client] Round 1: Each client creating 3 tasks'); + + for (let i = 1; i <= 3; i++) { + const name = `A-Task${i}-${uniqueId}`; + allTaskNames.push(name); + await clientA.workView.addTask(name); + } + await clientA.sync.syncAndWait(); + console.log('[3-Client] Client A synced'); + + await clientB.sync.syncAndWait(); // Get A's tasks + for (let i = 1; i <= 3; i++) { + const name = `B-Task${i}-${uniqueId}`; + allTaskNames.push(name); + await clientB.workView.addTask(name); + } + await clientB.sync.syncAndWait(); + console.log('[3-Client] Client B synced'); + + await clientC.sync.syncAndWait(); // Get A's + B's tasks + for (let i = 1; i <= 3; i++) { + const name = `C-Task${i}-${uniqueId}`; + allTaskNames.push(name); + await clientC.workView.addTask(name); + } + await clientC.sync.syncAndWait(); + console.log('[3-Client] Client C synced'); + + // ============ Round 2: All sync to converge ============ + console.log('[3-Client] Round 2: Convergence syncs'); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + // Extra round to ensure full propagation + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); + + // ============ Verify all clients have all tasks ============ + console.log('[3-Client] Verifying all clients have all 9 tasks'); + + const allClients = [clientA, clientB, clientC]; + for (const name of allTaskNames) { + await expectTaskOnAllClients(allClients, name); + } + + await expectEqualTaskCount(allClients); + + // No error snackbars on any client + for (const client of allClients) { + const errorSnack = client.page.locator('simple-snack-bar.error'); + await expect(errorSnack).not.toBeVisible({ timeout: 3000 }); + } + + console.log('[3-Client] Test PASSED - all 3 clients converged with 9 tasks'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + } + }); +}); diff --git a/packages/shared-schema/tests/vector-clock.spec.ts b/packages/shared-schema/tests/vector-clock.spec.ts new file mode 100644 index 0000000000..bbdfdfb27a --- /dev/null +++ b/packages/shared-schema/tests/vector-clock.spec.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest'; +import { + compareVectorClocks, + mergeVectorClocks, + limitVectorClockSize, + MAX_VECTOR_CLOCK_SIZE, +} from '../src/vector-clock'; + +describe('compareVectorClocks', () => { + describe('basic cases', () => { + it('should return EQUAL for identical clocks', () => { + const clock = { a: 1, b: 2 }; + expect(compareVectorClocks(clock, clock)).toBe('EQUAL'); + }); + + it('should return EQUAL for two empty clocks', () => { + expect(compareVectorClocks({}, {})).toBe('EQUAL'); + }); + + it('should return LESS_THAN when a is strictly behind b', () => { + const a = { x: 1, y: 2 }; + const b = { x: 2, y: 3 }; + expect(compareVectorClocks(a, b)).toBe('LESS_THAN'); + }); + + it('should return GREATER_THAN when a is strictly ahead of b', () => { + const a = { x: 5, y: 10 }; + const b = { x: 3, y: 7 }; + expect(compareVectorClocks(a, b)).toBe('GREATER_THAN'); + }); + + it('should return CONCURRENT when neither dominates', () => { + const a = { x: 3, y: 1 }; + const b = { x: 1, y: 3 }; + expect(compareVectorClocks(a, b)).toBe('CONCURRENT'); + }); + + it('should return LESS_THAN when a has subset of keys (missing key = 0)', () => { + const a = { x: 1 }; + const b = { x: 1, y: 2 }; + expect(compareVectorClocks(a, b)).toBe('LESS_THAN'); + }); + + it('should return GREATER_THAN when a has superset of keys', () => { + const a = { x: 1, y: 2 }; + const b = { x: 1 }; + expect(compareVectorClocks(a, b)).toBe('GREATER_THAN'); + }); + }); + + describe('pruning-aware mode', () => { + const buildMaxClock = (prefix: string, startVal: number): Record => { + const clock: Record = {}; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) { + clock[`${prefix}_${i}`] = startVal + i; + } + return clock; + }; + + it('should compare only shared keys when both clocks >= MAX_VECTOR_CLOCK_SIZE', () => { + // Build two max-size clocks with some shared and some unique keys + const a: Record = {}; + const b: Record = {}; + + // 5 shared keys where a dominates + for (let i = 0; i < 5; i++) { + a[`shared_${i}`] = 10; + b[`shared_${i}`] = 5; + } + // Fill remaining slots with unique keys + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE - 5; i++) { + a[`a_only_${i}`] = 100; + b[`b_only_${i}`] = 100; + } + + // Without pruning-aware mode, unique keys would cause CONCURRENT + // With pruning-aware mode (both at MAX), only shared keys are compared + expect(Object.keys(a).length).toBe(MAX_VECTOR_CLOCK_SIZE); + expect(Object.keys(b).length).toBe(MAX_VECTOR_CLOCK_SIZE); + expect(compareVectorClocks(a, b)).toBe('GREATER_THAN'); + }); + + it('should return EQUAL when shared keys are equal and both at max size', () => { + const a: Record = {}; + const b: Record = {}; + + for (let i = 0; i < 5; i++) { + a[`shared_${i}`] = 10; + b[`shared_${i}`] = 10; + } + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE - 5; i++) { + a[`a_only_${i}`] = 50; + b[`b_only_${i}`] = 50; + } + + expect(compareVectorClocks(a, b)).toBe('EQUAL'); + }); + + it('should return CONCURRENT when shared keys are genuinely concurrent', () => { + const a: Record = {}; + const b: Record = {}; + + // Shared keys where a wins some, b wins others + for (let i = 0; i < 3; i++) { + a[`shared_a_wins_${i}`] = 10; + b[`shared_a_wins_${i}`] = 5; + } + for (let i = 0; i < 3; i++) { + a[`shared_b_wins_${i}`] = 5; + b[`shared_b_wins_${i}`] = 10; + } + // Fill remaining with unique keys + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE - 6; i++) { + a[`a_only_${i}`] = 100; + b[`b_only_${i}`] = 100; + } + + expect(compareVectorClocks(a, b)).toBe('CONCURRENT'); + }); + + it('should use ALL keys when only one clock is at MAX_VECTOR_CLOCK_SIZE', () => { + const a: Record = {}; + // a has MAX keys + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) { + a[`client_${i}`] = 10; + } + // b has fewer keys, with one key not in a + const b: Record = { client_0: 5, extra_client: 100 }; + + // Since only a is at MAX, all keys are used + // a has client_0..client_9 at 10, extra_client at 0 (missing) + // b has client_0 at 5, extra_client at 100, client_1..9 at 0 + // a > b on client_0..9, b > a on extra_client => CONCURRENT + expect(compareVectorClocks(a, b)).toBe('CONCURRENT'); + }); + + it('should use ALL keys when neither clock is at MAX_VECTOR_CLOCK_SIZE', () => { + const a = { x: 5 }; + const b = { y: 5 }; + + // Both small clocks, all keys used + // a: x=5, y=0 -> aGreater on x + // b: x=0, y=5 -> bGreater on y + expect(compareVectorClocks(a, b)).toBe('CONCURRENT'); + }); + }); +}); + +describe('limitVectorClockSize', () => { + it('should return unchanged when within limit', () => { + const clock = { a: 1, b: 2, c: 3 }; + const result = limitVectorClockSize(clock); + expect(result).toBe(clock); // Same reference + }); + + it('should return unchanged when exactly at limit', () => { + const clock: Record = {}; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) { + clock[`client_${i}`] = i + 1; + } + const result = limitVectorClockSize(clock); + expect(result).toBe(clock); + }); + + it('should prune to MAX keeping highest-counter clients', () => { + const clock: Record = {}; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) { + clock[`client_${i}`] = i + 1; + } + + const result = limitVectorClockSize(clock); + expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE); + + // The lowest-counter clients should be pruned + // client_0 (1), client_1 (2), ..., client_4 (5) should be pruned + for (let i = 0; i < 5; i++) { + expect(result[`client_${i}`]).toBeUndefined(); + } + // Higher-counter clients should be kept + for (let i = 5; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) { + expect(result[`client_${i}`]).toBe(i + 1); + } + }); + + it('should preserve specified client IDs even with low counters', () => { + const clock: Record = { lowClient: 1 }; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 5; i++) { + clock[`high_${i}`] = 100 + i; + } + + const result = limitVectorClockSize(clock, ['lowClient']); + expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE); + expect(result['lowClient']).toBe(1); + }); + + it('should handle empty preserveClientIds', () => { + const clock: Record = {}; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) { + clock[`client_${i}`] = i + 1; + } + + const result = limitVectorClockSize(clock, []); + expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE); + }); + + it('should handle preserveClientIds not present in clock', () => { + const clock: Record = {}; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE + 3; i++) { + clock[`client_${i}`] = i + 1; + } + + const result = limitVectorClockSize(clock, ['nonexistent']); + expect(Object.keys(result).length).toBe(MAX_VECTOR_CLOCK_SIZE); + expect(result['nonexistent']).toBeUndefined(); + }); +}); + +describe('mergeVectorClocks', () => { + it('should merge by taking max of each key', () => { + const a = { x: 3, y: 1 }; + const b = { x: 1, y: 5 }; + expect(mergeVectorClocks(a, b)).toEqual({ x: 3, y: 5 }); + }); + + it('should include keys only present in one clock', () => { + const a = { x: 3 }; + const b = { y: 5 }; + expect(mergeVectorClocks(a, b)).toEqual({ x: 3, y: 5 }); + }); + + it('should handle one empty clock', () => { + const a = { x: 3, y: 1 }; + expect(mergeVectorClocks(a, {})).toEqual({ x: 3, y: 1 }); + expect(mergeVectorClocks({}, a)).toEqual({ x: 3, y: 1 }); + }); + + it('should handle both empty clocks', () => { + expect(mergeVectorClocks({}, {})).toEqual({}); + }); +}); diff --git a/src/app/core/util/vector-clock.spec.ts b/src/app/core/util/vector-clock.spec.ts index 9d7e753868..c1e6ab1af9 100644 --- a/src/app/core/util/vector-clock.spec.ts +++ b/src/app/core/util/vector-clock.spec.ts @@ -226,4 +226,45 @@ describe('vector-clock', () => { expect(comparison).toBe(VectorClockComparison.GREATER_THAN); }); }); + + describe('compareVectorClocks - pruning-aware via shared implementation', () => { + it('should return GREATER_THAN (not false CONCURRENT) when both clocks at MAX size with different unique keys', () => { + // Build two max-size clocks: shared keys where a dominates, plus unique keys + const a: Record = {}; + const b: Record = {}; + const half = Math.floor(MAX_VECTOR_CLOCK_SIZE / 2); + for (let i = 0; i < half; i++) { + a[`shared_${i}`] = 10; + b[`shared_${i}`] = 5; + } + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE - half; i++) { + a[`a_only_${i}`] = 100; + b[`b_only_${i}`] = 100; + } + + // Pruning-aware: only shared keys compared -> a dominates -> GREATER_THAN + expect(compareVectorClocks(a, b)).toBe(VectorClockComparison.GREATER_THAN); + }); + + it('should use all keys when only one clock is at MAX size', () => { + const a: Record = {}; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) { + a[`client_${i}`] = 10; + } + // b is small with a unique key + const b = { client_0: 5, unique: 100 }; + + // Not both at MAX -> all keys used -> CONCURRENT + // (a > b on client_1..9, b > a on unique) + expect(compareVectorClocks(a, b)).toBe(VectorClockComparison.CONCURRENT); + }); + + it('should use all keys when neither clock is at MAX size', () => { + const a = { x: 5 }; + const b = { y: 5 }; + + // Both small -> all keys -> CONCURRENT + expect(compareVectorClocks(a, b)).toBe(VectorClockComparison.CONCURRENT); + }); + }); }); diff --git a/src/app/imex/sync/sync-wrapper.service.spec.ts b/src/app/imex/sync/sync-wrapper.service.spec.ts index 9c9d899d05..7f39eba0e6 100644 --- a/src/app/imex/sync/sync-wrapper.service.spec.ts +++ b/src/app/imex/sync/sync-wrapper.service.spec.ts @@ -1239,4 +1239,128 @@ describe('SyncWrapperService', () => { expect(service['_isTimeoutError'](errorObj)).toBe(true); }); }); + + describe('_sync() - LWW retry loop limit', () => { + it('should stop after MAX_LWW_REUPLOAD_RETRIES when upload always returns localWinOpsCreated', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.resolve({ + newOpsCount: 0, + serverMigrationHandled: false, + localWinOpsCreated: 0, + }), + ); + // Upload always returns localWinOpsCreated: 2 (never resolves) + mockSyncService.uploadPendingOps.and.returnValue( + Promise.resolve({ + uploadedCount: 2, + rejectedCount: 0, + piggybackedOps: [], + rejectedOps: [], + localWinOpsCreated: 2, + }), + ); + + await service.sync(); + + // 1 initial upload + 3 retries = 4 total calls + expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(4); + // Should set UNKNOWN_OR_CHANGED since ops remain pending + expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith( + 'UNKNOWN_OR_CHANGED', + ); + }); + + it('should exit early when retry returns localWinOpsCreated: 0', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.resolve({ + newOpsCount: 0, + serverMigrationHandled: false, + localWinOpsCreated: 0, + }), + ); + + let uploadCallCount = 0; + mockSyncService.uploadPendingOps.and.callFake(async () => { + uploadCallCount++; + return { + uploadedCount: 2, + rejectedCount: 0, + piggybackedOps: [], + rejectedOps: [], + // First call returns 1, second call returns 0 -> exits loop + localWinOpsCreated: uploadCallCount <= 1 ? 1 : 0, + }; + }); + + const result = await service.sync(); + + // 1 initial upload + 1 retry (which returns 0) = 2 total + // The retry returns 0 so no more retries needed + expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2); + expect(result).toBe(SyncStatus.InSync); + }); + + it('should treat null reupload result as 0 localWinOpsCreated and exit loop', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.resolve({ + newOpsCount: 0, + serverMigrationHandled: false, + localWinOpsCreated: 0, + }), + ); + + let uploadCallCount = 0; + mockSyncService.uploadPendingOps.and.callFake(async () => { + uploadCallCount++; + if (uploadCallCount === 1) { + return { + uploadedCount: 1, + rejectedCount: 0, + piggybackedOps: [], + rejectedOps: [], + localWinOpsCreated: 2, + }; + } + // Second call returns null (e.g., fresh client scenario) + return null; + }); + + const result = await service.sync(); + + // 1 initial + 1 retry (returns null -> treated as 0) = 2 total + expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2); + expect(result).toBe(SyncStatus.InSync); + }); + + it('should enter while loop when both download and upload produce LWW ops', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.resolve({ + newOpsCount: 5, + serverMigrationHandled: false, + localWinOpsCreated: 2, // download produced LWW ops + }), + ); + + let uploadCallCount = 0; + mockSyncService.uploadPendingOps.and.callFake(async () => { + uploadCallCount++; + return { + uploadedCount: 3, + rejectedCount: 0, + piggybackedOps: [], + rejectedOps: [], + // First upload also produces LWW ops, subsequent do not + localWinOpsCreated: uploadCallCount === 1 ? 1 : 0, + }; + }); + + const result = await service.sync(); + + // pendingLwwOps = download(2) + upload(1) = 3 + // Retry 1: upload returns 0 -> exits loop + // Total uploads: 1 initial + 1 retry = 2 + expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2); + expect(result).toBe(SyncStatus.InSync); + }); + }); });