test(sync): add encryption and supersync e2e test coverage

Add comprehensive test coverage for the encryption and SuperSync features:

- Add e2e tests for encryption prompt loop, error scenarios, no-op sync
- Add e2e tests for provider switch, re-enable, account reset, server migration
- Add e2e tests for import clean-slate scenario
- Add unit tests for encryption dialogs, toggle, enable/disable services
- Add unit tests for sync-config, sync-wrapper, file-based-encryption
- Add unit tests for operation-log-sync, sync-import-filter, clean-slate
- Update supersync page object for mandatory encryption flows
This commit is contained in:
Johannes Millan 2026-03-01 12:54:10 +01:00
parent b46cce72df
commit baa9a46935
23 changed files with 2829 additions and 583 deletions

View file

@ -3,6 +3,33 @@ import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* Warm up the dev server by fetching the app once before any tests start.
* This ensures Angular compilation is complete and cached, so all workers
* get instant responses instead of competing for the first compilation.
*/
const warmUpDevServer = async (baseURL: string): Promise<void> => {
console.log(`Warming up dev server at ${baseURL}...`);
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(baseURL, { signal: AbortSignal.timeout(30000) });
if (response.ok) {
// Read the full body to ensure the server finishes compilation
await response.text();
console.log('Dev server warm-up complete');
return;
}
} catch {
if (attempt < maxRetries - 1) {
console.log(`Warm-up attempt ${attempt + 1} failed, retrying...`);
await new Promise((r) => setTimeout(r, 2000));
}
}
}
console.warn('Dev server warm-up failed — tests may be slow on first run');
};
const globalSetup = async (config: FullConfig): Promise<void> => {
// Set test environment variables
process.env.TZ = 'Europe/Berlin';
@ -30,6 +57,13 @@ const globalSetup = async (config: FullConfig): Promise<void> => {
throw error; // Fail fast if plugin build fails
}
}
// Warm up the dev server to pre-compile Angular bundles before workers start
const baseURL =
process.env.E2E_BASE_URL ||
config.projects[0]?.use?.baseURL ||
'http://localhost:4242';
await warmUpDevServer(baseURL);
};
export default globalSetup;

View file

@ -246,9 +246,10 @@ export class SuperSyncPage extends BasePage {
// Define locators for dialogs we might see
const configDialog = this.page.locator('dialog-sync-initial-cfg');
const passwordDialog = this.page.locator('dialog-enter-encryption-password');
const enableEncryptionDialog = this.page.locator('dialog-enable-encryption');
// Wait for config dialog to close OR password dialog to appear
// (password dialog appearing means config dialog closed and sync started)
// Wait for config dialog to close, password dialog to appear, or
// mandatory encryption setup dialog to appear (SuperSync requires encryption)
const configDialogClosed = await Promise.race([
configDialog
.waitFor({ state: 'hidden', timeout: 15000 })
@ -256,9 +257,36 @@ export class SuperSyncPage extends BasePage {
passwordDialog
.waitFor({ state: 'visible', timeout: 15000 })
.then(() => 'password_appeared'),
enableEncryptionDialog
.first()
.waitFor({ state: 'visible', timeout: 15000 })
.then(() => 'enable_encryption_appeared'),
]).catch(() => 'timeout');
if (configDialogClosed === 'timeout') {
if (configDialogClosed === 'enable_encryption_appeared') {
// Mandatory encryption setup dialog appeared — fill password and confirm.
// The config dialog AND the sync-wrapper may both open instances, so handle
// all of them in a loop, always targeting the topmost (highest index) one.
const defaultPw = config.password || 'e2e-default-encryption-pw';
console.log('[SuperSyncPage] Mandatory encryption dialog appeared after save');
for (let round = 0; round < 3; round++) {
const encCount = await enableEncryptionDialog.count();
if (encCount === 0) break;
console.log(
`[SuperSyncPage] Handling encryption dialog (${encCount} open, round ${round})`,
);
const topDialog = enableEncryptionDialog.nth(encCount - 1);
await topDialog.locator('input[type="password"]').first().fill(defaultPw);
await topDialog.locator('input[type="password"]').nth(1).fill(defaultPw);
await topDialog.locator('button[mat-flat-button]').click();
await expect(enableEncryptionDialog).toHaveCount(encCount - 1, {
timeout: 15000,
});
}
// After all encryption dialogs close, the config dialog save handler
// continues and closes the config dialog
await configDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
} else if (configDialogClosed === 'timeout') {
// Neither happened - try pressing Escape to close any stuck dialog
console.log(
'[SuperSyncPage] Config dialog did not close after save, trying Escape',
@ -293,25 +321,26 @@ export class SuperSyncPage extends BasePage {
// BEFORE the server's encrypted data triggers the password dialog.
// Instead, we wait specifically for dialogs that indicate definitive outcomes.
// If no dialog appears within timeout, THEN we check if sync succeeded.
const encSyncTimeout = 30000;
const outcome = await Promise.race([
passwordDialog
.waitFor({ state: 'visible', timeout: 15000 })
.waitFor({ state: 'visible', timeout: encSyncTimeout })
.then(() => 'password_dialog' as const),
this.freshClientDialog
.waitFor({ state: 'visible', timeout: 15000 })
.waitFor({ state: 'visible', timeout: encSyncTimeout })
.then(() => 'fresh_client_dialog' as const),
this.syncImportConflictDialog
.waitFor({ state: 'visible', timeout: 15000 })
.waitFor({ state: 'visible', timeout: encSyncTimeout })
.then(() => 'sync_import_dialog' as const),
decryptErrorDialog
.waitFor({ state: 'visible', timeout: 15000 })
.waitFor({ state: 'visible', timeout: encSyncTimeout })
.then(() => 'decrypt_error_dialog' as const),
// Sync error icon indicates encrypted data received but no password
this.syncErrorIcon
.waitFor({ state: 'visible', timeout: 15000 })
.waitFor({ state: 'visible', timeout: encSyncTimeout })
.then(() => 'sync_error' as const),
// Timeout fallback - we'll check state manually after timeout
this.page.waitForTimeout(15000).then(() => 'timeout' as const),
this.page.waitForTimeout(encSyncTimeout).then(() => 'timeout' as const),
]).catch(() => 'error' as const);
console.log(`[SuperSyncPage] Sync outcome: ${outcome}`);
@ -541,36 +570,160 @@ export class SuperSyncPage extends BasePage {
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 });
}
} else if (waitForInitialSync) {
// No encryption change needed - just wait for dialogs and sync
// Check if a fresh client confirmation dialog appeared
const freshDialogVisible = await this.freshClientDialog
.isVisible()
.catch(() => false);
if (freshDialogVisible) {
await this.freshClientConfirmBtn.click();
await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 });
// Encryption is mandatory for SuperSync, so even when the caller doesn't
// explicitly set isEncryptionEnabled, we must handle encryption dialogs.
const defaultPassword = config.password || 'e2e-default-encryption-pw';
const enterPasswordDialog = this.page.locator('dialog-enter-encryption-password');
// IMPORTANT: Wait for the initial sync to complete or for a dialog to appear
// BEFORE entering the dialog loop. The sync runs asynchronously after save(),
// and the mandatory encryption dialog (_promptSuperSyncEncryptionIfNeeded)
// opens AFTER sync completes. Without this wait, the dialog loop finds no
// dialogs and breaks immediately, missing the encryption dialog entirely.
console.log('[SuperSyncPage] Waiting for initial sync outcome...');
const syncTimeout = 45000;
const initialOutcome = await Promise.race([
enableEncryptionDialog
.first()
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => 'enable_encryption' as const),
enterPasswordDialog
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => 'enter_password' as const),
this.freshClientDialog
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => 'fresh_client' as const),
this.syncImportConflictDialog
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => 'sync_import' as const),
passwordDialog
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => 'password' as const),
// Sync error icon means encrypted data without password — wait for dialog
this.syncErrorIcon
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => 'sync_error' as const),
// Sync check icon means sync completed. The encryption dialog opens AFTER
// sync via the async _promptSuperSyncEncryptionIfNeeded(), so we add a
// 2s delay. If the encryption dialog appears during this delay, its promise
// resolves first and wins the race (because our .then() hasn't resolved yet).
this.syncCheckIcon
.waitFor({ state: 'visible', timeout: syncTimeout })
.then(() => this.page.waitForTimeout(2000))
.then(() => 'sync_success' as const),
]).catch(() => 'timeout' as const);
console.log(`[SuperSyncPage] Initial sync outcome: ${initialOutcome}`);
// If sync completed/errored without a dialog, wait briefly for post-sync dialogs
if (
initialOutcome === 'sync_error' ||
initialOutcome === 'sync_success' ||
initialOutcome === 'timeout'
) {
console.log('[SuperSyncPage] Checking for late-appearing post-sync dialogs...');
await Promise.race([
enableEncryptionDialog.first().waitFor({ state: 'visible', timeout: 3000 }),
enterPasswordDialog.waitFor({ state: 'visible', timeout: 3000 }),
passwordDialog.waitFor({ state: 'visible', timeout: 3000 }),
]).catch(() => {});
}
// Check if a sync import conflict dialog appeared
const syncImportDialogVisible = await this.syncImportConflictDialog
.isVisible()
.catch(() => false);
if (syncImportDialogVisible) {
await this.syncImportUseRemoteBtn.click();
await this.syncImportConflictDialog.waitFor({ state: 'hidden', timeout: 5000 });
// Handle dialogs in a loop — multiple can appear in sequence
for (let dialogRound = 0; dialogRound < 5; dialogRound++) {
// Check what dialog is currently visible
const freshDialogVisible = await this.freshClientDialog
.isVisible()
.catch(() => false);
if (freshDialogVisible) {
await this.freshClientConfirmBtn.click();
await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 });
continue;
}
const syncImportDialogVisible = await this.syncImportConflictDialog
.isVisible()
.catch(() => false);
if (syncImportDialogVisible) {
await this.syncImportUseRemoteBtn.click();
await this.syncImportConflictDialog.waitFor({
state: 'hidden',
timeout: 5000,
});
continue;
}
// Client A: mandatory encryption setup dialog
const enableEncVisible = await enableEncryptionDialog
.isVisible()
.catch(() => false);
if (enableEncVisible) {
console.log('[SuperSyncPage] Mandatory encryption dialog — setting password');
const topEncDlg = enableEncryptionDialog.last();
const pwInput = topEncDlg.locator('input[type="password"]').first();
const confirmPwInput = topEncDlg.locator('input[type="password"]').nth(1);
await pwInput.fill(defaultPassword);
await confirmPwInput.fill(defaultPassword);
const setPasswordBtn = topEncDlg.locator('button[mat-flat-button]');
await setPasswordBtn.click();
await topEncDlg.waitFor({
state: 'hidden',
timeout: 15000,
});
continue;
}
// Client B: enter-password dialog (server has encrypted data)
const enterPwVisible = await enterPasswordDialog.isVisible().catch(() => false);
if (enterPwVisible) {
console.log('[SuperSyncPage] Enter-password dialog — entering password');
const passwordInput = enterPasswordDialog.locator('input[type="password"]');
await passwordInput.fill(defaultPassword);
const saveAndSyncBtn = enterPasswordDialog.locator('button[mat-flat-button]');
await saveAndSyncBtn.click();
await enterPasswordDialog.waitFor({
state: 'hidden',
timeout: 30000,
});
continue;
}
// Client B: legacy password dialog (dialog-enter-encryption-password vs
// dialog-sync-initial-cfg password prompt)
const legacyPwVisible = await passwordDialog.isVisible().catch(() => false);
if (legacyPwVisible) {
console.log('[SuperSyncPage] Password dialog — entering password');
const passwordInput = passwordDialog.locator('input[type="password"]');
await passwordInput.fill(defaultPassword);
const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]');
await saveAndSyncBtn.click();
await passwordDialog.waitFor({
state: 'hidden',
timeout: 30000,
});
continue;
}
// No dialog currently visible — wait for one to appear or for dialogs to clear
const dialogCount = await this.page.locator('mat-dialog-container').count();
if (dialogCount === 0) {
break; // All dialogs closed
}
// Wait briefly for dialog state to settle, then re-check
await this.page.waitForTimeout(1000);
}
// Wait for all dialogs to close
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
timeout: 10000,
timeout: 15000,
});
// Wait for initial sync to complete
// Wait for sync to complete (either already done or triggered after dialog)
const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false);
if (!checkAlreadyVisible) {
const spinnerAppeared = await this.syncSpinner
.waitFor({ state: 'visible', timeout: 2000 })
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
@ -578,7 +731,7 @@ export class SuperSyncPage extends BasePage {
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
}
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 });
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 15000 });
}
} else if (needsEncryptionEnabled) {
// When waitForInitialSync is false but encryption is needed,

View file

@ -28,6 +28,9 @@ const generateTestRunId = (workerIndex: number): string => {
return `${Date.now()}-${workerIndex}`;
};
/** Default encryption password used by setupSuperSync's mandatory encryption dialog */
const ENCRYPTION_PASSWORD = 'e2e-default-encryption-pw';
base.describe('@importsync @supersync Import + Sync E2E', () => {
let serverHealthy: boolean | null = null;
@ -90,10 +93,10 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
await importPage.importBackupFile(backupPath);
console.log('[Import Test] Client A imported backup successfully');
// Reload page after import to ensure UI reflects the imported state
// (bulk state updates from BACKUP_IMPORT may not trigger component re-renders)
// Use goto instead of reload - more reliable with service workers
await clientA.page.goto(clientA.page.url(), {
// Navigate to tasks view after import to ensure UI reflects the imported state.
// Use explicit URL instead of page.url() because after import the page may be on
// the settings/import route, and the imported state may redirect to /#/config.
await clientA.page.goto('/#/tag/TODAY/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
@ -117,7 +120,13 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
// ============ PHASE 3: Client B Downloads via Sync ============
console.log('[Import Test] Phase 3: Client B syncing from server');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Client B must use explicit encryption config to enter the password dialog
// instead of the enable-encryption dialog (which would wipe server data).
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
});
await clientB.sync.syncAndWait();
console.log('[Import Test] Client B sync complete');
@ -267,9 +276,15 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
await clientA.sync.syncAndWait();
console.log(`[Merge Test] Client A created and synced: ${existingTaskA}`);
// Client B gets the task
// Client B gets the task.
// Must use explicit encryption config so Client B enters the password dialog
// instead of the enable-encryption dialog (which would wipe Client A's data).
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
});
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, existingTaskA);
@ -295,10 +310,10 @@ base.describe('@importsync @supersync Import + Sync E2E', () => {
await importPage.importBackupFile(backupPath);
console.log('[Merge Test] Client A imported backup');
// Reload page after import to ensure UI reflects the imported state
// (bulk state updates from BACKUP_IMPORT may not trigger component re-renders)
// Use goto instead of reload - more reliable with service workers
await clientA.page.goto(clientA.page.url(), {
// Navigate to tasks view after import.
// Use explicit URL instead of page.url() because after import the page may be on
// the settings/import route, and the imported state may redirect to /#/config.
await clientA.page.goto('/#/tag/TODAY/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});

View file

@ -11,6 +11,9 @@ import {
type TestUser,
} from '../../utils/supersync-helpers';
/** Default encryption password used by setupSuperSync's mandatory encryption dialog */
const ENCRYPTION_PASSWORD = 'e2e-default-encryption-pw';
/**
* Reset user's sync data on the server (keeps account active).
* This simulates what happens when clicking "Reset Account (Clear Data)" button.
@ -87,9 +90,15 @@ test.describe('@supersync SuperSync Account Reset', () => {
await waitForTask(clientA.page, task2);
console.log('[Reset] Client A synced initial tasks');
// 2. Create second client and verify it receives the tasks
// 2. Create second client and verify it receives the tasks.
// Must use explicit encryption config so Client B enters the password dialog
// instead of the enable-encryption dialog (which would wipe Client A's data).
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
});
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
@ -171,14 +180,20 @@ test.describe('@supersync SuperSync Account Reset', () => {
expect(taskExists).toBe(true);
console.log('[Resync] Client retained local data after reset');
// 5. Create another client to verify data was re-synced to server
// 5. Create another client to verify data was re-synced to server.
// Must use explicit encryption config so the verify client enters the password dialog
// instead of the enable-encryption dialog (which would wipe server data).
const clientVerify = await createSimulatedClient(
browser,
appUrl,
'Verify',
testRunId,
);
await clientVerify.sync.setupSuperSync(syncConfig);
await clientVerify.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: ENCRYPTION_PASSWORD,
});
await clientVerify.sync.syncAndWait();
// New client should receive the re-synced task

View file

@ -0,0 +1,162 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync Encryption Prompt Loop E2E Tests
*
* Scenarios covered:
* - E.6: Encryption prompt reappears after every unencrypted SuperSync sync
* - I.14: Cancelling encryption prompt disables sync entirely
*
* SuperSync requires encryption. After every successful unencrypted sync,
* the encryption dialog reappears with disableClose: true. The only options
* are to set a password or cancel (which disables sync).
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts
*/
test.describe('@supersync Encryption Prompt Loop', () => {
/**
* Scenario E.6: Encryption prompt reappears after every unencrypted SuperSync sync
*
* After a successful sync without encryption, the encryption dialog should
* appear with disableClose: true. This dialog appears after EVERY sync until
* encryption is enabled.
*
* We bypass the prompt initially by setting waitForInitialSync: false,
* then observe the dialog appearing after sync completes.
*/
test('Encryption prompt reappears after every unencrypted SuperSync sync', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(90000);
let clientA: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
// Set up SuperSync WITHOUT waiting for initial sync and WITHOUT encryption
// This lets us observe the encryption prompt behavior
await clientA.sync.setupSuperSync({
...syncConfig,
waitForInitialSync: false,
});
// Wait for sync to complete or for encryption dialog to appear
const encryptionDialog = clientA.page.locator('dialog-enable-encryption');
const syncCheck = clientA.sync.syncCheckIcon;
// The encryption prompt should appear after the initial sync
const outcome = await Promise.race([
encryptionDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'encryption_dialog' as const),
syncCheck
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'sync_complete' as const),
]);
if (outcome === 'sync_complete') {
// Sync completed first — encryption dialog should appear shortly after
await encryptionDialog.waitFor({ state: 'visible', timeout: 15000 });
}
// Verify the encryption dialog is visible
await expect(encryptionDialog).toBeVisible();
console.log('[E.6] Encryption dialog appeared after unencrypted sync');
// The dialog should have disableClose: true (can't click outside to dismiss)
// Verify by checking the backdrop doesn't close the dialog when clicked
// Instead, just verify it's visible and has the expected elements
const cancelBtn = encryptionDialog.locator('button:has-text("Cancel")');
const enableBtn = encryptionDialog.locator(
'button[mat-flat-button]:has-text("Enable")',
);
await expect(cancelBtn).toBeVisible();
await expect(enableBtn).toBeVisible();
console.log(
'[E.6] ✓ Encryption dialog with Cancel/Enable options appeared after sync',
);
} finally {
if (clientA) await closeClient(clientA);
}
});
/**
* Scenario I.14: Cancelling encryption prompt disables sync entirely
*
* When the encryption prompt appears and the user clicks Cancel,
* sync should be disabled (isEnabled: false) and no further syncs occur.
*/
test('Cancelling encryption prompt disables sync entirely', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(90000);
let clientA: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
// Set up SuperSync WITHOUT waiting for initial sync and WITHOUT encryption
await clientA.sync.setupSuperSync({
...syncConfig,
waitForInitialSync: false,
});
// Wait for the encryption dialog to appear
const encryptionDialog = clientA.page.locator('dialog-enable-encryption');
// Wait for sync to happen first, then dialog should appear
const outcome = await Promise.race([
encryptionDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'encryption_dialog' as const),
clientA.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'sync_complete' as const),
]);
if (outcome === 'sync_complete') {
await encryptionDialog.waitFor({ state: 'visible', timeout: 15000 });
}
await expect(encryptionDialog).toBeVisible();
console.log('[I.14] Encryption dialog appeared');
// Click Cancel to disable sync
const cancelBtn = encryptionDialog.locator('button:has-text("Cancel")');
await cancelBtn.click();
// Wait for dialog to close
await encryptionDialog.waitFor({ state: 'hidden', timeout: 10000 });
console.log('[I.14] Cancel clicked, dialog closed');
// Verify sync is disabled — the sync button should not show
// the check/spinner/error state anymore (sync is off)
await expect
.poll(() => clientA.sync.isSyncEnabled(), { timeout: 10000 })
.toBe(false);
console.log('[I.14] ✓ Sync disabled after cancelling encryption prompt');
} finally {
if (clientA) await closeClient(clientA);
}
});
});

