super-productivity/src/app/imex/sync/sync-wrapper.service.spec.ts
Thorsten Klein 7fa8f12132
feat(sync): add Helm chart and WebSocket push for SuperSync (#6971)
* feat(sync): add Helm chart for SuperSync Kubernetes deployment

Includes Deployment, StatefulSet (PostgreSQL), Service, Ingress,
ConfigMap, Secret, HPA, PDB, NetworkPolicy, and test templates.
Supports both bundled PostgreSQL and external database configurations.

* feat(sync): add WebSocket push notifications for near-realtime sync

Server: Fastify WebSocket plugin with connection manager, app-level
heartbeat (30s), debounced notifications, and per-user routing.
Client: WebSocket service with exponential backoff reconnection,
WS-triggered download service, and reduced polling when connected.

* fix(sync): improve WebSocket error handling and reactivity

Make syncInterval$ reactive to WS connection state, fix Set/Map
mutation during heartbeat iteration, add error handling to WS route
handler, separate JSON parsing from message handling, detect auth
failures in WS-triggered downloads, and add logging to all empty
catch blocks.

* fix(sync): address PR review findings for WebSocket and Helm

Fix race condition in WS-triggered download pipeline by moving
isSyncInProgress filter after debounce and adding guard in
_downloadOps. Add logging to remaining empty catch blocks, fix
missing $NODE_IP in Helm NOTES.txt, and correct inaccurate
comments in values.yaml and ws-triggered-download.service.ts.

* fix(sync): add rate limiting to WebSocket upgrade endpoint

Limit WS connection attempts to 10 per minute per IP to prevent
connection flooding, matching the rate-limit pattern used by other
sync endpoints.

* fix(sync): extract shared constants, fix debounce correctness, replace deprecated toPromise

Extract CLIENT_ID_REGEX and MAX_CLIENT_ID_LENGTH into sync.const.ts
to prevent drift between sync.routes.ts and websocket.routes.ts.

Fix debounce in notifyNewOps to accumulate excluded client IDs across
rapid calls from different clients, preventing self-notifications.

Replace deprecated toPromise() with firstValueFrom in connectWebSocket
and _sync methods. Add .catch() to reconnect path and logging to
silent early returns in _downloadOps.

* fix(build): restore correct package-lock.json and fix upstream lint errors

* revert: restore upstream HTML formatting to match CI prettier config

* fix(sync): use DownloadOutcome discriminated union in WsTriggeredDownloadService

* fix(sync): narrow TokenVerificationResult before accessing userId

* fix(sync): await async getProviderById in connectWebSocket

Missing await caused the Promise object to be cast to
SuperSyncProvider, making getWebSocketParams undefined.

* feat(sync): add SEED_USERS env var to create verified users on startup

For self-hosted single-user setups: set SEED_USERS=email1,email2 to
create verified users on boot and log their access tokens. Skips
existing users. Removes need for SMTP/magic link registration.

Also fix Dockerfile to use npm install instead of npm ci for lockfile
compatibility.

* fix(sync): address PR review feedback for Helm chart and server

Security:
- Remove seed-users.ts (logged full JWT tokens to stdout)
- Add fail assertions for missing jwtSecret and postgresql.password
- Add smtp.user/smtp.password values fields
- Add from: selector to NetworkPolicy ingress
- Add egress rule for external database when postgresql.enabled=false

Correctness:
- Fix PostgreSQL StatefulSet indentation for non-persistent mode
- Fix NOTES.txt panic on empty tls list (use len instead of index)
- Restore npm ci in Dockerfile by using node:24-alpine (ships npm 11)
- Add Recreate deployment strategy when using RWO PVC

Operational:
- Add fail guard preventing HPA maxReplicas > 1 (in-memory WS state)
- Fix PDB to use maxUnavailable instead of minAvailable
- Add WebSocket ingress timeout annotation examples
- Add Prisma db push init container for schema migrations
- Default serviceAccount.automount to false
- Add Chart.yaml maintainers, home, sources metadata

* feat(sync): add ALLOWED_EMAILS env var to restrict registration

Supports fully qualified emails (user@example.com) and domain
wildcards (*@example.com). When unset, all emails are allowed.
Applied to all three registration endpoints (passkey options,
passkey verify, magic link).

* fix(sync): exempt health endpoint from rate limiting

Kubernetes liveness/readiness probes hit /health every 5-15s,
exhausting the global rate limit (100 req/15min) and causing
429 responses that trigger container restarts.

* fix(sync): harden WebSocket, Helm chart, and sync services from multi-agent review

- Pin prisma@5.22.0 in init container and use migrate deploy instead of db push
- Add per-user WebSocket connection limit (max 10) to prevent resource exhaustion
- Add replicaCount > 1 fail guard in deployment template (in-memory WS state)
- Set maxPayload: 1024 on WebSocket plugin (only pong messages expected)
- Remove premature IN_SYNC status from download-only WS-triggered sync
- Fix double removeConnection on error+close events (let close handle cleanup)
- DRY debounce logic in notifyNewOps and store latestSeq on pending entry
- Remove dead fromClientId from NewOpsNotification and WsMessage interfaces
- Add takeUntilDestroyed to _wsProviderCleanup subscription
- Simplify email-allowlist.ts to eager init (eliminate mutable state)
- Guard connectWebSocket at call site for non-SuperSync providers
- Add distinctUntilChanged to syncInterval$ to prevent unnecessary resets
- Add container-level securityContext to PostgreSQL StatefulSet
- Fix HPA maxReplicas default to 1 (matches single-replica constraint)

* fix(sync): restore migration in Dockerfile CMD for non-Helm Docker users

Helm users get migration via init container (runs first, CMD becomes no-op).
Docker-compose/plain Docker users get automatic migration back on startup.

* fix(boards): add missing drag delay for touch and extract shared signal

Add cdkDragStartDelay to board-panel drag items, which was missing
entirely. Extract the repeated `isTouchActive() ? DRAG_DELAY_FOR_TOUCH : 0`
expression into a shared `dragDelayForTouch` computed signal and refactor
all 8 components to use it.

* test(sync): add comprehensive WebSocket test coverage

Add unit tests for the new WebSocket real-time sync notification pipeline:

- WebSocketConnectionService (server): connection lifecycle, max-per-user
  limits, notification debouncing, heartbeat, graceful shutdown (13 tests)
- WebSocket routes validation: token/clientId validation, close codes,
  error handling, validation ordering (18 tests)
- SuperSyncWebSocketService (frontend): reconnection with exponential
  backoff, heartbeat, disconnect cleanup, URL conversion (6 tests)
- WsTriggeredDownloadService: auth error handling, pipeline resilience,
  start() idempotency (3 tests)
- SyncWrapperService: connectWebSocket guards for non-SuperSync, null
  params, and already-connected cases (3 tests)
- E2E realtime push: verifies WS-triggered download between two clients

Quality fixes:
- Replace waitForTimeout with syncAndWait in E2E
- Add explanatory comment for microtask flushing in sync-wrapper spec
- Use getter for isSyncInProgress mock in ws-triggered-download spec

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-03-30 21:34:30 +02:00

1690 lines
57 KiB
TypeScript

import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, firstValueFrom, 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 { SuperSyncWebSocketService } from '../../op-log/sync/super-sync-websocket.service';
import { WsTriggeredDownloadService } from '../../op-log/sync/ws-triggered-download.service';
import {
AuthFailSPError,
MissingCredentialsSPError,
PotentialCorsError,
SyncProviderId,
SyncStatus,
} from '../../op-log/sync-exports';
import {
SyncAlreadyInProgressError,
LocalDataConflictError,
MissingRefreshTokenAPIError,
} from '../../op-log/core/errors/sync-errors';
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
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 mockSuperSyncWsService: jasmine.SpyObj<SuperSyncWebSocketService> & {
isConnected: ReturnType<typeof signal<boolean>>;
};
let mockWsDownloadService: jasmine.SpyObj<WsTriggeredDownloadService>;
let configSubject: BehaviorSubject<any>;
let mockSyncCapableProvider: any;
const createMockSyncConfig = (
provider: SyncProviderId | null,
overrides: Record<string, unknown> = {},
): { sync: any } => ({
sync: {
syncProvider: provider,
syncInterval: 60000,
isManualSyncOnly: false,
...overrides,
},
});
beforeEach(() => {
configSubject = new BehaviorSubject(createMockSyncConfig(SyncProviderId.SuperSync));
mockSyncCapableProvider = {
uploadOperations: jasmine.createSpy('uploadOperations'),
downloadOperations: jasmine.createSpy('downloadOperations'),
};
mockProviderManager = jasmine.createSpyObj(
'SyncProviderManager',
[
'getActiveProvider',
'setSyncStatus',
'setProviderConfig',
'getProviderById',
'clearAuthCredentials',
'getLastSyncedProviderId',
'setLastSyncedProviderId',
],
{
syncStatus$: of('SYNCED'),
isProviderReady$: of(true),
isSyncInProgress: false,
},
);
mockProviderManager.clearAuthCredentials.and.returnValue(Promise.resolve());
mockProviderManager.getProviderById.and.returnValue(Promise.resolve(undefined));
mockProviderManager.getLastSyncedProviderId.and.returnValue(null);
mockProviderManager.getActiveProvider.and.returnValue({
id: SyncProviderId.SuperSync,
} as any);
mockSyncService = jasmine.createSpyObj('OperationLogSyncService', [
'downloadRemoteOps',
'uploadPendingOps',
]);
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
kind: 'no_new_ops' as const,
}),
);
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 0,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
}),
);
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'], {
openDialogs: [],
});
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',
['clearScope'],
{
isConfirmedInSync: signal(false),
hasNoPendingOps: signal(false),
},
);
mockSuperSyncWsService = Object.assign(
jasmine.createSpyObj('SuperSyncWebSocketService', ['connect', 'disconnect']),
{
isConnected: signal(false),
},
);
mockSuperSyncWsService.connect.and.returnValue(Promise.resolve());
mockWsDownloadService = jasmine.createSpyObj('WsTriggeredDownloadService', [
'start',
'stop',
]);
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 },
{ provide: SuperSyncWebSocketService, useValue: mockSuperSyncWsService },
{ provide: WsTriggeredDownloadService, useValue: mockWsDownloadService },
],
});
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('syncInterval$', () => {
it('should use 1 minute for SuperSync when websocket is disconnected', async () => {
expect(await firstValueFrom(service.syncInterval$)).toBe(60000);
});
it('should use 5 minutes for SuperSync when websocket is connected', async () => {
mockSuperSyncWsService.isConnected.set(true);
configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));
expect(await firstValueFrom(service.syncInterval$)).toBe(300000);
});
it('should return 0 for manual sync only', async () => {
configSubject.next(
createMockSyncConfig(SyncProviderId.SuperSync, { isManualSyncOnly: true }),
);
expect(await firstValueFrom(service.syncInterval$)).toBe(0);
});
it('should use configured interval for non-SuperSync providers', async () => {
configSubject.next(
createMockSyncConfig(SyncProviderId.WebDAV, { syncInterval: 120000 }),
);
expect(await firstValueFrom(service.syncInterval$)).toBe(120000);
});
});
describe('websocket integration', () => {
it('should disconnect websocket when provider changes away from SuperSync', async () => {
configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
await Promise.resolve();
expect(mockWsDownloadService.stop).toHaveBeenCalled();
expect(mockSuperSyncWsService.disconnect).toHaveBeenCalled();
});
it('should connect websocket after successful SuperSync sync', async () => {
const mockProvider = {
getWebSocketParams: jasmine.createSpy().and.returnValue(
Promise.resolve({
baseUrl: 'https://sync.example.com',
accessToken: 'token-123',
}),
),
};
mockProviderManager.getProviderById.and.returnValue(
Promise.resolve(mockProvider as any),
);
await service.sync();
// Flush microtasks: connectWebSocket() is fire-and-forget (not awaited in _sync).
// Two flushes needed: one for getProviderById, one for getWebSocketParams + connect.
await Promise.resolve();
await Promise.resolve();
expect(mockProviderManager.getProviderById).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
);
expect(mockProvider.getWebSocketParams).toHaveBeenCalled();
expect(mockSuperSyncWsService.connect).toHaveBeenCalledWith(
'https://sync.example.com',
'token-123',
);
expect(mockWsDownloadService.start).toHaveBeenCalled();
});
it('should no-op connectWebSocket when provider is not SuperSync', async () => {
configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
await service.connectWebSocket();
await Promise.resolve();
expect(mockProviderManager.getProviderById).not.toHaveBeenCalled();
});
it('should no-op connectWebSocket when getWebSocketParams returns null', async () => {
const mockProvider = {
getWebSocketParams: jasmine.createSpy().and.returnValue(Promise.resolve(null)),
};
mockProviderManager.getProviderById.and.returnValue(
Promise.resolve(mockProvider as any),
);
await service.connectWebSocket();
await Promise.resolve();
await Promise.resolve();
expect(mockProvider.getWebSocketParams).toHaveBeenCalled();
expect(mockSuperSyncWsService.connect).not.toHaveBeenCalled();
});
it('should skip WS connection after sync if already connected', async () => {
mockSuperSyncWsService.isConnected.set(true);
await service.sync();
await Promise.resolve();
await Promise.resolve();
expect(mockSuperSyncWsService.connect).not.toHaveBeenCalled();
});
});
describe('_sync() - Provider handling', () => {
it('should call _syncVectorClockToPfapi for WebDAV provider', async () => {
configSubject.next(createMockSyncConfig(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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() - Provider switch detection', () => {
it('should pass forceFromSeq0 when provider has changed', async () => {
mockProviderManager.getLastSyncedProviderId.and.returnValue(SyncProviderId.Dropbox);
configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));
await service.sync();
expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledWith(
mockSyncCapableProvider,
{ forceFromSeq0: true },
);
});
it('should NOT pass forceFromSeq0 when provider is the same', async () => {
mockProviderManager.getLastSyncedProviderId.and.returnValue(
SyncProviderId.SuperSync,
);
configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));
await service.sync();
expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledWith(
mockSyncCapableProvider,
undefined,
);
});
it('should NOT pass forceFromSeq0 on first-ever sync (no last synced provider)', async () => {
mockProviderManager.getLastSyncedProviderId.and.returnValue(null);
await service.sync();
expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledWith(
mockSyncCapableProvider,
undefined,
);
});
it('should update lastSyncedProviderId after successful download', async () => {
configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));
await service.sync();
expect(mockProviderManager.setLastSyncedProviderId).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
);
});
it('should NOT update lastSyncedProviderId when download is cancelled', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
kind: 'cancelled' as const,
}),
);
await service.sync();
expect(mockProviderManager.setLastSyncedProviderId).not.toHaveBeenCalled();
});
});
describe('_sync() - Sync flow', () => {
it('should download before upload', async () => {
const callOrder: string[] = [];
mockSyncService.downloadRemoteOps.and.callFake(async () => {
callOrder.push('download');
return { kind: 'no_new_ops' as const };
});
mockSyncService.uploadPendingOps.and.callFake(async () => {
callOrder.push('upload');
return {
kind: 'completed' as const,
uploadedCount: 0,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
};
});
await service.sync();
expect(callOrder).toEqual(['download', 'upload']);
});
it('should re-upload when localWinOpsCreated > 0 from download', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
kind: 'ops_processed' as const,
newOpsCount: 5,
localWinOpsCreated: 3, // LWW created 3 local-win ops
}),
);
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 3,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
}),
);
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({
kind: 'no_new_ops' as const,
}),
);
// First upload returns localWinOpsCreated > 0
let uploadCallCount = 0;
mockSyncService.uploadPendingOps.and.callFake(async () => {
uploadCallCount++;
if (uploadCallCount === 1) {
return {
kind: 'completed' as const,
uploadedCount: 2,
piggybackedOpsCount: 0,
localWinOpsCreated: 2, // LWW created ops from piggybacked
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
};
}
return {
kind: 'completed' as const,
uploadedCount: 2,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
};
});
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({
kind: 'ops_processed' as const,
newOpsCount: 5,
localWinOpsCreated: 0,
}),
);
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 3,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
}),
);
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();
// setSyncStatus is called with 'SYNCING' at start, but should NOT be called with 'IN_SYNC' on error
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
});
it('should set ERROR and return HANDLED_ERROR when upload has rejected ops with "Payload too complex"', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 0,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 1,
hasMorePiggyback: false,
rejectedOps: [{ opId: 'test-op', error: 'Payload too complex (max depth 50)' }],
}),
);
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 and return HANDLED_ERROR when upload has rejected ops with "Payload too large"', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 0,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 1,
hasMorePiggyback: false,
rejectedOps: [{ opId: 'test-op', error: 'Payload too large' }],
}),
);
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({
kind: 'completed' as const,
uploadedCount: 0,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 1,
hasMorePiggyback: false,
rejectedOps: [{ opId: 'test-op', error: 'Some other rejection' }],
}),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
});
it('should set IN_SYNC when some ops uploaded and none rejected', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 5,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
}),
);
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
it('should set ERROR when multiple ops rejected', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 3,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 2,
hasMorePiggyback: false,
rejectedOps: [
{ opId: 'op1', error: 'Conflict' },
{ opId: 'op2', error: 'Validation failed' },
],
}),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
});
it('should set IN_SYNC when uploadResult is blocked_fresh_client', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({ kind: 'blocked_fresh_client' as const }),
);
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
it('should set IN_SYNC when permanentRejectionCount is 0 even with empty rejectedOps array', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 0,
piggybackedOpsCount: 0,
localWinOpsCreated: 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
}),
);
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
});
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(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSuperSyncStatusService.clearScope).toHaveBeenCalled();
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
actionFn: jasmine.any(Function),
}),
);
});
it('should handle MissingCredentialsSPError with config dialog action', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new MissingCredentialsSPError('Dropbox no token')),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSuperSyncStatusService.clearScope).toHaveBeenCalled();
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
actionFn: jasmine.any(Function),
}),
);
});
it('should handle MissingRefreshTokenAPIError with config dialog action', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new MissingRefreshTokenAPIError()),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSuperSyncStatusService.clearScope).toHaveBeenCalled();
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
actionFn: jasmine.any(Function),
}),
);
});
it('should NOT call clearAuthCredentials on AuthFailSPError for SuperSync', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new AuthFailSPError()),
);
await service.sync();
expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();
});
it('should call clearAuthCredentials on MissingCredentialsSPError', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new MissingCredentialsSPError('no token')),
);
await service.sync();
expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
);
});
it('should call clearAuthCredentials on MissingRefreshTokenAPIError', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new MissingRefreshTokenAPIError()),
);
await service.sync();
expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
);
});
it('should still show snack when clearAuthCredentials throws', async () => {
mockProviderManager.clearAuthCredentials.and.returnValue(
Promise.reject(new Error('IndexedDB error')),
);
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new MissingCredentialsSPError('no token')),
);
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('isEncryptionOperationInProgress', () => {
it('should return false initially', () => {
expect(service.isEncryptionOperationInProgress).toBe(false);
});
it('should return true during runWithSyncBlocked execution', async () => {
let capturedValue = false;
await service.runWithSyncBlocked(async () => {
capturedValue = service.isEncryptionOperationInProgress;
});
expect(capturedValue).toBe(true);
});
it('should return false after runWithSyncBlocked completes', async () => {
await service.runWithSyncBlocked(async () => {
// do nothing
});
expect(service.isEncryptionOperationInProgress).toBe(false);
});
it('should return false after runWithSyncBlocked throws', async () => {
try {
await service.runWithSyncBlocked(async () => {
throw new Error('Test error');
});
} catch {
// expected
}
expect(service.isEncryptionOperationInProgress).toBe(false);
});
});
describe('runWithSyncBlocked()', () => {
it('should execute the operation and return its result', async () => {
const result = await service.runWithSyncBlocked(async () => {
return 'test-result';
});
expect(result).toBe('test-result');
});
it('should propagate errors from the operation', async () => {
const testError = new Error('Test operation error');
await expectAsync(
service.runWithSyncBlocked(async () => {
throw testError;
}),
).toBeRejectedWith(testError);
});
it('should block sync during operation', async () => {
let syncResultDuringOperation: SyncStatus | 'HANDLED_ERROR' | undefined;
await service.runWithSyncBlocked(async () => {
// Try to sync during encryption operation
syncResultDuringOperation = await service.sync();
});
// Sync should have been blocked and returned HANDLED_ERROR
expect(syncResultDuringOperation).toBe('HANDLED_ERROR');
});
it('should allow sync after operation completes', async () => {
await service.runWithSyncBlocked(async () => {
// do nothing
});
// Sync should work after encryption operation completes
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
});
it('should wait for ongoing sync to complete before starting operation', async () => {
const callOrder: string[] = [];
// Start a sync that takes a bit
let syncResolve: () => void;
const syncPromise = new Promise<void>((resolve) => {
syncResolve = resolve;
});
mockSyncService.downloadRemoteOps.and.callFake(async () => {
callOrder.push('sync-download-start');
await syncPromise;
callOrder.push('sync-download-end');
return { kind: 'no_new_ops' as const };
});
// Start sync
const syncCall = service.sync();
// Give sync time to start
await new Promise((r) => setTimeout(r, 10));
// Start encryption operation - should wait for sync
const encryptionOpPromise = service.runWithSyncBlocked(async () => {
callOrder.push('encryption-op');
});
// Let sync complete
syncResolve!();
await syncCall;
await encryptionOpPromise;
// Encryption operation should have waited for sync to complete
expect(callOrder).toEqual([
'sync-download-start',
'sync-download-end',
'encryption-op',
]);
});
});
describe('sync() with encryption operation blocking', () => {
it('should return HANDLED_ERROR when encryption operation is in progress', async () => {
// Manually set the flag (simulating runWithSyncBlocked is active)
service['_isEncryptionOperationInProgress$'].next(true);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
// Clean up
service['_isEncryptionOperationInProgress$'].next(false);
});
it('should not call download or upload when encryption operation is in progress', async () => {
service['_isEncryptionOperationInProgress$'].next(true);
await service.sync();
expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled();
expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled();
// Clean up
service['_isEncryptionOperationInProgress$'].next(false);
});
});
describe('syncProviderId$', () => {
it('should emit SyncProviderId from sync config', (done) => {
configSubject.next(createMockSyncConfig(SyncProviderId.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: SyncProviderId | null): { sync: any } => ({
sync: {
syncProvider: provider,
syncInterval: 60000,
},
});
const createServiceWithSignal = (initialValue: boolean): SyncWrapperService => {
isConfirmedSignal = signal(initialValue);
signalConfigSubject = new BehaviorSubject(
createSignalMockConfig(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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(SyncProviderId.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();
});
});
});
describe('_isTimeoutError', () => {
it('should detect timeout keyword in error message', () => {
const timeoutError = new Error('Request timeout after 75s');
expect(service['_isTimeoutError'](timeoutError)).toBe(true);
});
it('should detect 504 status code', () => {
const error504 = new Error('504 Gateway Timeout');
expect(service['_isTimeoutError'](error504)).toBe(true);
});
it('should detect gateway timeout phrase', () => {
const gatewayError = new Error('Error: gateway timeout from proxy');
expect(service['_isTimeoutError'](gatewayError)).toBe(true);
});
it('should be case insensitive', () => {
const uppercaseError = new Error('REQUEST TIMEOUT');
expect(service['_isTimeoutError'](uppercaseError)).toBe(true);
const mixedCaseError = new Error('Gateway TIMEOUT occurred');
expect(service['_isTimeoutError'](mixedCaseError)).toBe(true);
});
it('should not false-positive on network errors', () => {
const networkError = new Error('Network error');
expect(service['_isTimeoutError'](networkError)).toBe(false);
});
it('should not false-positive on auth errors', () => {
const authError = new Error('401 Unauthorized');
expect(service['_isTimeoutError'](authError)).toBe(false);
});
it('should not false-positive on generic errors', () => {
const genericError = new Error('Something went wrong');
expect(service['_isTimeoutError'](genericError)).toBe(false);
});
it('should handle non-Error objects', () => {
expect(service['_isTimeoutError']('timeout string')).toBe(true);
expect(service['_isTimeoutError']('regular error')).toBe(false);
});
it('should handle objects with toString()', () => {
const errorObj = { toString: () => 'Error: timeout occurred' };
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({
kind: 'no_new_ops' as const,
}),
);
// Upload always returns localWinOpsCreated: 2 (never resolves)
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
kind: 'completed' as const,
uploadedCount: 2,
piggybackedOpsCount: 0,
localWinOpsCreated: 2,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
}),
);
const result = await service.sync();
// 1 initial upload + MAX_LWW_REUPLOAD_RETRIES retries
expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(
1 + MAX_LWW_REUPLOAD_RETRIES,
);
// Should set UNKNOWN_OR_CHANGED since ops remain pending
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
'UNKNOWN_OR_CHANGED',
);
// Should return UpdateRemote to signal that unuploaded ops remain
expect(result).toBe(SyncStatus.UpdateRemote);
});
it('should exit early when retry returns localWinOpsCreated: 0', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
kind: 'no_new_ops' as const,
}),
);
let uploadCallCount = 0;
mockSyncService.uploadPendingOps.and.callFake(async () => {
uploadCallCount++;
return {
kind: 'completed' as const,
uploadedCount: 2,
piggybackedOpsCount: 0,
// First call returns 1, second call returns 0 -> exits loop
localWinOpsCreated: uploadCallCount <= 1 ? 1 : 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
};
});
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 blocked_fresh_client reupload result as 0 localWinOpsCreated and exit loop', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.resolve({
kind: 'no_new_ops' as const,
}),
);
let uploadCallCount = 0;
mockSyncService.uploadPendingOps.and.callFake(async () => {
uploadCallCount++;
if (uploadCallCount === 1) {
return {
kind: 'completed' as const,
uploadedCount: 1,
piggybackedOpsCount: 0,
localWinOpsCreated: 2,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
};
}
// Second call returns blocked_fresh_client (treated as 0 localWinOpsCreated)
return { kind: 'blocked_fresh_client' as const };
});
const result = await service.sync();
// 1 initial + 1 retry (returns blocked_fresh_client -> 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({
kind: 'ops_processed' as const,
newOpsCount: 5,
localWinOpsCreated: 2, // download produced LWW ops
}),
);
let uploadCallCount = 0;
mockSyncService.uploadPendingOps.and.callFake(async () => {
uploadCallCount++;
return {
kind: 'completed' as const,
uploadedCount: 3,
piggybackedOpsCount: 0,
// First upload also produces LWW ops, subsequent do not
localWinOpsCreated: uploadCallCount === 1 ? 1 : 0,
permanentRejectionCount: 0,
hasMorePiggyback: false,
rejectedOps: [],
};
});
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);
});
});
});