文件
mynode/packages/lib/services/synchronizer/Synchronizer.basics.test.ts
chen 301194963b
一些检查失败了
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
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

593 行
18 KiB
TypeScript

import Setting from '../../models/Setting';
import { allNotesFolders, remoteNotesAndFolders, localNotesFoldersSameAsRemote } from '../../testing/test-utils-synchronizer';
import { syncTargetName, afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi, expectThrow, createNoteAndResource } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import BaseItem from '../../models/BaseItem';
import WelcomeUtils, { WelcomeAssetPlatform } from '../../WelcomeUtils';
import { NoteEntity } from '../database/types';
import { fetchSyncInfo, setAppMinVersion, uploadSyncInfo } from './syncInfoUtils';
import { ErrorCode } from '../../errors';
import Resource from '../../models/Resource';
import { exists } from 'fs-extra';
describe('Synchronizer.basics', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
synchronizer().testingHooks_ = [];
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should create remote items', (async () => {
const folder = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'un', parent_id: folder.id });
const all = await allNotesFolders();
await synchronizerStart();
await localNotesFoldersSameAsRemote(all, expect);
}));
it('should update remote items', (async () => {
const folder = await Folder.save({ title: 'folder1' });
const note = await Note.save({ title: 'un', parent_id: folder.id });
await synchronizerStart();
await Note.save({ title: 'un UPDATE', id: note.id });
const all = await allNotesFolders();
await synchronizerStart();
await localNotesFoldersSameAsRemote(all, expect);
}));
it('should create local items', (async () => {
const folder = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'un', parent_id: folder.id });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
const all = await allNotesFolders();
await localNotesFoldersSameAsRemote(all, expect);
}));
it('should update local items', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
await synchronizerStart();
await switchClient(2);
const dispatchC2 = jest.fn();
synchronizer(2).dispatch = dispatchC2;
await synchronizerStart();
await sleep(0.1);
let note2 = await Note.load(note1.id);
note2.title = 'Updated on client 2';
await Note.save(note2);
note2 = await Note.load(note2.id);
await synchronizerStart();
expect(dispatchC2).not.toHaveBeenCalledWith({
type: 'EDITOR_NOTE_NEEDS_RELOAD',
noteId: note1.id,
});
await switchClient(1);
const dispatchC1 = jest.fn();
synchronizer(1).dispatch = dispatchC1;
await synchronizerStart();
expect(dispatchC1).toHaveBeenCalledWith({
type: 'EDITOR_NOTE_NEEDS_RELOAD',
noteId: note1.id,
});
const all = await allNotesFolders();
await localNotesFoldersSameAsRemote(all, expect);
}));
it('should delete remote notes', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await sleep(0.1);
await Note.delete(note1.id);
await synchronizerStart();
const remotes = await remoteNotesAndFolders();
expect(remotes).toHaveLength(1);
expect(remotes[0].id).toBe(folder1.id);
const deletedItems = await BaseItem.deletedItems(syncTargetId());
expect(deletedItems.length).toBe(0);
}));
it('should delete multiple remote notes', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const count = 25;
const notes = [];
for (let i = 0; i < count; i++) {
notes.push(await Note.save({ title: 'test', parent_id: folder1.id }));
}
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await sleep(0.1);
await Note.batchDelete(notes.map(n => n.id));
await synchronizerStart();
const remotes = await remoteNotesAndFolders();
expect(remotes).toMatchObject([{ id: folder1.id }]);
const deletedItems = await BaseItem.deletedItems(syncTargetId());
expect(deletedItems.length).toBe(0);
}));
it('should delete a remote resource', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const { resource } = await createNoteAndResource({ parentId: folder1.id });
const expectResourceExists = async (expected: boolean) => {
expect(!!await Resource.load(resource.id)).toBe(expected);
const resourcePath = Resource.fullPath(resource);
expect(await exists(resourcePath)).toBe(expected);
};
await expectResourceExists(true);
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await sleep(0.1);
await Resource.delete(resource.id);
await synchronizerStart();
await expectResourceExists(false);
await switchClient(1);
await synchronizerStart();
await expectResourceExists(false);
}));
it('should not created deleted_items entries for items deleted via sync', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'un', parent_id: folder1.id });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await Folder.delete(folder1.id);
await synchronizerStart();
await switchClient(1);
await synchronizerStart();
const deletedItems = await BaseItem.deletedItems(syncTargetId());
expect(deletedItems.length).toBe(0);
}));
it('should delete local notes', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
const note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await Note.delete(note1.id);
await synchronizerStart();
await switchClient(1);
await synchronizerStart();
const items = await allNotesFolders();
expect(items.length).toBe(2);
const deletedItems = await BaseItem.deletedItems(syncTargetId());
expect(deletedItems.length).toBe(0);
await Note.delete(note2.id);
await synchronizerStart();
}));
it('should delete remote folder', (async () => {
await Folder.save({ title: 'folder1' });
const folder2 = await Folder.save({ title: 'folder2' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await sleep(0.1);
await Folder.delete(folder2.id);
await synchronizerStart();
const all = await allNotesFolders();
await localNotesFoldersSameAsRemote(all, expect);
}));
it('should delete local folder', (async () => {
await Folder.save({ title: 'folder1' });
const folder2 = await Folder.save({ title: 'folder2' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await Folder.delete(folder2.id);
await synchronizerStart();
await switchClient(1);
await synchronizerStart();
const items = await allNotesFolders();
await localNotesFoldersSameAsRemote(items, expect);
}));
it('should cross delete all folders', (async () => {
// If client1 and 2 have two folders, client 1 deletes item 1 and client
// 2 deletes item 2, they should both end up with no items after sync.
const folder1 = await Folder.save({ title: 'folder1' });
const folder2 = await Folder.save({ title: 'folder2' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await sleep(0.1);
await Folder.delete(folder1.id);
await switchClient(1);
await Folder.delete(folder2.id);
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
const items2 = await allNotesFolders();
await switchClient(1);
await synchronizerStart();
const items1 = await allNotesFolders();
expect(items1.length).toBe(0);
expect(items1.length).toBe(items2.length);
}));
it('items should be downloaded again when user cancels in the middle of delta operation', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
await synchronizerStart();
await switchClient(2);
synchronizer().testingHooks_ = ['cancelDeltaLoop2'];
await synchronizerStart();
let notes = await Note.all();
expect(notes.length).toBe(0);
synchronizer().testingHooks_ = [];
await synchronizerStart();
notes = await Note.all();
expect(notes.length).toBe(1);
}));
it('should skip items that cannot be synced', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
const noteId = note1.id;
await synchronizerStart();
let disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
expect(disabledItems.length).toBe(0);
await Note.save({ id: noteId, title: 'un mod' });
synchronizer().testingHooks_ = ['notesRejectedByTarget'];
await synchronizerStart();
synchronizer().testingHooks_ = [];
await synchronizerStart(); // Another sync to check that this item is now excluded from sync
await switchClient(2);
await synchronizerStart();
const notes = await Note.all();
expect(notes.length).toBe(1);
expect(notes[0].title).toBe('un');
await switchClient(1);
disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
expect(disabledItems.length).toBe(1);
}));
it('should handle items that are read-only on the sync target', (async () => {
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'un', is_todo: 1, parent_id: folder.id });
const noteId = note.id;
await synchronizerStart();
await Note.save({ id: noteId, title: 'un mod' });
synchronizer().testingHooks_ = ['itemIsReadOnly'];
await synchronizerStart();
synchronizer().testingHooks_ = [];
const noteReload = await Note.load(note.id);
expect(noteReload.title).toBe(note.title);
const conflictNote: NoteEntity = (await Note.all()).find((n: NoteEntity) => !!n.is_conflict);
expect(conflictNote).toBeTruthy();
expect(conflictNote.title).toBe('un mod');
expect(conflictNote.id).not.toBe(note.id);
}));
it('should revert local changes to read-only folders', (async () => {
const folder = await Folder.save({ title: 'folder' });
await synchronizerStart();
await Folder.save({
id: folder.id,
title: 'folder (changed)',
share_id: 'some-incorrect-share-id',
});
synchronizer().testingHooks_ = ['itemIsReadOnly'];
await synchronizerStart();
synchronizer().testingHooks_ = [];
const reloadedFolder = await Folder.load(folder.id);
expect(reloadedFolder.title).toBe('folder');
expect(reloadedFolder.share_id).toBe('');
// Should not have created a conflict
expect(await Folder.all()).toHaveLength(1);
}));
it('should allow duplicate folder titles', (async () => {
await Folder.save({ title: 'folder' });
await switchClient(2);
let remoteF2 = await Folder.save({ title: 'folder' });
await synchronizerStart();
await switchClient(1);
await sleep(0.1);
await synchronizerStart();
const localF2 = await Folder.load(remoteF2.id);
expect(localF2.title === remoteF2.title).toBe(true);
// Then that folder that has been renamed locally should be set in such a way
// that synchronizing it applies the title change remotely, and that new title
// should be retrieved by client 2.
await synchronizerStart();
await switchClient(2);
await sleep(0.1);
await synchronizerStart();
remoteF2 = await Folder.load(remoteF2.id);
expect(remoteF2.title === localF2.title).toBe(true);
}));
it('should create remote items with UTF-8 content', (async () => {
const folder = await Folder.save({ title: 'Fahrräder' });
await Note.save({ title: 'Fahrräder', body: 'Fahrräder', parent_id: folder.id });
const all = await allNotesFolders();
await synchronizerStart();
await localNotesFoldersSameAsRemote(all, expect);
}));
it('should update remote items but not pull remote changes', (async () => {
const folder = await Folder.save({ title: 'folder1' });
const note = await Note.save({ title: 'un', parent_id: folder.id });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await Note.save({ title: 'deux', parent_id: folder.id });
await synchronizerStart();
await switchClient(1);
await Note.save({ title: 'un UPDATE', id: note.id });
await synchronizerStart(null, { syncSteps: ['update_remote'] });
const all = await allNotesFolders();
expect(all.length).toBe(2);
await switchClient(2);
await synchronizerStart();
const note2 = await Note.load(note.id);
expect(note2.title).toBe('un UPDATE');
}));
it('should create a new Welcome notebook on each client', (async () => {
// Create the Welcome items on two separate clients
await WelcomeUtils.createWelcomeItems('en_GB', WelcomeAssetPlatform.Cli);
await synchronizerStart();
await switchClient(2);
await WelcomeUtils.createWelcomeItems('en_GB', WelcomeAssetPlatform.Cli);
const beforeFolderCount = (await Folder.all()).length;
const beforeNoteCount = (await Note.all()).length;
expect(beforeFolderCount === 1).toBe(true);
expect(beforeNoteCount > 1).toBe(true);
await synchronizerStart();
const afterFolderCount = (await Folder.all()).length;
const afterNoteCount = (await Note.all()).length;
expect(afterFolderCount).toBe(beforeFolderCount * 2);
expect(afterNoteCount).toBe(beforeNoteCount * 2);
// Changes to the Welcome items should be synced to all clients
const f1 = (await Folder.all())[0];
await Folder.save({ id: f1.id, title: 'Welcome MOD' });
await synchronizerStart();
await switchClient(1);
await synchronizerStart();
const f1_1 = await Folder.load(f1.id);
expect(f1_1.title).toBe('Welcome MOD');
}));
it('should not wipe out user data when syncing with an empty target', (async () => {
// Only these targets support the wipeOutFailSafe flag (in other words, the targets that use basicDelta)
if (!['nextcloud', 'memory', 'filesystem', 'amazon_s3'].includes(syncTargetName())) return;
for (let i = 0; i < 10; i++) await Note.save({ title: 'note' });
Setting.setValue('sync.wipeOutFailSafe', true);
await synchronizerStart();
await fileApi().clearRoot(); // oops
await synchronizerStart();
expect((await Note.all()).length).toBe(10); // but since the fail-safe if on, the notes have not been deleted
Setting.setValue('sync.wipeOutFailSafe', false); // Now switch it off
await synchronizerStart();
expect((await Note.all()).length).toBe(0); // Since the fail-safe was off, the data has been cleared
// Handle case where the sync target has been wiped out, then the user creates one note and sync.
for (let i = 0; i < 10; i++) await Note.save({ title: 'note' });
Setting.setValue('sync.wipeOutFailSafe', true);
await synchronizerStart();
await fileApi().clearRoot();
await Note.save({ title: 'ma note encore' });
await synchronizerStart();
expect((await Note.all()).length).toBe(11);
}));
it('should not sync deletions that came via sync even when there is a conflict', (async () => {
// This test is mainly to simulate sharing, unsharing and sharing a note
// again. Previously, when doing so, the app would create deleted_items
// objects on the recipient when the owner unshares. It means that when
// sharing again, the recipient would apply the deletions and delete
// everything in the shared notebook.
//
// Specifically it was happening when a conflict was generated as a
// result of the items being deleted.
//
// - C1 creates a note and sync
// - C2 sync and get the note
// - C2 deletes the note and sync
// - C1 modify the note, and sync
//
// => A conflict is created. The note is deleted and a copy is created
// in the Conflict folder.
//
// After this, we recreate the note on the sync target (simulates the
// note being shared again), and we check that C2 doesn't attempt to
// delete that note.
const note = await Note.save({});
await synchronizerStart();
const noteSerialized = await fileApi().get(`${note.id}.md`);
await switchClient(2);
await synchronizerStart();
await Note.delete(note.id);
await synchronizerStart();
await switchClient(1);
await Note.save({ id: note.id });
await synchronizerStart();
expect((await Note.all())[0].is_conflict).toBe(1);
await fileApi().put(`${note.id}.md`, noteSerialized); // Recreate the note - simulate sharing again.
await synchronizerStart();
// Check that the client didn't delete the note
const remotes = (await fileApi().list()).items;
expect(remotes.find(r => r.path === `${note.id}.md`)).toBeTruthy();
}));
it('should throw an error if the app version is not compatible with the sync target info', (async () => {
await synchronizerStart();
const remoteInfo = await fetchSyncInfo(synchronizer().api());
remoteInfo.appMinVersion = '100.0.0';
await uploadSyncInfo(synchronizer().api(), remoteInfo);
await expectThrow(async () => synchronizerStart(1, {
throwOnError: true,
}), ErrorCode.MustUpgradeApp);
}));
it('should update the remote appMinVersion when synchronising', (async () => {
await synchronizerStart();
const remoteInfoBefore = await fetchSyncInfo(synchronizer().api());
// Simulates upgrading the client
setAppMinVersion('100.0.0');
await synchronizerStart();
// Then after sync, appMinVersion should be the same as that client version
const remoteInfoAfter = await fetchSyncInfo(synchronizer().api());
expect(remoteInfoBefore.appMinVersion).toBe('3.7.0');
expect(remoteInfoAfter.appMinVersion).toBe('100.0.0');
// Now simulates synchronising with an older client version. In that case, it should not be
// allowed and the remote info.json should not change.
setAppMinVersion('80.0.0');
await expectThrow(async () => synchronizerStart(1, { throwOnError: true }), ErrorCode.MustUpgradeApp);
expect((await fetchSyncInfo(synchronizer().api())).appMinVersion).toBe('100.0.0');
}));
});