View file

@ -0,0 +1,481 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync Error Scenarios E2E Tests
*
* Tests error handling paths using Playwright route interception to simulate
* server responses that are difficult to trigger naturally.
*
* Scenarios covered:
* - B.3: Validation error permanently rejects op
* - B.4: Payload too large shows alert dialog
* - G.5: Duplicate operation silently marked as synced
* - G.7: Schema version mismatch returns handled error
* - G.8: Failed operation migration skips op
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-error-scenarios.spec.ts
*/
test.describe('@supersync Error Scenarios', () => {
/**
* Scenario B.3: Validation error permanently rejects op and shows error status
*
* When the server rejects an op with VALIDATION_ERROR, the op should be
* marked as permanently rejected (not retried) and sync status should show ERROR.
*/
test('Validation error permanently rejects op and shows error status', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(90000);
let clientA: SimulatedE2EClient | null = null;
const state = { interceptUpload: true };
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// Create a task (generates pending ops)
const taskName = `ValidationErr-${testRunId}`;
await clientA.workView.addTask(taskName);
await waitForTask(clientA.page, taskName);
// Intercept the upload to return a VALIDATION_ERROR rejection
await clientA.page.route('**/api/sync/ops', async (route) => {
if (state.interceptUpload && route.request().method() === 'POST') {
state.interceptUpload = false;
console.log('[Test] Simulating VALIDATION_ERROR rejection');
// Get the request body to extract op IDs
const body = route.request().postDataJSON();
const ops = body?.ops || [];
const rejectedOps = ops.map((op: { id: string }) => ({
opId: op.id,
error: 'Invalid entity structure',
errorCode: 'VALIDATION_ERROR',
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
piggybacked: [],
rejectedOps,
latestSeq: 1,
}),
});
} else {
await route.continue();
}
});
// Trigger sync — the upload should get VALIDATION_ERROR
try {
await clientA.sync.triggerSync();
await clientA.page.waitForTimeout(3000);
} catch {
// Expected — triggerSync may throw on error state
}
// Remove interception
await clientA.page.unroute('**/api/sync/ops');
// Verify sync shows error status (permanentRejectionCount > 0 → ERROR)
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(true);
// Sync again — the rejected op should NOT be retried
// (it should sync successfully since the rejected op is skipped)
await clientA.sync.syncAndWait();
console.log(
'[ValidationError] Validation error correctly caused error status and op was not retried',
);
} finally {
if (clientA) {
await clientA.page.unroute('**/api/sync/ops').catch(() => {});
await closeClient(clientA);
}
}
});
/**
* Scenario B.4: Payload too large shows alert dialog
*
* When the server returns 413, an alert dialog should appear.
*/
test('Payload too large shows alert dialog', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(60000);
let clientA: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// Set up dialog handler to capture alert
let alertShown = false;
let alertMessage = '';
clientA.page.on('dialog', async (dialog) => {
if (dialog.type() === 'alert') {
alertShown = true;
alertMessage = dialog.message();
console.log(`[Test] Alert dialog: ${alertMessage}`);
await dialog.accept();
}
});
// Wait for initial sync to complete
await clientA.sync.syncAndWait();
// Intercept upload to return 413
await clientA.page.route('**/api/sync/ops', async (route) => {
if (route.request().method() === 'POST') {
console.log('[Test] Simulating 413 Payload Too Large');
await route.fulfill({
status: 413,
contentType: 'application/json',
body: JSON.stringify({
error: 'Payload too large',
code: 'PAYLOAD_TOO_LARGE',
}),
});
} else {
await route.continue();
}
});
// Create task and trigger sync
const taskName = `PayloadTooLarge-${testRunId}`;
await clientA.workView.addTask(taskName);
await waitForTask(clientA.page, taskName);
try {
await clientA.sync.triggerSync();
// Poll for alert
const alertTimeout = 5000;
const pollInterval = 200;
let elapsed = 0;
while (!alertShown && elapsed < alertTimeout) {
await clientA.page.waitForTimeout(pollInterval);
elapsed += pollInterval;
}
} catch {
console.log('[Test] Sync failed with 413 as expected');
}
// Verify alert was shown with appropriate message
expect(alertShown).toBe(true);
expect(alertMessage.length).toBeGreaterThan(0);
// Task should still exist locally (not lost)
await waitForTask(clientA.page, taskName);
console.log('[PayloadTooLarge] Alert dialog shown for 413 response');
} finally {
if (clientA) {
await clientA.page.unroute('**/api/sync/ops').catch(() => {});
await closeClient(clientA);
}
}
});
/**
* Scenario G.5: Duplicate operation is silently marked as synced
*
* When the server rejects an op as DUPLICATE_OPERATION, the client should
* mark it as synced (not show an error). This handles the case where the
* client successfully uploaded but didn't receive the acknowledgment.
*/
test('Duplicate operation is silently marked as synced', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(90000);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
const state = { returnDuplicate: false };
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// Create task and sync it successfully first
const taskName = `Duplicate-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Create another task (generates new pending ops)
const taskName2 = `Duplicate2-${testRunId}`;
await clientA.workView.addTask(taskName2);
await waitForTask(clientA.page, taskName2);
// Intercept the next upload to return DUPLICATE_OPERATION
state.returnDuplicate = true;
await clientA.page.route('**/api/sync/ops', async (route) => {
if (state.returnDuplicate && route.request().method() === 'POST') {
state.returnDuplicate = false;
console.log('[Test] Simulating DUPLICATE_OPERATION rejection');
const body = route.request().postDataJSON();
const ops = body?.ops || [];
const rejectedOps = ops.map((op: { id: string }) => ({
opId: op.id,
error: 'Duplicate operation',
errorCode: 'DUPLICATE_OPERATION',
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
piggybacked: [],
rejectedOps,
latestSeq: 2,
}),
});
} else {
await route.continue();
}
});
// Trigger sync — duplicate rejection should be handled silently
try {
await clientA.sync.triggerSync();
await clientA.page.waitForTimeout(2000);
} catch {
// May or may not throw
}
// Remove interception
await clientA.page.unroute('**/api/sync/ops');
// Verify no error shown — duplicate should be handled silently
// After removing the route, the next sync should succeed
await clientA.sync.syncAndWait();
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
// Verify tasks still exist
await waitForTask(clientA.page, taskName);
// Verify with Client B that the task made it to the server
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskName);
console.log('[DuplicateOp] Duplicate operation handled silently without error');
} finally {
if (clientA) {
await clientA.page.unroute('**/api/sync/ops').catch(() => {});
await closeClient(clientA);
}
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario G.7: Schema version mismatch returns handled error
*
* When downloaded ops have a modelVersion higher than the client's,
* the client should log a warning and return HANDLED_ERROR without crashing.
*/
test('Schema version mismatch returns handled error without crash', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(90000);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
const state = { injectFutureSchemaOps: true };
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Client A creates real data
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `Schema-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Client B will receive ops with future schema version
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Intercept download to inject ops with a very high schema version
await clientB.page.route('**/api/sync/ops/**', async (route) => {
if (route.request().method() === 'GET' && state.injectFutureSchemaOps) {
state.injectFutureSchemaOps = false;
console.log('[Test] Injecting ops with future schema version');
// Get real response and modify it
const response = await route.fetch();
const json = await response.json();
// Modify all ops to have a very high schema version
if (json.ops) {
for (const op of json.ops) {
op.schemaVersion = 99999;
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(json),
});
} else {
await route.continue();
}
});
// Client B syncs — should handle the schema mismatch gracefully
try {
await clientB.sync.triggerSync();
await clientB.page.waitForTimeout(3000);
} catch {
// May or may not throw depending on error handling
}
// Remove interception and retry with real data
await clientB.page.unroute('**/api/sync/ops/**');
await clientB.sync.syncAndWait();
// Verify Client B didn't crash and can still sync
await waitForTask(clientB.page, taskName);
const hasError = await clientB.sync.hasSyncError();
expect(hasError).toBe(false);
console.log(
'[SchemaVersionMismatch] Client handled future schema version without crash',
);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) {
await clientB.page.unroute('**/api/sync/ops/**').catch(() => {});
await closeClient(clientB);
}
}
});
/**
* Scenario G.8: Failed operation migration skips op and other ops still apply
*
* When a downloaded op has a corrupted/unmigrateable structure,
* it should be skipped and other valid ops should still be applied.
*/
test('Failed operation migration skips corrupted op, applies others', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(90000);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
const state = { injectCorruptedOp: true };
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Client A creates real data
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `MigrationFail-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Client B will receive ops including one corrupted one
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Intercept download to inject a corrupted op alongside valid ones
await clientB.page.route('**/api/sync/ops/**', async (route) => {
if (route.request().method() === 'GET' && state.injectCorruptedOp) {
state.injectCorruptedOp = false;
console.log('[Test] Injecting corrupted op into download response');
const response = await route.fetch();
const json = await response.json();
// Insert a corrupted op before the valid ones
if (json.ops && json.ops.length > 0) {
const corruptedOp = {
id: 'corrupted-migration-op',
opType: 'UPD',
entityType: 'TASK',
entityId: 'nonexistent-entity',
actionType: '[Task] CORRUPTED_ACTION',
payload: { title: undefined, __broken: true },
vectorClock: { broken_client: 1 },
timestamp: Date.now(),
schemaVersion: 0, // Very old schema, likely to fail migration
clientId: 'broken-client',
};
json.ops.unshift(corruptedOp);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(json),
});
} else {
await route.continue();
}
});
// Client B syncs — corrupted op should be skipped, valid ops applied
await clientB.sync.syncAndWait();
// Remove interception
await clientB.page.unroute('**/api/sync/ops/**');
// Verify the valid task was still received despite the corrupted op
await waitForTask(clientB.page, taskName);
// Verify Client B is healthy and can sync again
await clientB.sync.syncAndWait();
const hasError = await clientB.sync.hasSyncError();
expect(hasError).toBe(false);
console.log(
'[MigrationFailure] Corrupted op skipped, valid ops applied successfully',
);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) {
await clientB.page.unroute('**/api/sync/ops/**').catch(() => {});
await closeClient(clientB);
}
}
});
});

View file

@ -550,4 +550,137 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario D.4: Remote ops concurrent with accepted remote import are silently filtered
*
* When Client B imports a backup and Client A accepts it (USE_REMOTE),
* any remaining old ops from before the import should be silently filtered
* on subsequent syncs no dialog, no errors.
*
* Actions:
* 1. Client A creates tasks, syncs
* 2. Client B imports backup, syncs (creates SYNC_IMPORT)
* 3. Client A syncs, gets SYNC_IMPORT, accepts remote import (USE_REMOTE)
* 4. Client A syncs again old ops should be silently filtered
*
* Verify:
* - No dialog on the second sync after accepting import
* - Sync completes successfully (IN_SYNC)
* - Only imported data remains
*/
test('Remote ops concurrent with accepted remote import are silently filtered (D.4)', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Client A creates tasks ============
console.log('[D.4] Phase 1: Client A creates tasks');
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskAOld = `Task-A-Old-${uniqueId}`;
await clientA.workView.addTask(taskAOld);
await clientA.sync.syncAndWait();
console.log(`[D.4] Client A created and synced: ${taskAOld}`);
// ============ PHASE 2: Client B imports backup ============
console.log('[D.4] Phase 2: Client B imports backup');
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Perform import on Client B
const importPage = new ImportPage(clientB.page);
await importPage.navigateToImportPage();
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
console.log('[D.4] Client B imported backup');
// Close and re-open Client B to pick up imported data
await clientB.page.close();
clientB.page = await clientB.context.newPage();
await clientB.page.goto('/');
clientB.workView = new WorkViewPage(clientB.page, `B-${testRunId}`);
clientB.sync = new SuperSyncPage(clientB.page);
await waitForAppReady(clientB.page, { ensureRoute: false });
// Configure sync WITHOUT waiting for initial sync
await clientB.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for sync import conflict dialog or sync completion
const syncImportDialog = clientB.sync.syncImportConflictDialog;
const syncResult = await Promise.race([
syncImportDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientB.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult === 'dialog') {
await clientB.sync.syncImportUseLocalBtn.click();
await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 });
await clientB.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Client B syncs to upload SYNC_IMPORT
await clientB.sync.syncAndWait();
console.log('[D.4] Client B synced (SYNC_IMPORT uploaded)');
// ============ PHASE 3: Client A syncs and accepts remote import ============
console.log('[D.4] Phase 3: Client A syncs and accepts remote import');
// Client A syncs — should get SYNC_IMPORT, may show conflict dialog
// Use syncAndWait which handles dialogs automatically (uses remote by default)
await clientA.sync.syncAndWait();
console.log('[D.4] Client A accepted remote import');
// Wait for state to settle
await clientA.page.waitForTimeout(1000);
// ============ PHASE 4: Client A syncs AGAIN — old ops should be silently filtered ============
console.log(
'[D.4] Phase 4: Client A syncs again (old ops should be silently filtered)',
);
// This is the CRITICAL sync — any remaining old ops from before the import
// should be silently filtered by SyncImportFilterService with no dialog
await clientA.sync.syncAndWait();
// Verify no error
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
// Verify sync is IN_SYNC
await expect(clientA.sync.syncCheckIcon).toBeVisible({ timeout: 5000 });
// Navigate to work view
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
// Verify imported task is present
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
// Verify old task is gone (clean slate)
const oldTaskOnA = clientA.page.locator(`task:has-text("${taskAOld}")`);
await expect(oldTaskOnA).not.toBeVisible({ timeout: 5000 });
console.log('[D.4] ✓ Old ops silently filtered after accepting remote import');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});

View file

@ -0,0 +1,81 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
getTaskCount,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync No-Op Sync E2E Tests
*
* Scenario A.3: No changes on either side.
* When sync fires but there are no new ops anywhere, the client should
* update lastServerSeq and remain in IN_SYNC status without errors.
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-no-op-sync.spec.ts
*/
test.describe('@supersync No-Op Sync', () => {
/**
* Scenario A.3: No-op sync round-trip updates seq without errors
*
* Actions:
* 1. Client A sets up SuperSync
* 2. Client A creates a task and syncs (initial data)
* 3. Client A syncs again with no changes on either side
*
* Verify:
* - No error after the no-op sync
* - Sync status is IN_SYNC (check icon visible)
* - Task count is unchanged
*/
test('No-op sync round-trip updates seq without errors', async ({
browser,
baseURL,
testRunId,
}) => {
let clientA: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// Create a task so there's some data on the server
const taskName = `NoOp-Task-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Record task count before no-op sync
const countBefore = await getTaskCount(clientA);
// No-op sync: no changes on either side
await clientA.sync.syncAndWait();
// Verify no error
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
// Verify sync shows success (check icon)
await expect(clientA.sync.syncCheckIcon).toBeVisible({ timeout: 5000 });
// Verify task count unchanged
const countAfter = await getTaskCount(clientA);
expect(countAfter).toBe(countBefore);
// Do one more no-op sync to be thorough
await clientA.sync.syncAndWait();
const hasErrorAfterSecond = await clientA.sync.hasSyncError();
expect(hasErrorAfterSecond).toBe(false);
console.log('[NoOpSync] No-op sync completed without errors');
} finally {
if (clientA) await closeClient(clientA);
}
});
});

