一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
212 行
5.8 KiB
TypeScript
212 行
5.8 KiB
TypeScript
import BaseItem from '../../models/BaseItem';
|
|
import Note from '../../models/Note';
|
|
import { encryptionService, loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
|
import handleConflictAction from './utils/handleConflictAction';
|
|
import { SyncAction } from './utils/types';
|
|
|
|
describe('handleConflictAction', () => {
|
|
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await switchClient(1);
|
|
});
|
|
|
|
test('note conflict is created', async () => {
|
|
const local = await Note.save({ title: 'Test', body: 'body' });
|
|
// Pass the local note with unsaved changes to verify that the note is reloaded before creating the conflict
|
|
const changedLocal = { ...local, title: 'TestChanged' };
|
|
const remoteContent = { ...local, title: 'TestRemote' };
|
|
const initialSyncItem = await BaseItem.syncItem(1, local.id);
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
changedLocal,
|
|
1,
|
|
false,
|
|
(action) => (action),
|
|
);
|
|
|
|
const createdSyncItem = await BaseItem.syncItem(1, local.id);
|
|
const updatedLocal = await Note.load(local.id);
|
|
const notes = await Note.all();
|
|
const conflictNote = await Note.loadByTitle('Test');
|
|
|
|
expect(initialSyncItem).toBeUndefined();
|
|
expect(createdSyncItem).toBeDefined();
|
|
expect(updatedLocal.title).toBe('TestRemote');
|
|
expect(notes.length).toBe(2);
|
|
expect(conflictNote.id).not.toBe(local.id);
|
|
});
|
|
|
|
test('note conflict is not created when remote and local contents match', async () => {
|
|
const local = await Note.save({ title: 'Test', body: 'body' });
|
|
// Pass the local note with unsaved changes to verify that the note is reloaded before checking if eligible to create a conflict
|
|
const changedLocal = { ...local, title: 'TestChanged' };
|
|
const remoteContent = local;
|
|
const initialSyncItem = await BaseItem.syncItem(1, local.id);
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
changedLocal,
|
|
1,
|
|
false,
|
|
(action) => (action),
|
|
);
|
|
|
|
const createdSyncItem = await BaseItem.syncItem(1, local.id);
|
|
const notes = await Note.all();
|
|
expect(initialSyncItem).toBeUndefined();
|
|
expect(createdSyncItem).toBeDefined();
|
|
expect(notes.length).toBe(1);
|
|
});
|
|
|
|
test('note conflict is not created when encrypted remote and local contents match', async () => {
|
|
await loadEncryptionMasterKey();
|
|
const local = await Note.save({ title: 'Test', body: 'body' });
|
|
const encryption_cipher_text = await encryptionService().encryptString(await Note.serialize(local));
|
|
const remoteContent = {
|
|
...local,
|
|
title: '',
|
|
body: '',
|
|
encryption_applied: 1,
|
|
encryption_cipher_text,
|
|
};
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
local,
|
|
1,
|
|
false,
|
|
jest.fn(),
|
|
);
|
|
|
|
expect(await Note.all()).toHaveLength(1);
|
|
});
|
|
|
|
test('conflict created for a remote deletion does not reference the deleted local note', async () => {
|
|
const local = await Note.save({ title: 'Locally changed', body: 'local body' });
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
false,
|
|
null,
|
|
local,
|
|
1,
|
|
false,
|
|
jest.fn(),
|
|
);
|
|
|
|
expect(await Note.load(local.id)).toBeFalsy();
|
|
const notes = await Note.all();
|
|
expect(notes).toHaveLength(1);
|
|
expect(notes[0]).toMatchObject({
|
|
title: local.title,
|
|
body: local.body,
|
|
is_conflict: 1,
|
|
conflict_original_id: '',
|
|
});
|
|
});
|
|
|
|
test('conflict of a conflict is not created for a read-only note', async () => {
|
|
const local = await Note.save({ title: 'Local conflict', body: 'local', is_conflict: 1 });
|
|
const remoteContent = { ...local, title: 'Remote conflict', body: 'remote' };
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
local,
|
|
1,
|
|
true,
|
|
jest.fn(),
|
|
);
|
|
|
|
const notes = await Note.all();
|
|
expect(notes).toHaveLength(1);
|
|
expect(notes[0].id).toBe(local.id);
|
|
expect(notes[0].title).toBe(remoteContent.title);
|
|
});
|
|
|
|
test('locked note becomes a conflict and the lock is restored when a read-only remote lost its lock', async () => {
|
|
const local = await Note.save({ title: 'Locked', body: 'JLD01cipher', is_locked: 1 });
|
|
const remoteContent = { ...local, body: 'edited on an old client' };
|
|
delete remoteContent.is_locked;
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
local,
|
|
1,
|
|
true,
|
|
jest.fn(),
|
|
);
|
|
|
|
expect(await Note.load(local.id)).toMatchObject({ body: remoteContent.body, is_locked: 1 });
|
|
expect((await Note.all()).find(note => note.is_conflict)).toMatchObject({ body: 'JLD01cipher', is_locked: 1 });
|
|
expect((await BaseItem.itemsThatNeedSync(1)).items.map(item => item.id)).not.toContain(local.id);
|
|
});
|
|
|
|
test('remote content is not decrypted for an existing conflict note', async () => {
|
|
const local = await Note.save({ title: 'Local conflict', body: 'local', is_conflict: 1 });
|
|
const remoteContent = {
|
|
...local,
|
|
title: '',
|
|
body: '',
|
|
encryption_applied: 1,
|
|
encryption_cipher_text: 'invalid',
|
|
};
|
|
const decryptStringSpy = jest.spyOn(encryptionService(), 'decryptString');
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
local,
|
|
1,
|
|
false,
|
|
jest.fn(),
|
|
);
|
|
|
|
expect(decryptStringSpy).not.toHaveBeenCalled();
|
|
decryptStringSpy.mockRestore();
|
|
expect(await Note.all()).toHaveLength(1);
|
|
});
|
|
|
|
test('editor reload event is emitted for note conflict', async () => {
|
|
const local = await Note.save({ title: 'Test', body: 'body' });
|
|
const remoteContent = { ...local, title: 'TestRemote' };
|
|
|
|
const dispatch = jest.fn();
|
|
|
|
await handleConflictAction(
|
|
SyncAction.NoteConflict,
|
|
Note,
|
|
true,
|
|
remoteContent,
|
|
local,
|
|
1,
|
|
false,
|
|
dispatch,
|
|
);
|
|
|
|
expect(dispatch).toHaveBeenCalledWith({
|
|
type: 'EDITOR_NOTE_NEEDS_RELOAD',
|
|
noteId: local.id,
|
|
});
|
|
});
|
|
});
|