文件
mynode/packages/lib/services/synchronizer/Synchronizer.baseFields.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

284 行
11 KiB
TypeScript

import { synchronizerStart, setupDatabaseAndSynchronizer, switchClient, syncTargetId, loadEncryptionMasterKey, decryptionWorker, encryptionService } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import BaseItem from '../../models/BaseItem';
import ConflictNoteState from '../../models/ConflictNoteState';
import Setting from '../../models/Setting';
import { setEncryptionEnabled } from './syncInfoUtils';
import { loadMasterKeysFromSettings } from '../e2ee/utils';
// Creates a conflict on client 1 for the given note: client 2 edits and syncs first
// (becoming the remote/winning version), then client 1 edits and syncs, which is when
// the conflict is detected and resolved locally on client 1.
const makeConflictOnClient1 = async (noteId: string, client2Body: string, client1Body: string) => {
await switchClient(2);
await Note.save({ id: noteId, body: client2Body });
await synchronizerStart();
await switchClient(1);
await Note.save({ id: noteId, body: client1Body });
await synchronizerStart();
};
describe('Synchronizer.baseFields', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
});
it('should record base_body and base_title after a clean upload', (async () => {
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'My title', body: 'My body', parent_id: folder.id });
await synchronizerStart();
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_title', 'base_conflict_note_id'] });
expect(syncItem.base_title).toBe('My title');
expect(syncItem.base_body).toBe('My body');
expect(syncItem.base_conflict_note_id).toBe('');
}));
it('should clear base_conflict_note_id when a note is cleanly uploaded again', (async () => {
const note = await Note.save({ title: 'title', body: 'body' });
await synchronizerStart();
// Simulate a previously recorded conflict link
await Note.setBaseConflictNoteId(syncTargetId(), note.id, 'some-old-conflict-id');
await Note.save({ id: note.id, body: 'body changed' });
await synchronizerStart();
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_conflict_note_id'] });
expect(syncItem.base_body).toBe('body changed');
expect(syncItem.base_conflict_note_id).toBe('');
}));
it('should link the original note to its conflict note when a conflict is resolved', (async () => {
const note = await Note.save({ title: 'title', body: 'base body' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await makeConflictOnClient1(note.id, 'remote body', 'local body');
const conflictedNotes = await Note.conflictedNotes();
expect(conflictedNotes.length).toBe(1);
const conflictNote = conflictedNotes[0];
// The link must survive the remote-overwrite step, which rebuilds the sync_items row.
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_conflict_note_id'] });
expect(syncItem.base_conflict_note_id).toBe(conflictNote.id);
}));
it('should record the base content in conflict_note_states', (async () => {
const note = await Note.save({ title: 'base title', body: 'base body' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await makeConflictOnClient1(note.id, 'remote body', 'local body');
const conflictNote = (await Note.conflictedNotes())[0];
const state = await ConflictNoteState.byNoteId(conflictNote.id);
expect(state.base_body).toBe('base body');
expect(state.base_title).toBe('base title');
expect(state.remote_updated_time).toBeGreaterThan(0);
}));
it('should delete the conflict state when its conflict note is deleted', (async () => {
const note = await Note.save({ title: 'base title', body: 'base body' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await makeConflictOnClient1(note.id, 'remote body', 'local body');
const conflictNote = (await Note.conflictedNotes())[0];
expect(await ConflictNoteState.byNoteId(conflictNote.id)).toBeTruthy();
await Note.delete(conflictNote.id, { toTrash: false });
expect(await ConflictNoteState.byNoteId(conflictNote.id)).toBeFalsy();
// The original note keeps working, it has no state row of its own
expect(await Note.load(note.id)).toBeTruthy();
}));
it('should record the base on download so a receive-only client has an ancestor', (async () => {
const note = await Note.save({ title: 'My title', body: 'My body' });
await synchronizerStart();
// Client 2 only ever receives this note, so without recording on download it
// would have no base at all
await switchClient(2);
await synchronizerStart();
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_title'] });
expect(syncItem.base_title).toBe('My title');
expect(syncItem.base_body).toBe('My body');
}));
it('should update the base on download when a note changes remotely', (async () => {
const note = await Note.save({ title: 'title', body: 'first' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await Note.save({ id: note.id, body: 'second' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body'] });
expect(syncItem.base_body).toBe('second');
}));
it('should let a receive-only client auto-merge instead of conflicting', (async () => {
const note = await Note.save({ title: 'title', body: 'a\nb\nc' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await Note.save({ id: note.id, body: 'a1\nb\nc' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await Note.save({ id: note.id, body: 'a1\nb\nc2' });
await synchronizerStart();
await switchClient(2);
await Note.save({ id: note.id, body: 'a12\nb\nc' });
await synchronizerStart();
expect(await Note.conflictedNotes()).toEqual([]);
expect((await Note.load(note.id)).body).toBe('a12\nb\nc2');
}));
it('should record the base only once an encrypted note has been decrypted', (async () => {
setEncryptionEnabled(true);
const masterKey = await loadEncryptionMasterKey();
const note = await Note.save({ title: 'My title', body: 'My body' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
// The note arrives encrypted, so there is no readable body to record yet
let syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_title'] });
expect(syncItem.base_body).toBe('');
expect(syncItem.base_title).toBe('');
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456');
await loadMasterKeysFromSettings(encryptionService());
await decryptionWorker().start();
syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_title'] });
expect(syncItem.base_title).toBe('My title');
expect(syncItem.base_body).toBe('My body');
}));
it('should not overwrite the base snapshot during a conflict', (async () => {
const note = await Note.save({ title: 'title', body: 'original base' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await makeConflictOnClient1(note.id, 'remote body', 'local body');
// conflict_note_states keeps the common ancestor, not either side's edit
const conflictNote = (await Note.conflictedNotes())[0];
const state = await ConflictNoteState.byNoteId(conflictNote.id);
expect(state.base_body).toBe('original base');
expect(state.base_body).not.toBe('remote body');
expect(state.base_body).not.toBe('local body');
// The sync item is updated to the version both sides now share,
// so future conflicts use the latest shared version as the base.
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body'] });
expect(syncItem.base_body).toBe('remote body');
}));
it('should not bring back a line both clients deleted', (async () => {
const note = await Note.save({ title: 'title', body: 'one\ntwo\nthree\nfour' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
// Both delete "two", and one side also edits elsewhere so the merge still runs
await switchClient(1);
await Note.save({ id: note.id, body: 'one\nthree\nfour EDITED' });
await switchClient(2);
await Note.save({ id: note.id, body: 'one\nthree\nfour' });
await switchClient(1);
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
expect(await Note.conflictedNotes()).toEqual([]);
expect((await Note.load(note.id)).body).toBe('one\nthree\nfour EDITED');
}));
it('should set the merged result as the base after an auto-merge', (async () => {
const note = await Note.save({ title: 'title', body: 'line1\nline2\nline3' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await makeConflictOnClient1(note.id, 'CHANGED1\nline2\nline3', 'line1\nline2\nCHANGED3');
const merged = await Note.load(note.id);
expect(merged.body).toBe('CHANGED1\nline2\nCHANGED3');
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body'] });
expect(syncItem.base_body).toBe(merged.body);
}));
it('should clear the base while a conflicting note is still encrypted', (async () => {
setEncryptionEnabled(true);
const masterKey = await loadEncryptionMasterKey();
const note = await Note.save({ title: 'title', body: 'original base' });
await synchronizerStart();
// Client 2 has no master key, so it holds the note encrypted
await switchClient(2);
await synchronizerStart();
await switchClient(1);
await Note.save({ id: note.id, body: 'client 1 edit' });
await synchronizerStart();
// The conflict is handled on client 2 while the note is still unreadable
await switchClient(2);
await Note.save({ id: note.id, body: 'client 2 edit' });
await synchronizerStart();
// Without readable content, the base cannot be written, so it is cleared until
// the note is decrypted.
let syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body'] });
expect(syncItem.base_body).toBe('');
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456');
await loadMasterKeysFromSettings(encryptionService());
await decryptionWorker().start();
syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body'] });
expect(syncItem.base_body).toBe((await Note.load(note.id)).body);
}));
});