View file

@ -0,0 +1,187 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import {
WEBDAV_CONFIG_TEMPLATE,
createSyncFolder,
generateSyncFolderName,
setupSyncClient,
waitForSyncComplete,
closeContextsSafely,
} from '../../utils/sync-helpers';
import { SyncPage } from '../../pages/sync.page';
import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForStatePersistence } from '../../utils/waits';
/**
* SuperSync Provider Switch (WebDAV SuperSync) E2E Tests
*
* Scenario I.7: Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT
*
* When a client that has been syncing to WebDAV switches to SuperSync,
* the server migration logic detects the empty SuperSync server and the
* client's existing synced ops, creating a SYNC_IMPORT to migrate data.
* Another client joining the SuperSync account should receive all tasks.
*
* Prerequisites:
* - super-sync-server running on localhost:1901 with TEST_MODE=true
* - WebDAV server running on localhost:2345
* - Frontend running on localhost:4242
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts
*/
/**
* Check if the WebDAV server is available.
*/
const isWebDAVAvailable = async (): Promise<boolean> => {
try {
const response = await fetch('http://localhost:2345', {
method: 'OPTIONS',
signal: AbortSignal.timeout(3000),
});
return response.ok || response.status === 200 || response.status === 207;
} catch {
return false;
}
};
test.describe('@supersync Provider Switch WebDAV to SuperSync', () => {
/**
* Scenario I.7: Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT
*
* Actions:
* 1. Client A sets up WebDAV sync, creates tasks, syncs to WebDAV
* 2. Client A switches to SuperSync (new empty server) on the same page
* 3. Server migration detects empty server + client has synced ops
*
* Verify:
* - Tasks are preserved after provider switch
* - Client B joining SuperSync receives all tasks
*/
test('Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
test.setTimeout(180000);
// Skip if WebDAV server is not available
const webdavAvailable = await isWebDAVAvailable();
testInfo.skip(!webdavAvailable, 'WebDAV server not running on localhost:2345');
const appUrl = baseURL || 'http://localhost:4242';
let contextA: Awaited<ReturnType<typeof browser.newContext>> | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Create WebDAV sync folder
const syncFolderName = generateSyncFolderName(`e2e-to-supersync-${testRunId}`);
const webdavRequestContext = await browser.newContext();
try {
await createSyncFolder(webdavRequestContext.request, syncFolderName);
} finally {
await webdavRequestContext.close();
}
const webdavConfig = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${syncFolderName}`,
};
// ============ PHASE 1: Set up WebDAV and create tasks ============
console.log('[I.7] Phase 1: Set up WebDAV sync and create tasks');
// Use setupSyncClient which auto-accepts confirm dialogs
const { context, page: pageA } = await setupSyncClient(browser, appUrl);
contextA = context;
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(webdavConfig);
await expect(syncPageA.syncBtn).toBeVisible();
console.log('[I.7] Client A: WebDAV sync configured');
// Create tasks
const task1 = `WebDAV-To-SS-1-${testRunId}`;
const task2 = `WebDAV-To-SS-2-${testRunId}`;
await workViewPageA.addTask(task1);
await workViewPageA.addTask(task2);
await expect(pageA.locator('task')).toHaveCount(2);
console.log(`[I.7] Client A: Created tasks: ${task1}, ${task2}`);
// Sync to WebDAV
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[I.7] Client A: Synced to WebDAV');
// ============ PHASE 2: Switch to SuperSync on the same page ============
console.log('[I.7] Phase 2: Switching to SuperSync (same browser context)');
// Use SuperSyncPage on the SAME page to switch providers
// setupSuperSync right-clicks the sync button to open settings,
// which works even when another provider is already configured
const superSyncPageA = new SuperSyncPage(pageA);
await superSyncPageA.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: 'test-password-123',
});
console.log('[I.7] Client A: Switched to SuperSync with encryption');
// Sync to upload all data to SuperSync
await superSyncPageA.syncAndWait();
console.log('[I.7] Client A: Synced to SuperSync');
// Verify tasks still exist after the switch
await expect(pageA.locator('task', { hasText: task1 })).toBeVisible({
timeout: 10000,
});
await expect(pageA.locator('task', { hasText: task2 })).toBeVisible({
timeout: 10000,
});
console.log('[I.7] Client A: Tasks preserved after switch to SuperSync');
// ============ PHASE 3: Client B joins SuperSync ============
console.log('[I.7] Phase 3: Client B joins SuperSync');
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: 'test-password-123',
});
await clientB.sync.syncAndWait();
// Verify Client B received the tasks
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
console.log('[I.7] Client B: Received tasks from SuperSync');
// No errors on either client
const hasErrorA = await superSyncPageA.hasSyncError();
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorA).toBe(false);
expect(hasErrorB).toBe(false);
console.log(
'[I.7] ✓ WebDAV → SuperSync provider switch migrated data successfully',
);
} finally {
if (contextA) await closeContextsSafely(contextA);
if (clientB) await closeClient(clientB);
}
});
});

View file

@ -0,0 +1,185 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { expectTaskVisible } from '../../utils/supersync-assertions';
/**
* SuperSync Re-enable and Account Switch E2E Tests
*
* Scenarios covered:
* - I.5: Re-enabling sync after disable resumes from stored lastServerSeq
* - I.6: Switching SuperSync accounts syncs to new server
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts
*/
test.describe('@supersync Re-enable and Account Switch', () => {
/**
* Scenario I.5: Re-enabling sync after disable resumes from stored lastServerSeq
*
* Actions:
* 1. Client A sets up SuperSync, creates tasks, syncs
* 2. Client A disables sync
* 3. Client A creates more tasks while offline
* 4. Client A re-enables SuperSync with same config
*
* Verify:
* - New tasks upload successfully
* - Existing tasks preserved
* - Status is IN_SYNC
*/
test('Re-enabling sync after disable resumes from stored lastServerSeq', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(120000);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Setup and initial sync ============
console.log('[I.5] Phase 1: Setup and initial sync');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskBefore = `ReEnable-Before-${testRunId}`;
await clientA.workView.addTask(taskBefore);
await clientA.sync.syncAndWait();
console.log(`[I.5] Created and synced: ${taskBefore}`);
// ============ PHASE 2: Disable sync ============
console.log('[I.5] Phase 2: Disabling sync');
await clientA.sync.disableSync();
console.log('[I.5] Sync disabled');
// ============ PHASE 3: Create tasks while "offline" ============
console.log('[I.5] Phase 3: Creating tasks while sync is disabled');
const taskOffline = `ReEnable-Offline-${testRunId}`;
await clientA.workView.addTask(taskOffline);
await waitForTask(clientA.page, taskOffline);
console.log(`[I.5] Created while disabled: ${taskOffline}`);
// ============ PHASE 4: Re-enable sync ============
console.log('[I.5] Phase 4: Re-enabling sync');
await clientA.sync.setupSuperSync(syncConfig);
// Sync to upload offline tasks
await clientA.sync.syncAndWait();
console.log('[I.5] Re-enabled and synced');
// ============ PHASE 5: Verify ============
console.log('[I.5] Phase 5: Verifying');
// Both tasks should still exist
await expectTaskVisible(clientA, taskBefore);
await expectTaskVisible(clientA, taskOffline);
// No error
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
// Verify with Client B that offline tasks uploaded
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskBefore);
await waitForTask(clientB.page, taskOffline);
console.log('[I.5] ✓ Re-enabling sync resumed correctly');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario I.6: Switching SuperSync accounts syncs to new server
*
* Actions:
* 1. Client A sets up SuperSync with user1, creates tasks, syncs
* 2. Client A switches to user2 (different token/account)
* 3. Client A syncs to new account server migration creates SYNC_IMPORT
*
* Verify:
* - Tasks are available on the new account (via SYNC_IMPORT)
* - Client B joining new account receives the tasks
*/
test('Switching SuperSync accounts syncs to new server', async ({
browser,
baseURL,
testRunId,
}) => {
test.setTimeout(120000);
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
// Create two separate user accounts
const user1 = await createTestUser(`${testRunId}-user1`);
const user2 = await createTestUser(`${testRunId}-user2`);
const syncConfig1 = getSuperSyncConfig(user1);
const syncConfig2 = getSuperSyncConfig(user2);
// ============ PHASE 1: Setup with user1 ============
console.log('[I.6] Phase 1: Setup with user1');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig1);
const taskName = `AcctSwitch-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
console.log(`[I.6] Created and synced to user1: ${taskName}`);
// ============ PHASE 2: Switch to user2 ============
console.log('[I.6] Phase 2: Switching to user2');
// Reconfigure sync with user2 credentials
await clientA.sync.setupSuperSync(syncConfig2);
console.log('[I.6] Reconfigured with user2');
// Sync to new account — server migration should create SYNC_IMPORT
// because the new server is empty but client has synced ops
await clientA.sync.syncAndWait();
console.log('[I.6] Synced to user2');
// ============ PHASE 3: Verify task is on new account ============
console.log('[I.6] Phase 3: Verifying task on new account');
// Task should still be visible on Client A
await expectTaskVisible(clientA, taskName);
// No error
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
// Verify with Client B joining user2's account
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig2);
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskName);
console.log('[I.6] ✓ Client B on user2 received the task');
console.log('[I.6] ✓ Account switch completed successfully');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});

