super-productivity/src/app/imex/sync/sync-wrapper.service.spec.ts
Johannes Millan 4780f08333 fix(sync): show error instead of falsely claiming IN_SYNC when upload fails
When SYNC_IMPORT upload is rejected (e.g., payload too complex/large),
the sync status was incorrectly set to IN_SYNC, misleading users into
thinking their data was synced when it wasn't.

Changes:
- Check uploadResult.rejectedCount before setting IN_SYNC
- Set status to ERROR when upload has rejected operations
- Show alert dialog for payload size/complexity errors
- Add ERROR_PAYLOAD_TOO_LARGE translation key
- Add tests for rejected ops handling
2026-01-10 18:38:49 +01:00

965 lines
33 KiB
TypeScript

import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, of } from 'rxjs';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { SnackService } from '../../core/snack/snack.service';
import { ReminderService } from '../../features/reminder/reminder.service';
import { DataInitService } from '../../core/data-init/data-init.service';
import { UserInputWaitStateService } from './user-input-wait-state.service';
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
import {
AuthFailSPError,
PotentialCorsError,
SyncProviderId,
SyncStatus,
} from '../../op-log/sync-exports';
import {
SyncAlreadyInProgressError,
LocalDataConflictError,
} from '../../op-log/core/errors/sync-errors';
import { LegacySyncProvider } from './legacy-sync-provider.model';
describe('SyncWrapperService', () => {
let service: SyncWrapperService;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockSyncService: jasmine.SpyObj<OperationLogSyncService>;
let mockWrappedProvider: jasmine.SpyObj<WrappedProviderService>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockLegacyPfDb: jasmine.SpyObj<LegacyPfDbService>;
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let mockTranslateService: jasmine.SpyObj<TranslateService>;
let mockDataInitService: jasmine.SpyObj<DataInitService>;
let mockReminderService: jasmine.SpyObj<ReminderService>;
let mockUserInputWaitState: jasmine.SpyObj<UserInputWaitStateService>;
let mockSuperSyncStatusService: jasmine.SpyObj<SuperSyncStatusService>;
let configSubject: BehaviorSubject<any>;
let mockSyncCapableProvider: any;
const createMockSyncConfig = (provider: LegacySyncProvider | null): { sync: any } => ({
sync: {
syncProvider: provider,
syncInterval: 60000,
},
});
beforeEach(() => {
configSubject = new BehaviorSubject(
createMockSyncConfig(LegacySyncProvider.SuperSync),
);
mockSyncCapableProvider = {
uploadOperations: jasmine.createSpy('uploadOperations'),
downloadOperations: jasmine.createSpy('downloadOperations'),
};
mockProviderManager = jasmine.createSpyObj(
'SyncProviderManager',
['getActiveProvider', 'setSyncStatus', 'setProviderConfig', 'getProviderById'],
{
syncStatus$: of('SYNCED'),
isProviderReady$: of(true),
isSyncInProgress: false,
},
);
mockProviderManager.getActiveProvider.and.returnValue({
id: SyncProviderId.SuperSync,
} as any);
mockSyncService = jasmine.createSpyObj('OperationLogSyncService', [
'downloadRemoteOps',
'uploadPendingOps',
]);
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
newOpsCount: 0,
serverMigrationHandled: false,
localWinOpsCreated: 0,
}),
);
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 0,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
}),
);
mockWrappedProvider = jasmine.createSpyObj('WrappedProviderService', [
'getOperationSyncCapable',
]);
mockWrappedProvider.getOperationSyncCapable.and.returnValue(
Promise.resolve(mockSyncCapableProvider),
);
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'getVectorClockEntry',
'setVectorClock',
]);
mockOpLogStore.getVectorClockEntry.and.returnValue(Promise.resolve(null));
mockLegacyPfDb = jasmine.createSpyObj('LegacyPfDbService', [
'loadMetaModel',
'saveMetaModel',
]);
mockLegacyPfDb.loadMetaModel.and.returnValue(Promise.resolve({}));
mockLegacyPfDb.saveMetaModel.and.returnValue(Promise.resolve());
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
cfg$: configSubject.asObservable(),
});
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
mockTranslateService = jasmine.createSpyObj('TranslateService', ['instant']);
mockTranslateService.instant.and.callFake((key: string) => key);
mockDataInitService = jasmine.createSpyObj('DataInitService', [
'reInitFromRemoteSync',
]);
mockReminderService = jasmine.createSpyObj('ReminderService', ['reloadFromDatabase']);
mockUserInputWaitState = jasmine.createSpyObj(
'UserInputWaitStateService',
['startWaiting'],
{
isWaitingForUserInput$: of(false),
},
);
// startWaiting returns a stopWaiting function
mockUserInputWaitState.startWaiting.and.returnValue(() => {});
mockSuperSyncStatusService = jasmine.createSpyObj('SuperSyncStatusService', [], {
isConfirmedInSync: signal(false),
hasNoPendingOps: signal(false),
});
TestBed.configureTestingModule({
providers: [
SyncWrapperService,
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: OperationLogSyncService, useValue: mockSyncService },
{ provide: WrappedProviderService, useValue: mockWrappedProvider },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: LegacyPfDbService, useValue: mockLegacyPfDb },
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
{ provide: TranslateService, useValue: mockTranslateService },
{ provide: MatDialog, useValue: mockMatDialog },
{ provide: SnackService, useValue: mockSnackService },
{ provide: DataInitService, useValue: mockDataInitService },
{ provide: ReminderService, useValue: mockReminderService },
{ provide: UserInputWaitStateService, useValue: mockUserInputWaitState },
{ provide: SuperSyncStatusService, useValue: mockSuperSyncStatusService },
],
});
service = TestBed.inject(SyncWrapperService);
});
describe('sync() method', () => {
it('should return HANDLED_ERROR when sync already in progress', async () => {
// Start first sync but don't await it
const firstSync = service.sync();
// Try to start another sync while first is in progress
const secondResult = await service.sync();
expect(secondResult).toBe('HANDLED_ERROR');
// Clean up first sync
await firstSync;
});
it('should set isSyncInProgress true during sync, false after', async () => {
expect(service.isSyncInProgressSync()).toBe(false);
const syncPromise = service.sync();
// Should be true during sync
expect(service.isSyncInProgressSync()).toBe(true);
await syncPromise;
// Should be false after sync
expect(service.isSyncInProgressSync()).toBe(false);
});
it('should reset isSyncInProgress even on error', async () => {
mockWrappedProvider.getOperationSyncCapable.and.returnValue(
Promise.reject(new Error('Test error')),
);
expect(service.isSyncInProgressSync()).toBe(false);
await service.sync();
// Should be false after error
expect(service.isSyncInProgressSync()).toBe(false);
});
it('should return InSync on successful sync', async () => {
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
});
});
describe('_sync() - Provider handling', () => {
it('should call _syncVectorClockToPfapi for WebDAV provider', async () => {
configSubject.next(createMockSyncConfig(LegacySyncProvider.WebDAV));
mockOpLogStore.getVectorClockEntry.and.returnValue(
Promise.resolve({
clock: { clientA: 5 },
lastUpdate: Date.now(),
}),
);
await service.sync();
// Should have loaded and saved meta model for vector clock sync
expect(mockLegacyPfDb.loadMetaModel).toHaveBeenCalled();
expect(mockLegacyPfDb.saveMetaModel).toHaveBeenCalled();
});
it('should call _syncVectorClockToPfapi for Dropbox provider', async () => {
configSubject.next(createMockSyncConfig(LegacySyncProvider.Dropbox));
mockOpLogStore.getVectorClockEntry.and.returnValue(
Promise.resolve({
clock: { clientA: 5 },
lastUpdate: Date.now(),
}),
);
await service.sync();
expect(mockLegacyPfDb.loadMetaModel).toHaveBeenCalled();
expect(mockLegacyPfDb.saveMetaModel).toHaveBeenCalled();
});
it('should NOT call _syncVectorClockToPfapi for SuperSync provider', async () => {
configSubject.next(createMockSyncConfig(LegacySyncProvider.SuperSync));
await service.sync();
// Should NOT have tried to sync vector clock for SuperSync
expect(mockLegacyPfDb.saveMetaModel).not.toHaveBeenCalled();
});
it('should return InSync when provider does not support operation sync', async () => {
mockWrappedProvider.getOperationSyncCapable.and.returnValue(Promise.resolve(null));
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled();
expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled();
});
});
describe('_sync() - Sync flow', () => {
it('should download before upload', async () => {
const callOrder: string[] = [];
mockSyncService.downloadRemoteOps.and.callFake(async () => {
callOrder.push('download');
return { newOpsCount: 0, serverMigrationHandled: false, localWinOpsCreated: 0 };
});
mockSyncService.uploadPendingOps.and.callFake(async () => {
callOrder.push('upload');
return {
uploadedCount: 0,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
};
});
await service.sync();
expect(callOrder).toEqual(['download', 'upload']);
});
it('should re-upload when localWinOpsCreated > 0 from download', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
newOpsCount: 5,
serverMigrationHandled: false,
localWinOpsCreated: 3, // LWW created 3 local-win ops
}),
);
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 3,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
}),
);
await service.sync();
// Upload should be called twice: initial + re-upload for LWW ops
expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
});
it('should re-upload when localWinOpsCreated > 0 from upload', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
newOpsCount: 0,
serverMigrationHandled: false,
localWinOpsCreated: 0,
}),
);
// First upload returns localWinOpsCreated > 0
let uploadCallCount = 0;
mockSyncService.uploadPendingOps.and.callFake(async () => {
uploadCallCount++;
if (uploadCallCount === 1) {
return {
uploadedCount: 2,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 2, // LWW created ops from piggybacked
};
}
return {
uploadedCount: 2,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
};
});
await service.sync();
// Upload should be called twice
expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
});
it('should NOT re-upload when no localWinOpsCreated', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
newOpsCount: 5,
serverMigrationHandled: false,
localWinOpsCreated: 0,
}),
);
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 3,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
}),
);
await service.sync();
// Upload should be called only once
expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(1);
});
});
describe('Status handling', () => {
it('should set setSyncStatus IN_SYNC after successful sync', async () => {
await service.sync();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
it('should NOT set IN_SYNC when error occurs', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new Error('Network error')),
);
await service.sync();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
});
it('should set ERROR and return HANDLED_ERROR when upload has rejected ops', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 0,
rejectedCount: 1,
piggybackedOps: [],
rejectedOps: [{ opId: 'test-op', error: 'Payload too complex (max depth 50)' }],
localWinOpsCreated: 0,
}),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
});
it('should set ERROR for non-payload rejected ops', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 0,
rejectedCount: 1,
piggybackedOps: [],
rejectedOps: [{ opId: 'test-op', error: 'Some other rejection' }],
localWinOpsCreated: 0,
}),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
});
});
describe('Error handling', () => {
it('should handle PotentialCorsError with snack message', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new PotentialCorsError('https://example.com')),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
}),
);
});
it('should handle AuthFailSPError with config dialog action', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new AuthFailSPError()),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
actionFn: jasmine.any(Function),
}),
);
});
it('should handle SyncAlreadyInProgressError silently', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new SyncAlreadyInProgressError()),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
// Should NOT show snack for this error
expect(mockSnackService.open).not.toHaveBeenCalled();
});
describe('LocalDataConflictError handling', () => {
beforeEach(() => {
mockOpLogStore.getVectorClockEntry.and.returnValue(
Promise.resolve({
clock: { clientA: 5 },
lastUpdate: Date.now(),
}),
);
});
it('should catch LocalDataConflictError and open conflict dialog', async () => {
const conflictError = new LocalDataConflictError(
3, // unsyncedCount
{ tasks: [{ id: 'remote-task' }] }, // remoteSnapshotState
{ clientB: 5 }, // remoteVectorClock
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
// Mock dialog to return USE_LOCAL
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
mockSyncService.forceUploadLocalState = jasmine
.createSpy('forceUploadLocalState')
.and.resolveTo();
await service.sync();
// Should open conflict dialog
expect(mockMatDialog.open).toHaveBeenCalled();
});
it('should call forceUploadLocalState when user chooses USE_LOCAL', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
mockSyncService.forceUploadLocalState = jasmine
.createSpy('forceUploadLocalState')
.and.resolveTo();
const result = await service.sync();
expect(mockSyncService.forceUploadLocalState).toHaveBeenCalledWith(
mockSyncCapableProvider,
);
expect(result).toBe(SyncStatus.InSync);
});
it('should call forceDownloadRemoteState when user chooses USE_REMOTE', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_REMOTE'),
} as any);
mockSyncService.forceDownloadRemoteState = jasmine
.createSpy('forceDownloadRemoteState')
.and.resolveTo();
const result = await service.sync();
expect(mockSyncService.forceDownloadRemoteState).toHaveBeenCalledWith(
mockSyncCapableProvider,
);
expect(result).toBe(SyncStatus.InSync);
});
it('should return HANDLED_ERROR when user cancels conflict dialog', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
// User cancels dialog (returns undefined)
mockMatDialog.open.and.returnValue({
afterClosed: () => of(undefined),
} as any);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalled();
});
it('should return HANDLED_ERROR when forceUploadLocalState fails', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
mockSyncService.forceUploadLocalState = jasmine
.createSpy('forceUploadLocalState')
.and.rejectWith(new Error('Upload failed'));
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
}),
);
});
it('should return HANDLED_ERROR when forceDownloadRemoteState fails', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_REMOTE'),
} as any);
mockSyncService.forceDownloadRemoteState = jasmine
.createSpy('forceDownloadRemoteState')
.and.rejectWith(new Error('Download failed'));
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
}),
);
});
it('should return HANDLED_ERROR when provider becomes unavailable during resolution', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
// First call returns provider (for initial sync), second call returns null (during resolution)
let callCount = 0;
mockWrappedProvider.getOperationSyncCapable.and.callFake(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(mockSyncCapableProvider);
}
return Promise.resolve(null);
});
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
});
it('should call startWaiting before showing dialog and stop after resolution', async () => {
const stopWaitingSpy = jasmine.createSpy('stopWaiting');
mockUserInputWaitState.startWaiting.and.returnValue(stopWaitingSpy);
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
mockSyncService.forceUploadLocalState = jasmine
.createSpy('forceUploadLocalState')
.and.resolveTo();
await service.sync();
expect(mockUserInputWaitState.startWaiting).toHaveBeenCalledWith(
'local-data-conflict',
);
expect(stopWaitingSpy).toHaveBeenCalled();
});
it('should call stopWaiting even when resolution fails', async () => {
const stopWaitingSpy = jasmine.createSpy('stopWaiting');
mockUserInputWaitState.startWaiting.and.returnValue(stopWaitingSpy);
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
mockSyncService.forceUploadLocalState = jasmine
.createSpy('forceUploadLocalState')
.and.rejectWith(new Error('Upload failed'));
await service.sync();
// stopWaiting should be called even on error (finally block)
expect(stopWaitingSpy).toHaveBeenCalled();
});
});
it('should handle permission errors with appropriate message', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new Error('EACCES: permission denied')),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
}),
);
});
it('should handle unknown errors with generic snack', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new Error('Some unexpected error')),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
}),
);
});
});
describe('isSyncInProgressSync()', () => {
it('should return false initially', () => {
expect(service.isSyncInProgressSync()).toBe(false);
});
});
describe('syncProviderId$', () => {
it('should convert LegacySyncProvider to SyncProviderId', (done) => {
configSubject.next(createMockSyncConfig(LegacySyncProvider.SuperSync));
service.syncProviderId$.subscribe((providerId) => {
expect(providerId).toBe(SyncProviderId.SuperSync);
done();
});
});
it('should return null for null sync provider', (done) => {
configSubject.next(createMockSyncConfig(null));
service.syncProviderId$.subscribe((providerId) => {
expect(providerId).toBeNull();
done();
});
});
});
describe('superSyncIsConfirmedInSync$', () => {
let signalService: SyncWrapperService;
let isConfirmedSignal: ReturnType<typeof signal<boolean>>;
let signalConfigSubject: BehaviorSubject<any>;
const createSignalMockConfig = (
provider: LegacySyncProvider | null,
): { sync: any } => ({
sync: {
syncProvider: provider,
syncInterval: 60000,
},
});
const createServiceWithSignal = (initialValue: boolean): SyncWrapperService => {
isConfirmedSignal = signal(initialValue);
signalConfigSubject = new BehaviorSubject(
createSignalMockConfig(LegacySyncProvider.SuperSync),
);
const signalMockSuperSyncStatusService = {
isConfirmedInSync: isConfirmedSignal,
hasNoPendingOps: signal(true), // When isConfirmedInSync is true, hasNoPendingOps is also true
markRemoteChecked: jasmine.createSpy('markRemoteChecked'),
clearScope: jasmine.createSpy('clearScope'),
updatePendingOpsStatus: jasmine.createSpy('updatePendingOpsStatus'),
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
SyncWrapperService,
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: OperationLogSyncService, useValue: mockSyncService },
{ provide: WrappedProviderService, useValue: mockWrappedProvider },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: LegacyPfDbService, useValue: mockLegacyPfDb },
{
provide: GlobalConfigService,
useValue: { cfg$: signalConfigSubject.asObservable() },
},
{ provide: TranslateService, useValue: mockTranslateService },
{ provide: MatDialog, useValue: mockMatDialog },
{ provide: SnackService, useValue: mockSnackService },
{ provide: DataInitService, useValue: mockDataInitService },
{ provide: ReminderService, useValue: mockReminderService },
{ provide: UserInputWaitStateService, useValue: mockUserInputWaitState },
{ provide: SuperSyncStatusService, useValue: signalMockSuperSyncStatusService },
],
});
return TestBed.inject(SyncWrapperService);
};
describe('with SuperSync provider', () => {
it('should return true when SuperSyncStatusService.isConfirmedInSync is true', (done) => {
signalService = createServiceWithSignal(true);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.SuperSync));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(true);
done();
});
});
it('should return false when SuperSyncStatusService.isConfirmedInSync is false', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.SuperSync));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(false);
done();
});
});
});
describe('with file-based providers', () => {
it('should return false for WebDAV when status service returns false', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.WebDAV));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(false);
done();
});
});
it('should return true for WebDAV when status service returns true', (done) => {
signalService = createServiceWithSignal(true);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.WebDAV));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(true);
done();
});
});
it('should return false for Dropbox when status service returns false', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.Dropbox));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(false);
done();
});
});
it('should return true for Dropbox when status service returns true', (done) => {
signalService = createServiceWithSignal(true);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.Dropbox));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(true);
done();
});
});
it('should return false for LocalFile when status service returns false', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.LocalFile));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(false);
done();
});
});
it('should return true for LocalFile when status service returns true', (done) => {
signalService = createServiceWithSignal(true);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.LocalFile));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(true);
done();
});
});
});
});
describe('hasNoPendingOps$', () => {
let signalService: SyncWrapperService;
let hasNoPendingOpsSignal: ReturnType<typeof signal<boolean>>;
const createServiceWithPendingOpsSignal = (
hasNoPendingOps: boolean,
): SyncWrapperService => {
hasNoPendingOpsSignal = signal(hasNoPendingOps);
const signalMockSuperSyncStatusService = {
isConfirmedInSync: signal(false),
hasNoPendingOps: hasNoPendingOpsSignal,
markRemoteChecked: jasmine.createSpy('markRemoteChecked'),
clearScope: jasmine.createSpy('clearScope'),
updatePendingOpsStatus: jasmine.createSpy('updatePendingOpsStatus'),
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
SyncWrapperService,
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: OperationLogSyncService, useValue: mockSyncService },
{ provide: WrappedProviderService, useValue: mockWrappedProvider },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: LegacyPfDbService, useValue: mockLegacyPfDb },
{
provide: GlobalConfigService,
useValue: { cfg$: configSubject.asObservable() },
},
{ provide: TranslateService, useValue: mockTranslateService },
{ provide: MatDialog, useValue: mockMatDialog },
{ provide: SnackService, useValue: mockSnackService },
{ provide: DataInitService, useValue: mockDataInitService },
{ provide: ReminderService, useValue: mockReminderService },
{ provide: UserInputWaitStateService, useValue: mockUserInputWaitState },
{ provide: SuperSyncStatusService, useValue: signalMockSuperSyncStatusService },
],
});
return TestBed.inject(SyncWrapperService);
};
it('should return true when hasNoPendingOps signal is true', (done) => {
signalService = createServiceWithPendingOpsSignal(true);
signalService.hasNoPendingOps$.subscribe((hasNoPending) => {
expect(hasNoPending).toBe(true);
done();
});
});
it('should return false when hasNoPendingOps signal is false', (done) => {
signalService = createServiceWithPendingOpsSignal(false);
signalService.hasNoPendingOps$.subscribe((hasNoPending) => {
expect(hasNoPending).toBe(false);
done();
});
});
});
});