test(sync): add edge case tests for conflict resolution error handling

- Add tests for forceUploadLocalState/forceDownloadRemoteState failures
- Add test for provider becoming unavailable during resolution
- Add tests for UserInputWaitState lifecycle (startWaiting/stopWaiting)
- Fix bug: add catch block to _handleLocalDataConflict to handle
  resolution errors (previously caused unhandled promise rejections)
This commit is contained in:
Johannes Millan 2026-01-08 11:54:54 +01:00
parent 4cfaf6bd49
commit 77d09796d8
2 changed files with 253 additions and 0 deletions

View file

@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, of } from 'rxjs';
import { SyncWrapperService } from './sync-wrapper.service';
@ -535,6 +536,137 @@ describe('SyncWrapperService', () => {
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 () => {
@ -593,4 +725,113 @@ describe('SyncWrapperService', () => {
});
});
});
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,
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 non-SuperSync providers (current behavior)', () => {
it('should return true for WebDAV regardless of status service', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.WebDAV));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
// Currently returns true regardless of status (this is the bug we'll fix)
expect(isConfirmed).toBe(true);
done();
});
});
it('should return true for Dropbox regardless of status service', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.Dropbox));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(true);
done();
});
});
it('should return true for LocalFile regardless of status service', (done) => {
signalService = createServiceWithSignal(false);
signalConfigSubject.next(createSignalMockConfig(LegacySyncProvider.LocalFile));
signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
expect(isConfirmed).toBe(true);
done();
});
});
});
});
});

View file

@ -572,6 +572,18 @@ export class SyncWrapperService {
});
return 'HANDLED_ERROR';
}
} catch (resolutionError) {
// Error during conflict resolution (forceUpload or forceDownload failed)
SyncLog.err(
'SyncWrapperService: Error during conflict resolution:',
resolutionError,
);
const errStr = getSyncErrorStr(resolutionError);
this._snackService.open({
msg: errStr,
type: 'ERROR',
});
return 'HANDLED_ERROR';
} finally {
stopWaiting();
}