View file

@ -0,0 +1,115 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import {
expectTaskOnAllClients,
expectEqualTaskCount,
} from '../../utils/supersync-assertions';
/**
* SuperSync Server Migration Abort E2E Tests
*
* Scenario F.2: Migration aborted when server is no longer empty.
*
* When a client that has synced ops from a previous provider connects to a
* SuperSync server that already has data (from another client), the server
* migration check should find the server non-empty and skip the SYNC_IMPORT.
* Instead, the client should do a normal sync.
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-server-migration-abort.spec.ts
*/
test.describe('@supersync Server Migration Abort', () => {
/**
* Scenario F.2: Migration aborts when server is no longer empty
*
* Actions:
* 1. Client A sets up SuperSync, creates tasks, syncs (populates server)
* 2. Client B sets up SuperSync with the same account
* 3. Client B syncs server is NOT empty, so no migration/SYNC_IMPORT
* 4. Client B does normal sync instead (downloads A's ops)
*
* Verify:
* - Client B receives Client A's tasks via normal sync (no SYNC_IMPORT)
* - Both clients converge to the same state
* - No error on either client
*/
test('Migration aborts when server is no longer empty', async ({
browser,
baseURL,
testRunId,
}) => {
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Client A populates the server ============
console.log('[F.2] Phase 1: Client A populates the server');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskA1 = `Migration-A1-${testRunId}`;
const taskA2 = `Migration-A2-${testRunId}`;
await clientA.workView.addTask(taskA1);
await clientA.workView.addTask(taskA2);
await clientA.sync.syncAndWait();
console.log(`[F.2] Client A created and synced: ${taskA1}, ${taskA2}`);
// ============ PHASE 2: Client B joins (server already has data) ============
console.log('[F.2] Phase 2: Client B joins (server already has data)');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Client B syncs — server is NOT empty (A already populated it)
// Migration should be aborted, normal sync occurs instead
await clientB.sync.syncAndWait();
console.log('[F.2] Client B synced (migration should have been aborted)');
// ============ PHASE 3: Verify convergence ============
console.log('[F.2] Phase 3: Verifying both clients converge');
// Client B should have received A's tasks via normal sync
await waitForTask(clientB.page, taskA1);
await waitForTask(clientB.page, taskA2);
// Verify both clients have the same tasks
await expectTaskOnAllClients([clientA, clientB], taskA1);
await expectTaskOnAllClients([clientA, clientB], taskA2);
await expectEqualTaskCount([clientA, clientB]);
// Verify no errors on either client
const hasErrorA = await clientA.sync.hasSyncError();
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorA).toBe(false);
expect(hasErrorB).toBe(false);
// ============ PHASE 4: Verify bidirectional sync works ============
console.log('[F.2] Phase 4: Verifying bidirectional sync');
// Client B creates a task
const taskB1 = `Migration-B1-${testRunId}`;
await clientB.workView.addTask(taskB1);
await clientB.sync.syncAndWait();
// Client A syncs and receives it
await clientA.sync.syncAndWait();
await waitForTask(clientA.page, taskB1);
console.log('[F.2] ✓ Migration aborted, normal sync worked correctly');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});

View file

@ -8,8 +8,9 @@ import {
} from './dialog-change-encryption-password.component';
import { EncryptionPasswordChangeService } from '../encryption-password-change.service';
import { SnackService } from '../../../core/snack/snack.service';
import { EncryptionDisableService } from '../encryption-disable.service';
import { SuperSyncEncryptionToggleService } from '../supersync-encryption-toggle.service';
import { FileBasedEncryptionService } from '../file-based-encryption.service';
import { SyncWrapperService } from '../sync-wrapper.service';
describe('DialogChangeEncryptionPasswordComponent', () => {
let component: DialogChangeEncryptionPasswordComponent;
@ -20,7 +21,8 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
let mockEncryptionPasswordChangeService: jasmine.SpyObj<EncryptionPasswordChangeService>;
let mockFileBasedEncryptionService: jasmine.SpyObj<FileBasedEncryptionService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockEncryptionDisableService: jasmine.SpyObj<EncryptionDisableService>;
let mockEncryptionToggleService: jasmine.SpyObj<SuperSyncEncryptionToggleService>;
let mockSyncWrapperService: jasmine.SpyObj<SyncWrapperService>;
beforeEach(async () => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
@ -30,12 +32,19 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
);
mockFileBasedEncryptionService = jasmine.createSpyObj('FileBasedEncryptionService', [
'changePassword',
'disableEncryption',
]);
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockEncryptionDisableService = jasmine.createSpyObj('EncryptionDisableService', [
'disableEncryption',
'disableEncryptionForFileBased',
mockEncryptionToggleService = jasmine.createSpyObj(
'SuperSyncEncryptionToggleService',
['disableEncryption'],
);
mockSyncWrapperService = jasmine.createSpyObj('SyncWrapperService', [
'runWithSyncBlocked',
]);
mockSyncWrapperService.runWithSyncBlocked.and.callFake(
async <T>(operation: () => Promise<T>): Promise<T> => operation(),
);
await TestBed.configureTestingModule({
imports: [
@ -54,7 +63,14 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
useValue: mockFileBasedEncryptionService,
},
{ provide: SnackService, useValue: mockSnackService },
{ provide: EncryptionDisableService, useValue: mockEncryptionDisableService },
{
provide: SuperSyncEncryptionToggleService,
useValue: mockEncryptionToggleService,
},
{
provide: SyncWrapperService,
useValue: mockSyncWrapperService,
},
],
}).compileComponents();
@ -170,7 +186,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
msg: 'Failed to change password: Network error',
translateParams: { message: 'Network error' },
}),
);
expect(component.isLoading()).toBe(false);
@ -189,7 +205,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
msg: 'Failed to change password: Unknown error',
translateParams: { message: 'Unknown error' },
}),
);
});

