fix: resolve build issues and update E2E tests for sync import conflict

- Fix TypeScript error in passkey.ts (Buffer to Uint8Array conversion)
- Update Dockerfile.test to use npm install instead of npm ci for workspace sync
- Update E2E test to not setup sync before import
- Add additional unit tests for sync-wrapper.service

Note: E2E tests for sync import conflict dialog need further work due to
automatic upload of SYNC_IMPORT when sync is enabled. The core dialog
functionality is implemented and unit tested.
This commit is contained in:
Johannes Millan 2026-01-10 18:48:47 +01:00
parent 4780f08333
commit 6501950760
4 changed files with 97 additions and 13 deletions

View file

@ -75,8 +75,7 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
console.log('[Conflict Dialog] Phase 2: Client B imports backup');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// DO NOT sync before import - B doesn't know about A's task
// DO NOT setup sync before import - we want B to have no knowledge of server state
// Navigate to import page
const importPage = new ImportPage(clientB.page);
@ -91,14 +90,14 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
await clientB.page.reload();
await clientB.page.waitForLoadState('networkidle');
// Re-enable sync after import (import overwrites globalConfig)
// NOW setup sync - B has local SYNC_IMPORT but no knowledge of server ops
await clientB.sync.setupSuperSync(syncConfig);
// ============ PHASE 3: Client B Syncs (Should See Dialog) ============
console.log('[Conflict Dialog] Phase 3: Client B syncs (should see dialog)');
// Trigger sync - this should cause the dialog to appear
await clientB.page.locator('button.trigger-sync-button').click();
await clientB.sync.triggerSync();
// Wait for the conflict dialog to appear
const dialog = clientB.page.locator('mat-dialog-container');
@ -173,7 +172,7 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
console.log('[USE_LOCAL] Phase 2: Client B imports backup');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// DO NOT setup sync before import
const importPage = new ImportPage(clientB.page);
await importPage.navigateToImportPage();
@ -183,12 +182,13 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
await clientB.page.reload();
await clientB.page.waitForLoadState('networkidle');
// Setup sync AFTER import
await clientB.sync.setupSuperSync(syncConfig);
// ============ PHASE 3: Client B Syncs and Chooses USE_LOCAL ============
console.log('[USE_LOCAL] Phase 3: Client B syncs and chooses USE_LOCAL');
await clientB.page.locator('button.trigger-sync-button').click();
await clientB.sync.triggerSync();
const dialog = clientB.page.locator('mat-dialog-container');
await expect(dialog).toBeVisible({ timeout: 10000 });
@ -276,7 +276,7 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
console.log('[USE_REMOTE] Phase 2: Client B imports backup');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// DO NOT setup sync before import
const importPage = new ImportPage(clientB.page);
await importPage.navigateToImportPage();
@ -286,12 +286,13 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
await clientB.page.reload();
await clientB.page.waitForLoadState('networkidle');
// Setup sync AFTER import
await clientB.sync.setupSuperSync(syncConfig);
// ============ PHASE 3: Client B Syncs and Chooses USE_REMOTE ============
console.log('[USE_REMOTE] Phase 3: Client B syncs and chooses USE_REMOTE');
await clientB.page.locator('button.trigger-sync-button').click();
await clientB.sync.triggerSync();
const dialog = clientB.page.locator('mat-dialog-container');
await expect(dialog).toBeVisible({ timeout: 10000 });
@ -366,7 +367,7 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
console.log('[CANCEL] Phase 2: Client B imports backup');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// DO NOT setup sync before import
const importPage = new ImportPage(clientB.page);
await importPage.navigateToImportPage();
@ -376,12 +377,13 @@ test.describe('@supersync @import-conflict Sync Import Conflict Dialog', () => {
await clientB.page.reload();
await clientB.page.waitForLoadState('networkidle');
// Setup sync AFTER import
await clientB.sync.setupSuperSync(syncConfig);
// ============ PHASE 3: Client B Syncs and Chooses CANCEL ============
console.log('[CANCEL] Phase 3: Client B syncs and chooses CANCEL');
await clientB.page.locator('button.trigger-sync-button').click();
await clientB.sync.triggerSync();
const dialog = clientB.page.locator('mat-dialog-container');
await expect(dialog).toBeVisible({ timeout: 10000 });

View file

@ -20,7 +20,8 @@ COPY packages/shared-schema/package.json ./packages/shared-schema/
COPY packages/super-sync-server/package.json ./packages/super-sync-server/
# Install dependencies (ignore scripts to skip husky/prepare from root)
RUN npm ci --workspace=packages/shared-schema --workspace=packages/super-sync-server --ignore-scripts
# Using npm install instead of npm ci due to workspace lock file sync issues
RUN npm install --workspace=packages/shared-schema --workspace=packages/super-sync-server --ignore-scripts
# Copy source files
COPY packages/shared-schema/ ./packages/shared-schema/

View file

@ -360,7 +360,7 @@ export const verifyAuthentication = async (
requireUserVerification: false, // We use 'preferred', not 'required'
credential: {
id: passkey.credentialId.toString('base64url'),
publicKey: passkey.publicKey,
publicKey: new Uint8Array(passkey.publicKey),
counter: Number(passkey.counter),
transports: passkey.transports ? JSON.parse(passkey.transports) : undefined,
},

View file

@ -393,7 +393,7 @@ describe('SyncWrapperService', () => {
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
});
it('should set ERROR and return HANDLED_ERROR when upload has rejected ops', async () => {
it('should set ERROR and return HANDLED_ERROR when upload has rejected ops with "Payload too complex"', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 0,
@ -411,6 +411,24 @@ describe('SyncWrapperService', () => {
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
});
it('should set ERROR and return HANDLED_ERROR when upload has rejected ops with "Payload too large"', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 0,
rejectedCount: 1,
piggybackedOps: [],
rejectedOps: [{ opId: 'test-op', error: 'Payload too large' }],
localWinOpsCreated: 0,
}),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
});
it('should set ERROR for non-payload rejected ops', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
@ -427,6 +445,69 @@ describe('SyncWrapperService', () => {
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
});
it('should set IN_SYNC when some ops uploaded and none rejected', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 5,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
}),
);
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
it('should set ERROR when multiple ops rejected', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 3,
rejectedCount: 2,
piggybackedOps: [],
rejectedOps: [
{ opId: 'op1', error: 'Conflict' },
{ opId: 'op2', error: 'Validation failed' },
],
localWinOpsCreated: 0,
}),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
});
it('should set IN_SYNC when uploadResult is null (fresh client)', async () => {
mockSyncService.uploadPendingOps.and.returnValue(Promise.resolve(null));
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
it('should set IN_SYNC when rejectedCount is 0 even with empty rejectedOps array', async () => {
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 0,
rejectedCount: 0,
piggybackedOps: [],
rejectedOps: [],
localWinOpsCreated: 0,
}),
);
const result = await service.sync();
expect(result).toBe(SyncStatus.InSync);
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
});
});
describe('Error handling', () => {