test(sync): add tests for vector clock pruning and LWW retry loop fix

Cover all 4 changes from cb36c09538: shared-schema vector clock tests
(22 Vitest), sync wrapper LWW retry loop limit tests (4 Jasmine),
client vector clock pruning-aware comparison tests (3 Jasmine), and
E2E tests for heavy LWW conflict, TAG:TODAY convergence, and
multi-client vector clock consistency (3 Playwright).
This commit is contained in:
Johannes Millan 2026-01-30 19:04:44 +01:00
parent c268b1a9a9
commit fd3d06c5ff
4 changed files with 743 additions and 0 deletions

View file

@ -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);
}
});
});

View file

@ -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<string, number> => {
const clock: Record<string, number> = {};
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<string, number> = {};
const b: Record<string, number> = {};
// 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<string, number> = {};
const b: Record<string, number> = {};
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<string, number> = {};
const b: Record<string, number> = {};
// 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<string, number> = {};
// 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<string, number> = { 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<string, number> = {};
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<string, number> = {};
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<string, number> = { 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<string, number> = {};
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<string, number> = {};
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({});
});
});

View file

@ -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<string, number> = {};
const b: Record<string, number> = {};
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<string, number> = {};
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);
});
});
});

View file

@ -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);
});
});
});