View file

@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password.component';
import { SyncConfigService } from '../sync-config.service';
import { EncryptionPasswordChangeService } from '../encryption-password-change.service';
@ -73,4 +74,91 @@ describe('DialogEnterEncryptionPasswordComponent', () => {
expect(mockSnackService.open).toHaveBeenCalled();
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
describe('forceOverwrite', () => {
it('should return early when passwordVal is empty', async () => {
component.passwordVal = '';
await component.forceOverwrite();
expect(mockMatDialog.open).not.toHaveBeenCalled();
});
it('should return early when isLoading is true', async () => {
component.passwordVal = 'password123';
component.isLoading.set(true);
await component.forceOverwrite();
expect(mockMatDialog.open).not.toHaveBeenCalled();
});
it('should return early when user cancels the confirm dialog', async () => {
component.passwordVal = 'password123';
mockMatDialog.open.and.returnValue({
afterClosed: () => of(false),
} as any);
await component.forceOverwrite();
expect(mockMatDialog.open).toHaveBeenCalled();
expect(mockEncryptionPasswordChangeService.changePassword).not.toHaveBeenCalled();
});
it('should call changePassword and close with forceOverwrite on confirm', async () => {
component.passwordVal = 'password123';
mockMatDialog.open.and.returnValue({
afterClosed: () => of(true),
} as any);
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
Promise.resolve(),
);
await component.forceOverwrite();
expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith(
'password123',
{ allowUnsyncedOps: true },
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ forceOverwrite: true });
expect(component.isLoading()).toBe(false);
});
it('should show error snackbar and reset loading on changePassword failure', async () => {
component.passwordVal = 'password123';
mockMatDialog.open.and.returnValue({
afterClosed: () => of(true),
} as any);
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
Promise.reject(new Error('server error')),
);
await component.forceOverwrite();
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'ERROR',
translateParams: { message: 'server error' },
}),
);
expect(component.isLoading()).toBe(false);
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
});
describe('cancel', () => {
it('should close with empty object', () => {
component.cancel();
expect(mockDialogRef.close).toHaveBeenCalledWith({});
});
it('should not close when loading', () => {
component.isLoading.set(true);
component.cancel();
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
});
});

View file

@ -1,305 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { EncryptionDisableService } from './encryption-disable.service';
import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import {
OperationSyncCapable,
SyncProviderServiceInterface,
} from '../../op-log/sync-providers/provider.interface';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { StateSnapshotService } from '../../op-log/backup/state-snapshot.service';
import { VectorClockService } from '../../op-log/sync/vector-clock.service';
import { CLIENT_ID_PROVIDER } from '../../op-log/util/client-id.provider';
import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-based/file-based-sync-adapter.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
describe('EncryptionDisableService', () => {
let service: EncryptionDisableService;
let mockSnapshotUploadService: jasmine.SpyObj<SnapshotUploadService>;
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
let mockFileBasedAdapter: jasmine.SpyObj<FileBasedSyncAdapterService>;
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
let mockSyncProvider: jasmine.SpyObj<
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
>;
const mockExistingCfg: SuperSyncPrivateCfg = {
baseUrl: 'https://test.example.com',
accessToken: 'test-token',
isEncryptionEnabled: true,
encryptKey: 'existing-key',
};
beforeEach(() => {
mockSyncProvider = jasmine.createSpyObj('SyncProvider', [
'deleteAllData',
'setPrivateCfg',
]);
mockSyncProvider.id = SyncProviderId.SuperSync;
mockSyncProvider.deleteAllData.and.resolveTo({ success: true });
mockSyncProvider.setPrivateCfg.and.resolveTo();
mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [
'gatherSnapshotData',
'uploadSnapshot',
'updateLastServerSeq',
]);
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
syncProvider: mockSyncProvider,
existingCfg: mockExistingCfg,
state: { task: [] },
vectorClock: { testClient1: 1 },
clientId: 'testClient1',
} as unknown as SnapshotUploadData);
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: true,
serverSeq: 1,
});
mockSnapshotUploadService.updateLastServerSeq.and.resolveTo();
mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [
'clearCache',
]);
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'getActiveProvider',
'getEncryptAndCompressCfg',
]);
mockProviderManager.getActiveProvider.and.returnValue(null);
mockProviderManager.getEncryptAndCompressCfg.and.returnValue({
isEncrypt: false,
isCompress: true,
});
mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [
'getStateSnapshotAsync',
]);
mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo({ task: [] } as any);
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
'getCurrentVectorClock',
]);
mockVectorClockService.getCurrentVectorClock.and.resolveTo({ testClient1: 1 });
mockFileBasedAdapter = jasmine.createSpyObj('FileBasedSyncAdapterService', [
'createAdapter',
]);
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [
'updateSection',
]);
TestBed.configureTestingModule({
providers: [
EncryptionDisableService,
{ provide: SnapshotUploadService, useValue: mockSnapshotUploadService },
{ provide: WrappedProviderService, useValue: mockWrappedProviderService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: StateSnapshotService, useValue: mockStateSnapshotService },
{ provide: VectorClockService, useValue: mockVectorClockService },
{
provide: CLIENT_ID_PROVIDER,
useValue: { loadClientId: () => Promise.resolve('testClient1') },
},
{ provide: FileBasedSyncAdapterService, useValue: mockFileBasedAdapter },
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
],
});
service = TestBed.inject(EncryptionDisableService);
});
describe('disableEncryption', () => {
it('should delete server data before disabling encryption', async () => {
await service.disableEncryption();
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
mockSnapshotUploadService.uploadSnapshot,
);
});
it('should upload unencrypted snapshot with isPayloadEncrypted=false', async () => {
await service.disableEncryption();
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith(
mockSyncProvider as any,
{ task: [] }, // Raw state, not encrypted
'testClient1',
{ testClient1: 1 },
false, // isPayloadEncrypted
);
});
it('should update config with encryption disabled AFTER successful upload', async () => {
await service.disableEncryption();
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledBefore(
mockSyncProvider.setPrivateCfg,
);
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
jasmine.objectContaining({
encryptKey: undefined,
isEncryptionEnabled: false,
}),
);
});
it('should update lastServerSeq after successful upload', async () => {
await service.disableEncryption();
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
mockSyncProvider as any,
1,
'EncryptionDisableService',
);
});
it('should NOT update config on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.disableEncryption()).toBeRejected();
// Config should NOT be updated when upload fails
expect(mockSyncProvider.setPrivateCfg).not.toHaveBeenCalled();
});
it('should throw with CRITICAL message on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
/CRITICAL.*Network error/,
);
});
it('should throw when upload returns not accepted', async () => {
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Server rejected',
});
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
/CRITICAL.*Server rejected/,
);
});
it('should preserve other config properties when disabling encryption', async () => {
await service.disableEncryption();
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
jasmine.objectContaining({
baseUrl: 'https://test.example.com',
accessToken: 'test-token',
}),
);
});
});
describe('disableEncryptionForFileBased', () => {
let mockFileBasedProvider: jasmine.SpyObj<
SyncProviderServiceInterface<SyncProviderId>
>;
let mockAdapter: jasmine.SpyObj<OperationSyncCapable>;
beforeEach(() => {
mockFileBasedProvider = jasmine.createSpyObj('FileBasedProvider', [
'isReady',
'setPrivateCfg',
]);
mockFileBasedProvider.id = SyncProviderId.Dropbox;
mockFileBasedProvider.isReady.and.resolveTo(true);
mockFileBasedProvider.setPrivateCfg.and.resolveTo();
mockFileBasedProvider.privateCfg = {
load: () => Promise.resolve({ encryptKey: 'test-key' }),
} as any;
mockAdapter = jasmine.createSpyObj('Adapter', [
'uploadSnapshot',
'setLastServerSeq',
]);
mockAdapter.uploadSnapshot.and.resolveTo({
accepted: true,
serverSeq: 1,
});
mockAdapter.setLastServerSeq.and.resolveTo();
mockProviderManager.getActiveProvider.and.returnValue(mockFileBasedProvider);
mockFileBasedAdapter.createAdapter.and.returnValue(mockAdapter);
});
it('should throw when no active provider', async () => {
mockProviderManager.getActiveProvider.and.returnValue(null);
await expectAsync(service.disableEncryptionForFileBased()).toBeRejectedWithError(
/No active sync provider/,
);
});
it('should throw when provider is SuperSync (not file-based)', async () => {
const superSyncProvider = jasmine.createSpyObj('SuperSyncProvider', ['isReady']);
superSyncProvider.id = SyncProviderId.SuperSync;
mockProviderManager.getActiveProvider.and.returnValue(superSyncProvider);
await expectAsync(service.disableEncryptionForFileBased()).toBeRejectedWithError(
/only supported for file-based providers/,
);
});
it('should throw when provider is not ready', async () => {
mockFileBasedProvider.isReady.and.resolveTo(false);
await expectAsync(service.disableEncryptionForFileBased()).toBeRejectedWithError(
/not ready/,
);
});
it('should create unencrypted adapter and upload snapshot', async () => {
await service.disableEncryptionForFileBased();
expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledWith(
mockFileBasedProvider,
jasmine.objectContaining({ isEncrypt: false }),
undefined, // No encryption key
);
expect(mockAdapter.uploadSnapshot).toHaveBeenCalled();
});
it('should update config after successful upload', async () => {
await service.disableEncryptionForFileBased();
expect(mockFileBasedProvider.setPrivateCfg).toHaveBeenCalledWith(
jasmine.objectContaining({
encryptKey: undefined,
}),
);
});
it('should NOT update config on upload failure', async () => {
mockAdapter.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.disableEncryptionForFileBased()).toBeRejected();
expect(mockFileBasedProvider.setPrivateCfg).not.toHaveBeenCalled();
});
it('should clear cache after successful upload', async () => {
await service.disableEncryptionForFileBased();
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
});
it('should update global config to disable encryption', async () => {
await service.disableEncryptionForFileBased();
expect(mockGlobalConfigService.updateSection).toHaveBeenCalledWith('sync', {
isEncryptionEnabled: false,
encryptKey: '',
});
});
});
});

View file

