文件
mynode/packages/lib/models/BaseItem.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

310 行
11 KiB
TypeScript

import { Second } from '@joplin/utils/time';
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, syncTargetId, synchronizerStart, msleep } from '../testing/test-utils';
import BaseItem from './BaseItem';
import Folder from './Folder';
import Note from './Note';
import ItemChange from './ItemChange';
describe('BaseItem', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
afterAll(async () => {
await afterAllCleanUp();
});
// This is to handle the case where a property is removed from a BaseItem table - in that case files in
// the sync target will still have the old property but we don't need it locally.
it('should ignore properties that are present in sync file but not in database when serialising', async () => {
const folder = await Folder.save({ title: 'folder1' });
let serialized = await Folder.serialize(folder);
serialized += '\nignore_me: true';
const unserialized = await Folder.unserialize(serialized);
expect('ignore_me' in unserialized).toBe(false);
});
it('should not modify title when unserializing', async () => {
const folder1 = await Folder.save({ title: '' });
const folder2 = await Folder.save({ title: 'folder1' });
const serialized1 = await Folder.serialize(folder1);
const unserialized1 = await Folder.unserialize(serialized1);
expect(unserialized1.title).toBe(folder1.title);
const serialized2 = await Folder.serialize(folder2);
const unserialized2 = await Folder.unserialize(serialized2);
expect(unserialized2.title).toBe(folder2.title);
});
it.each([
'',
'\n\na\nb\nc\nç\nTest!\n Testing. \n',
'Test! ☺',
'Test! ☺\n\n\n',
])('should not modify body when unserializing (body: %j)', async (body) => {
const note = await Note.save({ title: 'note1', body });
expect(await Note.unserialize(await Note.serialize(note))).toMatchObject({
body,
});
});
it('should correctly unserialize note timestamps', async () => {
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', parent_id: folder.id });
const serialized = await Note.serialize(note);
const unserialized = await Note.unserialize(serialized);
expect(unserialized.created_time).toEqual(note.created_time);
expect(unserialized.updated_time).toEqual(note.updated_time);
expect(unserialized.user_created_time).toEqual(note.user_created_time);
expect(unserialized.user_updated_time).toEqual(note.user_updated_time);
});
it('should unserialize a very large note quickly', async () => {
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', parent_id: folder.id });
const serialized = await Note.serialize({
...note,
// 2 MiB
body: '\n.'.repeat(1 * 1024 * 1024),
});
const start = performance.now();
await Note.unserialize(serialized);
// Locally, this passes in in < 2s, so 30s should be a safe upper bound.
expect(performance.now() - start).toBeLessThan(30 * Second);
});
it('should serialize geolocation fields', async () => {
const folder = await Folder.save({ title: 'folder' });
let note = await Note.save({ title: 'note', parent_id: folder.id });
note = await Note.load(note.id);
let serialized = await Note.serialize(note);
let unserialized = await Note.unserialize(serialized);
expect(unserialized.latitude).toEqual('0.00000000');
expect(unserialized.longitude).toEqual('0.00000000');
expect(unserialized.altitude).toEqual('0.0000');
await Note.save({
id: note.id,
longitude: -3.459,
altitude: 0,
latitude: 48.732,
});
note = await Note.load(note.id);
serialized = await Note.serialize(note);
unserialized = await Note.unserialize(serialized);
expect(unserialized.latitude).toEqual(note.latitude);
expect(unserialized.longitude).toEqual(note.longitude);
expect(unserialized.altitude).toEqual(note.altitude);
});
it('should serialize and unserialize notes', async () => {
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', parent_id: folder.id });
await Note.save({
id: note.id,
longitude: -3.459,
altitude: 0,
latitude: 48.732,
});
const noteBefore = await Note.load(note.id);
const serialized = await Note.serialize(noteBefore);
const noteAfter = await Note.unserialize(serialized);
expect(noteAfter).toEqual(noteBefore);
});
it('should serialize and unserialize properties that contain new lines', async () => {
const sourceUrl = `
https://joplinapp.org/ \\n
`;
const note = await Note.save({ title: 'note', source_url: sourceUrl });
const noteBefore = await Note.load(note.id);
const serialized = await Note.serialize(noteBefore);
const noteAfter = await Note.unserialize(serialized);
expect(noteAfter).toEqual(noteBefore);
});
it('should not serialize the note title and body', async () => {
const note = await Note.save({ title: 'my note', body: `one line
two line
three line \\n no escape` });
const noteBefore = await Note.load(note.id);
const serialized = await Note.serialize(noteBefore);
expect(serialized.indexOf(`my note
one line
two line
three line \\n no escape`)).toBe(0);
});
it('should update item sync item', async () => {
const note1 = await Note.save({ });
const syncTime = async (itemId: string) => {
const syncItem = await BaseItem.syncItem(syncTargetId(), itemId, { fields: ['sync_time'] });
return syncItem ? syncItem.sync_time : 0;
};
expect(await syncTime(note1.id)).toBe(0);
await synchronizerStart();
const newTime = await syncTime(note1.id);
expect(newTime).toBeLessThanOrEqual(Date.now());
// Check that it doesn't change if we sync again
await msleep(1);
await synchronizerStart();
expect(await syncTime(note1.id)).toBe(newTime);
});
it('should only sync updates to conflict notes with an original note and no share', async () => {
const originalNote = await Note.save({ title: 'Original' });
const conflictNote = await Note.createConflictNote(originalNote, ItemChange.SOURCE_SYNC);
const conflictWithoutOriginal = await Note.save({ title: 'Conflict without original', is_conflict: 1 });
const sharedConflict = await Note.save({
title: 'Shared conflict',
is_conflict: 1,
conflict_original_id: originalNote.id,
share_id: 'share-id',
});
let result = await BaseItem.itemsThatNeedSync(syncTargetId());
expect(result.items.map(item => item.id)).toContain(conflictNote.id);
expect(result.items.map(item => item.id)).not.toContain(conflictWithoutOriginal.id);
expect(result.items.map(item => item.id)).not.toContain(sharedConflict.id);
await BaseItem.saveSyncTime(syncTargetId(), conflictNote, conflictNote.updated_time);
await BaseItem.deleteOrphanSyncItems();
expect(await BaseItem.syncItem(syncTargetId(), conflictNote.id)).toBeTruthy();
await msleep(1);
await Note.save({ id: conflictNote.id, title: 'Changed conflict' });
result = await BaseItem.itemsThatNeedSync(syncTargetId());
expect(result.items.map(item => item.id)).toContain(conflictNote.id);
let changedConflict = result.items.find(item => item.id === conflictNote.id);
await BaseItem.saveSyncTime(syncTargetId(), changedConflict, changedConflict.updated_time);
await msleep(1);
await Note.delete(conflictNote.id, { toTrash: true });
result = await BaseItem.itemsThatNeedSync(syncTargetId());
expect(result.items.map(item => item.id)).toContain(conflictNote.id);
changedConflict = result.items.find(item => item.id === conflictNote.id);
await BaseItem.saveSyncTime(syncTargetId(), changedConflict, changedConflict.updated_time);
await msleep(1);
await Note.save({ id: conflictNote.id, deleted_time: 0 });
result = await BaseItem.itemsThatNeedSync(syncTargetId());
expect(result.items.map(item => item.id)).toContain(conflictNote.id);
await BaseItem.saveSyncTime(syncTargetId(), conflictWithoutOriginal, conflictWithoutOriginal.updated_time);
await BaseItem.saveSyncTime(syncTargetId(), sharedConflict, sharedConflict.updated_time);
await msleep(1);
await Note.save({ id: conflictWithoutOriginal.id, title: 'Changed without original' });
await Note.save({ id: sharedConflict.id, title: 'Changed shared conflict' });
result = await BaseItem.itemsThatNeedSync(syncTargetId());
expect(result.items.map(item => item.id)).not.toContain(conflictWithoutOriginal.id);
expect(result.items.map(item => item.id)).not.toContain(sharedConflict.id);
});
it('should not track deletions of conflict notes that are ineligible for sync', async () => {
const normalNote = await Note.save({ title: 'Normal' });
const originalNote = await Note.save({ title: 'Original' });
const eligibleConflict = await Note.createConflictNote(originalNote, ItemChange.SOURCE_SYNC);
const conflictWithoutOriginal = await Note.save({ title: 'Conflict without original', is_conflict: 1 });
const sharedConflict = await Note.save({
title: 'Shared conflict',
is_conflict: 1,
conflict_original_id: originalNote.id,
share_id: 'share-id',
});
await Note.batchDelete([
normalNote.id,
eligibleConflict.id,
conflictWithoutOriginal.id,
sharedConflict.id,
], { disableReadOnlyCheck: true });
const deletedItemIds = (await BaseItem.deletedItems(syncTargetId())).map(item => item.item_id);
expect(deletedItemIds).toContain(normalNote.id);
expect(deletedItemIds).toContain(eligibleConflict.id);
expect(deletedItemIds).not.toContain(conflictWithoutOriginal.id);
expect(deletedItemIds).not.toContain(sharedConflict.id);
});
it.each([
'test-test!',
'This ID has spaces\ttabs\nand newlines',
'Test`;',
'Test"',
'Test\'',
'Test\'\'\'a\'\'',
'% test',
])('should support querying items with IDs containing special characters (id: %j)', async (id) => {
const note = await Note.save({ id }, { isNew: true });
expect(await BaseItem.loadItemById(note.id)).toMatchObject({ id });
});
// Sync ingestion concatenates resource.id and resource.file_extension into
// a local file path; a malformed id like `../../foo` would escape the
// resource directory. unserialize() must reject these.
it.each([
'../../escape',
'../foo',
'foo/bar',
'foo\\bar',
'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ',
'short',
])('should reject items with malformed IDs during unserialize (id: %j)', async (id) => {
const serialized = `poc-resource\n\nid: ${id}\ntype_: 4`;
await expect(BaseItem.unserialize(serialized)).rejects.toMatchObject({
code: 'malformedItem',
message: expect.stringMatching(/Invalid item ID/),
});
});
it.each([
'../foo',
'foo/bar',
'foo\\bar',
'..',
])('should reject items with malformed file_extension during unserialize (ext: %j)', async (ext) => {
const serialized = `poc-resource\n\nid: 00000000000000000000000000000001\nfile_extension: ${ext}\ntype_: 4`;
await expect(BaseItem.unserialize(serialized)).rejects.toMatchObject({
code: 'malformedItem',
message: expect.stringMatching(/Invalid file extension/),
});
});
it('should accept well-formed resource items', async () => {
const serialized = 'poc-resource\n\nid: 00000000000000000000000000000001\nfile_extension: txt\ntype_: 4';
const out = await BaseItem.unserialize(serialized);
expect(out.id).toBe('00000000000000000000000000000001');
expect(out.file_extension).toBe('txt');
});
});