@ -1,187 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { EncryptionEnableService } from './encryption-enable.service';
import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service';
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import {
OperationSyncCapable,
SyncProviderServiceInterface,
} from '../../op-log/sync-providers/provider.interface';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
describe('EncryptionEnableService', () => {
let service: EncryptionEnableService;
let mockSnapshotUploadService: jasmine.SpyObj<SnapshotUploadService>;
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockSyncProvider: jasmine.SpyObj<
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
>;
const mockExistingCfg: SuperSyncPrivateCfg = {
baseUrl: 'https://test.example.com',
accessToken: 'test-token',
isEncryptionEnabled: false,
encryptKey: undefined,
};
beforeEach(() => {
mockSyncProvider = jasmine.createSpyObj('SyncProvider', [
'deleteAllData',
'setPrivateCfg',
]);
mockSyncProvider.id = SyncProviderId.SuperSync;
mockSyncProvider.deleteAllData.and.resolveTo({ success: true });
mockSyncProvider.setPrivateCfg.and.resolveTo();
mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [
'gatherSnapshotData',
'uploadSnapshot',
'updateLastServerSeq',
]);
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
syncProvider: mockSyncProvider,
existingCfg: mockExistingCfg,
state: { task: [] },
vectorClock: { testClient1: 1 },
clientId: 'testClient1',
} as unknown as SnapshotUploadData);
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: true,
serverSeq: 1,
});
mockSnapshotUploadService.updateLastServerSeq.and.resolveTo();
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
'encryptPayload',
]);
mockEncryptionService.encryptPayload.and.resolveTo('encrypted-data');
mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [
'clearCache',
]);
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'setProviderConfig',
]);
mockProviderManager.setProviderConfig.and.resolveTo();
TestBed.configureTestingModule({
providers: [
EncryptionEnableService,
{ provide: SnapshotUploadService, useValue: mockSnapshotUploadService },
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
{ provide: WrappedProviderService, useValue: mockWrappedProviderService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
],
});
service = TestBed.inject(EncryptionEnableService);
});
describe('enableEncryption', () => {
it('should throw when encryptKey is empty', async () => {
await expectAsync(service.enableEncryption('')).toBeRejectedWithError(
'Encryption key is required',
);
});
it('should delete server data before enabling encryption', async () => {
await service.enableEncryption('my-secret-key');
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
mockProviderManager.setProviderConfig,
);
});
it('should update config with encryption enabled BEFORE upload', async () => {
await service.enableEncryption('my-secret-key');
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: 'my-secret-key',
isEncryptionEnabled: true,
}),
);
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledBefore(
mockSnapshotUploadService.uploadSnapshot,
);
});
it('should encrypt the state before uploading', async () => {
await service.enableEncryption('my-secret-key');
expect(mockEncryptionService.encryptPayload).toHaveBeenCalledWith(
{ task: [] },
'my-secret-key',
);
});
it('should upload encrypted snapshot with isPayloadEncrypted=true', async () => {
await service.enableEncryption('my-secret-key');
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith(
mockSyncProvider as any,
'encrypted-data',
'testClient1',
{ testClient1: 1 },
true, // isPayloadEncrypted
);
});
it('should update lastServerSeq after successful upload', async () => {
await service.enableEncryption('my-secret-key');
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
mockSyncProvider as any,
1,
'EncryptionEnableService',
);
});
it('should revert config on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejected();
// Should have called setProviderConfig twice:
// 1. Enable encryption before upload
// 2. Revert to unencrypted on failure
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(2);
// Second call should revert to unencrypted state
const revertCall = mockProviderManager.setProviderConfig.calls.mostRecent();
expect(revertCall.args[1]).toEqual(
jasmine.objectContaining({
encryptKey: undefined,
isEncryptionEnabled: false,
}),
);
});
it('should throw with CRITICAL message on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
/CRITICAL.*Network error/,
);
});
it('should revert config when upload returns not accepted', async () => {
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Server rejected',
});
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejected();
// Second call should revert
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -43,6 +43,9 @@ describe('EncryptionPasswordChangeService', () => {
getActiveProvider: jasmine
.createSpy('getActiveProvider')
.and.returnValue(mockSyncProvider),
setProviderConfig: jasmine
.createSpy('setProviderConfig')
.and.returnValue(Promise.resolve()),
};
mockCleanSlateService = jasmine.createSpyObj('CleanSlateService', [
@ -114,10 +117,12 @@ describe('EncryptionPasswordChangeService', () => {
// Should create clean slate
expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalledWith(
'ENCRYPTION_CHANGE',
'PASSWORD_CHANGED',
);
// Should update config with new password
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: TEST_PASSWORD,
isEncryptionEnabled: true,
@ -144,7 +149,8 @@ describe('EncryptionPasswordChangeService', () => {
await service.changePassword(TEST_PASSWORD);
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: TEST_PASSWORD,
isEncryptionEnabled: true,
@ -285,14 +291,15 @@ describe('EncryptionPasswordChangeService', () => {
);
// Should have set password to new value
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: TEST_PASSWORD,
}),
);
// Should NOT revert - only one call to setPrivateCfg
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledTimes(1);
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(1);
// Should have cleared cache only once (on change, not on revert)
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalledTimes(1);
@ -369,8 +376,8 @@ describe('EncryptionPasswordChangeService', () => {
callOrder.push('createCleanSlate');
});
mockSyncProvider.setPrivateCfg.and.callFake(async () => {
callOrder.push('setPrivateCfg');
mockProviderManager.setProviderConfig.and.callFake(async () => {
callOrder.push('setProviderConfig');
});
mockDerivedKeyCache.clearCache.and.callFake(() => {
@ -397,7 +404,7 @@ describe('EncryptionPasswordChangeService', () => {
'getUnsynced(1)', // Check for unsynced user ops
'createCleanSlate',
'getUnsynced(2)', // Verify SYNC_IMPORT was stored
'setPrivateCfg',
'setProviderConfig',
'clearDerivedKeyCache',
'clearWrappedProviderCache',
'uploadPendingOps',
@ -428,7 +435,8 @@ describe('EncryptionPasswordChangeService', () => {
await service.changePassword(TEST_PASSWORD);
// Verify BOTH fields are set in the new config
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: TEST_PASSWORD,
isEncryptionEnabled: true, // MUST be true!
@ -452,7 +460,8 @@ describe('EncryptionPasswordChangeService', () => {
await service.changePassword(TEST_PASSWORD);
// Verify all other fields are preserved
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
baseUrl: 'https://custom-server.com',
accessToken: 'my-access-token',
@ -542,7 +551,7 @@ describe('EncryptionPasswordChangeService', () => {
expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalled();
// Should NOT have updated config (failed before reaching that step)
expect(mockSyncProvider.setPrivateCfg).not.toHaveBeenCalled();
expect(mockProviderManager.setProviderConfig).not.toHaveBeenCalled();
});
it('should generate new client ID via clean slate to prevent operation conflicts', async () => {
@ -560,6 +569,7 @@ describe('EncryptionPasswordChangeService', () => {
// Verify clean slate was created (which generates new client ID)
expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalledWith(
'ENCRYPTION_CHANGE',
'PASSWORD_CHANGED',
);
});
});

View file

@ -246,4 +246,68 @@ describe('FileBasedEncryptionService', () => {
expect(mockProvider.setPrivateCfg).not.toHaveBeenCalled();
});
});
describe('disableEncryption', () => {
it('should create unencrypted adapter and upload snapshot', async () => {
await service.disableEncryption();
expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledWith(
mockProvider,
jasmine.objectContaining({ isEncrypt: false }),
undefined,
);
expect(mockAdapter.uploadSnapshot).toHaveBeenCalled();
});
it('should use providerManager.setProviderConfig for disable', async () => {
await service.disableEncryption();
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.WebDAV,
jasmine.objectContaining({
encryptKey: undefined,
}),
);
// Should NOT call setPrivateCfg directly
expect(mockProvider.setPrivateCfg).not.toHaveBeenCalled();
});
it('should update global config to disable encryption', async () => {
await service.disableEncryption();
expect(mockGlobalConfigService.updateSection).toHaveBeenCalledWith('sync', {
isEncryptionEnabled: false,
encryptKey: '',
});
});
it('should clear caches after successful upload', async () => {
await service.disableEncryption();
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalled();
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
});
it('should NOT update config on upload failure', async () => {
mockAdapter.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Upload rejected',
});
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
/Snapshot upload failed/,
);
expect(mockProviderManager.setProviderConfig).not.toHaveBeenCalled();
});
it('should throw when no active provider', async () => {
mockProviderManager.getActiveProvider.and.returnValue(null);
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
/No active sync provider/,
);
});
});
});

View file

@ -0,0 +1,479 @@
import { TestBed } from '@angular/core/testing';
import { SuperSyncEncryptionToggleService } from './supersync-encryption-toggle.service';
import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service';
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import {
OperationSyncCapable,
SyncProviderServiceInterface,
} from '../../op-log/sync-providers/provider.interface';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
describe('SuperSyncEncryptionToggleService', () => {
let service: SuperSyncEncryptionToggleService;
let mockSnapshotUploadService: jasmine.SpyObj<SnapshotUploadService>;
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockSyncProvider: jasmine.SpyObj<
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
>;
const mockExistingCfg: SuperSyncPrivateCfg = {
baseUrl: 'https://test.example.com',
accessToken: 'test-token',
isEncryptionEnabled: false,
encryptKey: undefined,
};
const mockState = { task: [], project: [] };
const mockVectorClock = { testClient1: 1 };
const mockClientId = 'testClient1';
beforeEach(() => {
mockSyncProvider = jasmine.createSpyObj('SyncProvider', [
'deleteAllData',
'setPrivateCfg',
]);
mockSyncProvider.id = SyncProviderId.SuperSync;
mockSyncProvider.deleteAllData.and.resolveTo({ success: true });
mockSyncProvider.setPrivateCfg.and.resolveTo();
mockSyncProvider.privateCfg = {
load: jasmine.createSpy('load').and.resolveTo(mockExistingCfg),
} as any;
(mockSyncProvider as any).supportsOperationSync = true;
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'getActiveProvider',
'setProviderConfig',
]);
mockProviderManager.getActiveProvider.and.returnValue(mockSyncProvider as any);
mockProviderManager.setProviderConfig.and.resolveTo();
mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [
'gatherSnapshotData',
'uploadSnapshot',
'updateLastServerSeq',
]);
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
syncProvider: mockSyncProvider,
existingCfg: mockExistingCfg,
state: mockState,
vectorClock: mockVectorClock,
clientId: mockClientId,
} as unknown as SnapshotUploadData);
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: true,
serverSeq: 42,
});
mockSnapshotUploadService.updateLastServerSeq.and.resolveTo();
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
'encryptPayload',
]);
mockEncryptionService.encryptPayload.and.resolveTo('encrypted-state-data');
mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [
'clearCache',
]);
TestBed.configureTestingModule({
providers: [
SuperSyncEncryptionToggleService,
{ provide: SnapshotUploadService, useValue: mockSnapshotUploadService },
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
{ provide: WrappedProviderService, useValue: mockWrappedProviderService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
],
});
service = TestBed.inject(SuperSyncEncryptionToggleService);
});
describe('enableEncryption', () => {
it('should throw when encryptKey is empty', async () => {
await expectAsync(service.enableEncryption('')).toBeRejectedWithError(
'Encryption key is required',
);
});
it('should skip when encryption is already enabled', async () => {
(mockSyncProvider.privateCfg.load as jasmine.Spy).and.resolveTo({
...mockExistingCfg,
isEncryptionEnabled: true,
encryptKey: 'existing-key',
});
await service.enableEncryption('new-key');
expect(mockSnapshotUploadService.gatherSnapshotData).not.toHaveBeenCalled();
});
it('should delete server data before enabling encryption', async () => {
await service.enableEncryption('my-secret-key');
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
mockSnapshotUploadService.uploadSnapshot,
);
});
it('should update config BEFORE upload via providerManager.setProviderConfig', async () => {
await service.enableEncryption('my-secret-key');
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: 'my-secret-key',
isEncryptionEnabled: true,
}),
);
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledBefore(
mockSnapshotUploadService.uploadSnapshot,
);
});
it('should encrypt the state before uploading', async () => {
await service.enableEncryption('my-secret-key');
expect(mockEncryptionService.encryptPayload).toHaveBeenCalledWith(
mockState,
'my-secret-key',
);
});
it('should upload encrypted snapshot with isPayloadEncrypted=true', async () => {
await service.enableEncryption('my-secret-key');
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith(
mockSyncProvider as any,
'encrypted-state-data',
mockClientId,
mockVectorClock,
true,
);
});
it('should update lastServerSeq after successful upload', async () => {
await service.enableEncryption('my-secret-key');
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
mockSyncProvider as any,
42,
'SuperSyncEncryptionToggleService',
);
});
it('should clear wrapped provider cache after config update', async () => {
await service.enableEncryption('my-secret-key');
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
});
it('should revert config on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Server rejected',
});
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
/CRITICAL/,
);
// First call: set new config (before upload)
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: 'my-secret-key',
isEncryptionEnabled: true,
}),
);
// Second call: revert config (after upload failure)
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: undefined,
isEncryptionEnabled: false,
}),
);
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(2);
});
it('should throw with CRITICAL message on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Server rejected',
});
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
/CRITICAL: Failed to upload encrypted snapshot after deleting server data/,
);
});
it('should throw with CRITICAL message when uploadSnapshot throws', async () => {
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
/CRITICAL.*Network error/,
);
});
it('should preserve existing config properties when enabling encryption', async () => {
const existingCfg: SuperSyncPrivateCfg = {
baseUrl: 'https://custom-server.com',
accessToken: 'my-access-token',
refreshToken: 'my-refresh-token',
isEncryptionEnabled: false,
encryptKey: undefined,
};
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
syncProvider: mockSyncProvider,
existingCfg,
state: mockState,
vectorClock: mockVectorClock,
clientId: mockClientId,
} as unknown as SnapshotUploadData);
await service.enableEncryption('my-secret-key');
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
baseUrl: 'https://custom-server.com',
accessToken: 'my-access-token',
refreshToken: 'my-refresh-token',
encryptKey: 'my-secret-key',
isEncryptionEnabled: true,
}),
);
});
it('should execute steps in correct order', async () => {
const callOrder: string[] = [];
mockSnapshotUploadService.gatherSnapshotData.and.callFake(async () => {
callOrder.push('gatherSnapshotData');
return {
syncProvider: mockSyncProvider,
existingCfg: mockExistingCfg,
state: mockState,
vectorClock: mockVectorClock,
clientId: mockClientId,
} as unknown as SnapshotUploadData;
});
mockSyncProvider.deleteAllData.and.callFake(async () => {
callOrder.push('deleteAllData');
return { success: true };
});
mockProviderManager.setProviderConfig.and.callFake(async () => {
callOrder.push('setProviderConfig');
});
mockWrappedProviderService.clearCache.and.callFake(() => {
callOrder.push('clearCache');
});
mockEncryptionService.encryptPayload.and.callFake(async () => {
callOrder.push('encryptPayload');
return 'encrypted-data';
});
mockSnapshotUploadService.uploadSnapshot.and.callFake(async () => {
callOrder.push('uploadSnapshot');
return { accepted: true, serverSeq: 42 };
});
mockSnapshotUploadService.updateLastServerSeq.and.callFake(async () => {
callOrder.push('updateLastServerSeq');
});
await service.enableEncryption('my-secret-key');
expect(callOrder).toEqual([
'gatherSnapshotData',
'encryptPayload',
'deleteAllData',
'setProviderConfig',
'clearCache',
'uploadSnapshot',
'updateLastServerSeq',
]);
});
});
describe('disableEncryption', () => {
it('should delete server data before disabling encryption', async () => {
await service.disableEncryption();
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
mockSnapshotUploadService.uploadSnapshot,
);
});
it('should upload unencrypted snapshot with isPayloadEncrypted=false', async () => {
await service.disableEncryption();
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith(
mockSyncProvider as any,
mockState,
mockClientId,
mockVectorClock,
false,
);
});
it('should update config with encryption disabled AFTER successful upload', async () => {
await service.disableEncryption();
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
encryptKey: undefined,
isEncryptionEnabled: false,
}),
);
// Upload should happen BEFORE config update
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledBefore(
mockProviderManager.setProviderConfig,
);
});
it('should update lastServerSeq after successful upload', async () => {
await service.disableEncryption();
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
mockSyncProvider as any,
42,
'SuperSyncEncryptionToggleService',
);
});
it('should clear wrapped provider cache after disabling', async () => {
await service.disableEncryption();
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
});
it('should NOT update config on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Server rejected',
});
await expectAsync(service.disableEncryption()).toBeRejectedWithError(/CRITICAL/);
// disableEncryption updates config AFTER upload — on failure, config should not be updated
expect(mockProviderManager.setProviderConfig).not.toHaveBeenCalled();
});
it('should throw with CRITICAL message on upload failure', async () => {
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
accepted: false,
error: 'Server rejected',
});
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
/CRITICAL: Failed to upload unencrypted snapshot after deleting server data/,
);
});
it('should throw with CRITICAL message when uploadSnapshot throws', async () => {
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
/CRITICAL.*Network error/,
);
});
it('should preserve other config properties when disabling encryption', async () => {
const existingCfg: SuperSyncPrivateCfg = {
baseUrl: 'https://custom-server.com',
accessToken: 'my-access-token',
refreshToken: 'my-refresh-token',
isEncryptionEnabled: true,
encryptKey: 'old-key',
};
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
syncProvider: mockSyncProvider,
existingCfg,
state: mockState,
vectorClock: mockVectorClock,
clientId: mockClientId,
} as unknown as SnapshotUploadData);
await service.disableEncryption();
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.SuperSync,
jasmine.objectContaining({
baseUrl: 'https://custom-server.com',
accessToken: 'my-access-token',
refreshToken: 'my-refresh-token',
encryptKey: undefined,
isEncryptionEnabled: false,
}),
);
});
it('should NOT encrypt the payload', async () => {
await service.disableEncryption();
expect(mockEncryptionService.encryptPayload).not.toHaveBeenCalled();
});
it('should execute steps in correct order', async () => {
const callOrder: string[] = [];
mockSnapshotUploadService.gatherSnapshotData.and.callFake(async () => {
callOrder.push('gatherSnapshotData');
return {
syncProvider: mockSyncProvider,
existingCfg: mockExistingCfg,
state: mockState,
vectorClock: mockVectorClock,
clientId: mockClientId,
} as unknown as SnapshotUploadData;
});
mockSyncProvider.deleteAllData.and.callFake(async () => {
callOrder.push('deleteAllData');
return { success: true };
});
mockSnapshotUploadService.uploadSnapshot.and.callFake(async () => {
callOrder.push('uploadSnapshot');
return { accepted: true, serverSeq: 42 };
});
mockSnapshotUploadService.updateLastServerSeq.and.callFake(async () => {
callOrder.push('updateLastServerSeq');
});
mockProviderManager.setProviderConfig.and.callFake(async () => {
callOrder.push('setProviderConfig');
});
mockWrappedProviderService.clearCache.and.callFake(() => {
callOrder.push('clearCache');
});
await service.disableEncryption();
expect(callOrder).toEqual([
'gatherSnapshotData',
'deleteAllData',
'uploadSnapshot',
'updateLastServerSeq',
'setProviderConfig',
'clearCache',
]);
});
});
});

View file

@ -8,15 +8,18 @@ import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { first } from 'rxjs/operators';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncWrapperService } from './sync-wrapper.service';
describe('SyncConfigService', () => {
let service: SyncConfigService;
let providerManager: jasmine.SpyObj<SyncProviderManager>;
let mockSyncConfig$: BehaviorSubject<SyncConfig>;
let mockCurrentProviderPrivateCfg$: BehaviorSubject<any>;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
// Mock fetch for the sync-config-default-override.json
originalFetch = globalThis.fetch;
// @ts-ignore - fetch might not exist in test environment
globalThis.fetch = jasmine.createSpy('fetch').and.returnValue(
Promise.resolve({
@ -54,12 +57,17 @@ describe('SyncConfigService', () => {
'clearCache',
]);
const syncWrapperServiceSpy = jasmine.createSpyObj('SyncWrapperService', [
'clearEncryptionDialogSuppression',
]);
TestBed.configureTestingModule({
providers: [
SyncConfigService,
{ provide: SyncProviderManager, useValue: providerManagerSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
{ provide: WrappedProviderService, useValue: wrappedProviderServiceSpy },
{ provide: SyncWrapperService, useValue: syncWrapperServiceSpy },
],
});
@ -69,6 +77,10 @@ describe('SyncConfigService', () => {
) as jasmine.SpyObj<SyncProviderManager>;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('updateSettingsFromForm', () => {
it('should update global config with non-private data only', async () => {
const globalConfigService = TestBed.inject(
@ -579,6 +591,37 @@ describe('SyncConfigService', () => {
expect(providerManager.setProviderConfig).toHaveBeenCalledTimes(2);
});
it('should deduplicate when syncSettingsForm$ emits before Formly modelChange', async () => {
// Mock provider for the test
(providerManager.getProviderById as jasmine.Spy).and.returnValue({
id: SyncProviderId.WebDAV,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
});
// Simulate syncSettingsForm$ emission by pushing provider config
mockCurrentProviderPrivateCfg$.next({
providerId: SyncProviderId.WebDAV,
privateCfg: {
baseUrl: 'https://example.com',
userName: 'user',
password: 'pass',
syncFolderPath: '/sync',
encryptKey: 'key',
},
});
// Capture the actual emitted value from syncSettingsForm$
const emittedSettings = await service.syncSettingsForm$.pipe(first()).toPromise();
// Now simulate Formly modelChange with the exact same emitted value
await service.updateSettingsFromForm(emittedSettings!);
// Should NOT have called setProviderConfig since _lastSettings matches
expect(providerManager.setProviderConfig).not.toHaveBeenCalled();
});
it('should not save private config when no provider is selected', async () => {
const settings: SyncConfig = {
isEnabled: false,
@ -1038,6 +1081,39 @@ describe('SyncConfigService', () => {
expect(callArgs.isEncryptionEnabled).toBeUndefined();
});
it('should not dispatch persistent global config action when updating password', async () => {
const globalConfigService = TestBed.inject(
GlobalConfigService,
) as jasmine.SpyObj<GlobalConfigService>;
globalConfigService.updateSection.calls.reset();
const mockProvider = {
id: SyncProviderId.SuperSync,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(
Promise.resolve({
baseUrl: 'http://test.com',
userName: 'test',
password: 'test',
accessToken: 'token',
syncFolderPath: '/',
encryptKey: 'oldpass',
isEncryptionEnabled: false,
}),
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
await service.updateEncryptionPassword('newpass', SyncProviderId.SuperSync);
// Must NOT dispatch a persistent global config update - this caused the
// encryption password change cascade bug where other clients couldn't
// decrypt the operation and got stuck in a decrypt error loop
expect(globalConfigService.updateSection).not.toHaveBeenCalled();
});
it('should preserve existing config when updating password', async () => {
// Setup SuperSync provider with existing config
const existingConfig = {

View file

@ -129,7 +129,9 @@ describe('SyncWrapperService', () => {
});
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open'], {
openDialogs: [],
});
mockTranslateService = jasmine.createSpyObj('TranslateService', ['instant']);
mockTranslateService.instant.and.callFake((key: string) => key);

View file

@ -66,7 +66,7 @@ describe('CleanSlateService', () => {
describe('createCleanSlate', () => {
it('should create a clean slate successfully', async () => {
await service.createCleanSlate('ENCRYPTION_CHANGE');
await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED');
// Should create pre-migration backup
expect(mockPreMigrationBackupService.createPreMigrationBackup).toHaveBeenCalledWith(
@ -107,7 +107,7 @@ describe('CleanSlateService', () => {
});
it('should work with MANUAL reason', async () => {
await service.createCleanSlate('MANUAL');
await service.createCleanSlate('MANUAL', 'PASSWORD_CHANGED');
expect(mockPreMigrationBackupService.createPreMigrationBackup).toHaveBeenCalledWith(
'MANUAL',
@ -120,7 +120,9 @@ describe('CleanSlateService', () => {
);
// Should not throw - backup failure is non-fatal
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeResolved();
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeResolved();
// Should still complete the clean slate
expect(mockOpLogStore.clearAllOperations).toHaveBeenCalled();
@ -128,14 +130,14 @@ describe('CleanSlateService', () => {
});
it('should generate fresh vector clock starting at 1', async () => {
await service.createCleanSlate('ENCRYPTION_CHANGE');
await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED');
const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation;
expect(appendedOp.vectorClock).toEqual({ eNewC: 1 });
});
it('should create operation with valid UUIDv7', async () => {
await service.createCleanSlate('ENCRYPTION_CHANGE');
await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED');
const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation;
// UUIDv7 format: 8-4-4-4-12 characters
@ -149,25 +151,25 @@ describe('CleanSlateService', () => {
new Error('State error'),
);
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith(
jasmine.objectContaining({ message: 'State error' }),
);
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeRejectedWith(jasmine.objectContaining({ message: 'State error' }));
});
it('should throw if client ID generation fails', async () => {
mockClientIdService.generateNewClientId.and.rejectWith(new Error('ClientID error'));
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith(
jasmine.objectContaining({ message: 'ClientID error' }),
);
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeRejectedWith(jasmine.objectContaining({ message: 'ClientID error' }));
});
it('should throw if operation append fails', async () => {
mockOpLogStore.append.and.rejectWith(new Error('Append error'));
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith(
jasmine.objectContaining({ message: 'Append error' }),
);
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeRejectedWith(jasmine.objectContaining({ message: 'Append error' }));
});
});
@ -175,25 +177,25 @@ describe('CleanSlateService', () => {
it('should propagate clearAllOperations errors', async () => {
mockOpLogStore.clearAllOperations.and.rejectWith(new Error('Clear failed'));
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith(
jasmine.objectContaining({ message: 'Clear failed' }),
);
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeRejectedWith(jasmine.objectContaining({ message: 'Clear failed' }));
});
it('should propagate setVectorClock errors', async () => {
mockOpLogStore.setVectorClock.and.rejectWith(new Error('VectorClock failed'));
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith(
jasmine.objectContaining({ message: 'VectorClock failed' }),
);
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeRejectedWith(jasmine.objectContaining({ message: 'VectorClock failed' }));
});
it('should propagate saveStateCache errors', async () => {
mockOpLogStore.saveStateCache.and.rejectWith(new Error('SaveCache failed'));
await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith(
jasmine.objectContaining({ message: 'SaveCache failed' }),
);
await expectAsync(
service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'),
).toBeRejectedWith(jasmine.objectContaining({ message: 'SaveCache failed' }));
});
});
@ -208,7 +210,7 @@ describe('CleanSlateService', () => {
return 1;
});
await service.createCleanSlate('ENCRYPTION_CHANGE');
await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED');
expect(callOrder).toEqual(['clear', 'append']);
});
@ -223,7 +225,7 @@ describe('CleanSlateService', () => {
callOrder.push('setVectorClock');
});
await service.createCleanSlate('ENCRYPTION_CHANGE');
await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED');
expect(callOrder).toEqual(['append', 'setVectorClock']);
});

View file

@ -29,6 +29,7 @@ import {
import { TranslateService } from '@ngx-translate/core';
import { LocalDataConflictError } from '../core/errors/sync-errors';
import { SyncHydrationService } from '../persistence/sync-hydration.service';
import { SyncImportConflictDialogService } from './sync-import-conflict-dialog.service';
import { StateSnapshotService } from '../backup/state-snapshot.service';
import { INBOX_PROJECT } from '../../features/project/project.const';
import { TODAY_TAG, SYSTEM_TAG_IDS } from '../../features/tag/tag.const';
@ -43,6 +44,7 @@ describe('OperationLogSyncService', () => {
let writeFlushServiceSpy: jasmine.SpyObj<OperationWriteFlushService>;
let superSyncStatusServiceSpy: jasmine.SpyObj<SuperSyncStatusService>;
let stateSnapshotServiceSpy: jasmine.SpyObj<StateSnapshotService>;
let syncImportConflictDialogServiceSpy: jasmine.SpyObj<SyncImportConflictDialogService>;
beforeEach(() => {
snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
@ -102,6 +104,12 @@ describe('OperationLogSyncService', () => {
'updatePendingOpsStatus',
]);
syncImportConflictDialogServiceSpy = jasmine.createSpyObj(
'SyncImportConflictDialogService',
['showConflictDialog'],
);
syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('CANCEL');
TestBed.configureTestingModule({
providers: [
OperationLogSyncService,
@ -196,6 +204,10 @@ describe('OperationLogSyncService', () => {
]),
},
{ provide: StateSnapshotService, useValue: stateSnapshotServiceSpy },
{
provide: SyncImportConflictDialogService,
useValue: syncImportConflictDialogServiceSpy,
},
],
});
@ -430,7 +442,7 @@ describe('OperationLogSyncService', () => {
expect(result?.localWinOpsCreated).toBe(5);
});
it('should call handleRejectedOps in finally block even if processRemoteOps throws', async () => {
it('should not call handleRejectedOps if processRemoteOps throws', async () => {
const piggybackedOp: Operation = {
id: 'piggybacked-1',
clientId: 'client-B',
@ -462,8 +474,8 @@ describe('OperationLogSyncService', () => {
'Processing failed',
);
// handleRejectedOps should still be called (via finally block)
expect(rejectedOpsHandlerServiceSpy.handleRejectedOps).toHaveBeenCalled();
// handleRejectedOps should NOT be called — error propagates before reaching rejection handling
expect(rejectedOpsHandlerServiceSpy.handleRejectedOps).not.toHaveBeenCalled();
});
it('should not call handleRejectedOps when there are no rejected ops', async () => {
@ -1040,7 +1052,7 @@ describe('OperationLogSyncService', () => {
expect(serverMigrationServiceSpy.handleServerMigration).toHaveBeenCalledWith(
mockProvider,
{ skipServerEmptyCheck: true }, // Force upload skips the server empty check
{ skipServerEmptyCheck: true, syncImportReason: 'FORCE_UPLOAD' },
);
});
@ -1627,4 +1639,430 @@ describe('OperationLogSyncService', () => {
}
});
});
describe('pre-op-log client on empty server (I.2 fix)', () => {
let downloadServiceSpy: jasmine.SpyObj<OperationLogDownloadService>;
beforeEach(() => {
downloadServiceSpy = TestBed.inject(
OperationLogDownloadService,
) as jasmine.SpyObj<OperationLogDownloadService>;
// Make this a fresh client (no snapshot, no ops)
opLogStoreSpy.loadStateCache.and.resolveTo(null);
opLogStoreSpy.getLastSeq.and.resolveTo(0);
opLogStoreSpy.getUnsynced.and.resolveTo([]);
});
it('should create SYNC_IMPORT via migration service when fresh client has meaningful data on empty server', async () => {
// Store has tasks (meaningful data)
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: ['task-1'] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [],
needsFullStateUpload: false,
success: true,
failedFileCount: 0,
latestServerSeq: 0, // Empty server
});
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as any;
const result = await service.downloadRemoteOps(mockProvider);
expect(serverMigrationServiceSpy.handleServerMigration).toHaveBeenCalledWith(
mockProvider,
{ syncImportReason: 'SERVER_MIGRATION' },
);
expect(result.serverMigrationHandled).toBe(true);
});
it('should NOT create SYNC_IMPORT when fresh client has no meaningful data on empty server', async () => {
// Store has only default data
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: [] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [],
needsFullStateUpload: false,
success: true,
failedFileCount: 0,
latestServerSeq: 0, // Empty server
});
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as any;
const result = await service.downloadRemoteOps(mockProvider);
expect(serverMigrationServiceSpy.handleServerMigration).not.toHaveBeenCalled();
expect(result.serverMigrationHandled).toBe(false);
});
it('should NOT create SYNC_IMPORT when server is not empty (latestServerSeq > 0)', async () => {
// Store has tasks
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: ['task-1'] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [],
needsFullStateUpload: false,
success: true,
failedFileCount: 0,
latestServerSeq: 5, // Server has data (just no new ops for us)
});
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as any;
const result = await service.downloadRemoteOps(mockProvider);
expect(serverMigrationServiceSpy.handleServerMigration).not.toHaveBeenCalled();
expect(result.serverMigrationHandled).toBe(false);
});
it('should NOT create SYNC_IMPORT when client is not fresh (has op-log history)', async () => {
// Client has history (not fresh)
opLogStoreSpy.loadStateCache.and.resolveTo({
state: {},
lastAppliedOpSeq: 5,
vectorClock: { clientA: 5 },
compactedAt: Date.now(),
});
opLogStoreSpy.getLastSeq.and.resolveTo(5);
// Store has tasks
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: ['task-1'] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [],
needsFullStateUpload: false,
success: true,
failedFileCount: 0,
latestServerSeq: 0,
});
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as any;
const result = await service.downloadRemoteOps(mockProvider);
// Should NOT call handleServerMigration - client is not fresh
expect(serverMigrationServiceSpy.handleServerMigration).not.toHaveBeenCalled();
expect(result.serverMigrationHandled).toBe(false);
});
});
describe('piggybacked SYNC_IMPORT conflict dialog', () => {
let uploadServiceSpy: jasmine.SpyObj<OperationLogUploadService>;
beforeEach(() => {
uploadServiceSpy = TestBed.inject(
OperationLogUploadService,
) as jasmine.SpyObj<OperationLogUploadService>;
// Not a fresh client
opLogStoreSpy.loadStateCache.and.resolveTo({
state: {},
lastAppliedOpSeq: 1,
vectorClock: {},
compactedAt: Date.now(),
});
opLogStoreSpy.getLastSeq.and.resolveTo(1);
});
it('should show conflict dialog when piggybacked ops contain SYNC_IMPORT and client has pending ops', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: { task: { ids: ['remote-task'] } },
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
syncImportReason: 'SERVER_MIGRATION',
};
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
});
// Client has pending ops
const pendingEntry: OperationLogEntry = {
seq: 1,
op: {
id: 'local-op-1',
clientId: 'client-A',
actionType: 'test' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Local Title' },
vectorClock: { clientA: 1 },
timestamp: Date.now(),
schemaVersion: 1,
},
appliedAt: Date.now(),
source: 'local',
};
opLogStoreSpy.getUnsynced.and.resolveTo([pendingEntry]);
syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('CANCEL');
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
const result = await service.uploadPendingOps(mockProvider);
expect(syncImportConflictDialogServiceSpy.showConflictDialog).toHaveBeenCalledWith(
jasmine.objectContaining({
scenario: 'INCOMING_IMPORT',
syncImportReason: 'SERVER_MIGRATION',
}),
);
expect(result?.cancelled).toBe(true);
});
it('should show conflict dialog when piggybacked ops contain SYNC_IMPORT and client has meaningful data', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: {},
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
};
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
});
// No pending ops but meaningful local data
opLogStoreSpy.getUnsynced.and.resolveTo([]);
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: ['task-1'] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('CANCEL');
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
const result = await service.uploadPendingOps(mockProvider);
expect(syncImportConflictDialogServiceSpy.showConflictDialog).toHaveBeenCalled();
expect(result?.cancelled).toBe(true);
});
it('should process piggybacked SYNC_IMPORT silently when no meaningful local data', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: {},
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
};
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
});
// No pending ops AND no meaningful data (only defaults)
opLogStoreSpy.getUnsynced.and.resolveTo([]);
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: [] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
const result = await service.uploadPendingOps(mockProvider);
// Should NOT show dialog
expect(
syncImportConflictDialogServiceSpy.showConflictDialog,
).not.toHaveBeenCalled();
// Should process normally via processRemoteOps
expect(remoteOpsProcessingServiceSpy.processRemoteOps).toHaveBeenCalledWith([
piggybackedSyncImport,
]);
expect(result?.cancelled).toBeUndefined();
});
it('should process piggybacked ops normally when no SYNC_IMPORT present', async () => {
const piggybackedOp: Operation = {
id: 'op-1',
clientId: 'client-B',
actionType: 'test' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Remote Title' },
vectorClock: { clientB: 1 },
timestamp: Date.now(),
schemaVersion: 1,
};
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedOp],
rejectedCount: 0,
rejectedOps: [],
});
opLogStoreSpy.getUnsynced.and.resolveTo([]);
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
const result = await service.uploadPendingOps(mockProvider);
// Should NOT show dialog
expect(
syncImportConflictDialogServiceSpy.showConflictDialog,
).not.toHaveBeenCalled();
// Should process normally
expect(remoteOpsProcessingServiceSpy.processRemoteOps).toHaveBeenCalledWith([
piggybackedOp,
]);
expect(result?.cancelled).toBeUndefined();
});
it('should call forceUploadLocalState when user chooses USE_LOCAL', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: {},
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
};
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
});
opLogStoreSpy.getUnsynced.and.resolveTo([]);
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: ['task-1'] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('USE_LOCAL');
const forceUploadSpy = spyOn(service, 'forceUploadLocalState').and.resolveTo();
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
await service.uploadPendingOps(mockProvider);
expect(forceUploadSpy).toHaveBeenCalledWith(mockProvider);
});
it('should call forceDownloadRemoteState when user chooses USE_REMOTE', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: {},
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
};
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
});
opLogStoreSpy.getUnsynced.and.resolveTo([]);
stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({
task: { ids: ['task-1'] },
project: { ids: [INBOX_PROJECT.id] },
tag: { ids: [TODAY_TAG.id] },
note: { ids: [] },
} as any);
syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('USE_REMOTE');
const forceDownloadSpy = spyOn(service, 'forceDownloadRemoteState').and.resolveTo();
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
await service.uploadPendingOps(mockProvider);
expect(forceDownloadSpy).toHaveBeenCalledWith(mockProvider);
});
});
});

View file

@ -1184,6 +1184,8 @@ describe('SyncImportFilterService', () => {
});
it('should set isLocalUnsyncedImport=false when stored import is local but already synced', async () => {
// Once synced, the import is established — old straggler ops should be
// silently discarded without showing the conflict dialog.
const storedImportOp = createOp({
id: '019afd68-0050-7000-0000-000000000000',
opType: OpType.SyncImport,