import * as Factory from './lib/factory.js' import { createNoteParams } from './lib/Items.js' chai.use(chaiAsPromised) const expect = chai.expect describe('history manager', () => { const largeCharacterChange = 25 let application, history, email, password const syncOptions = { checkIntegrity: true, awaitAll: true, } beforeEach(function () { localStorage.clear() }) afterEach(function () { localStorage.clear() }) describe('session', function () { beforeEach(async function () { application = await Factory.createInitAppWithFakeCrypto() history = application.dependencies.get(TYPES.HistoryManager) /** Automatically optimize after every revision by setting this to 0 */ history.itemRevisionThreshold = 0 }) afterEach(async function () { await Factory.safeDeinit(application) }) async function setTextAndSync(application, item, text) { const result = await application.changeAndSaveItem.execute( item, (mutator) => { mutator.text = text }, undefined, undefined, syncOptions, ) return result.getValue() } function deleteCharsFromString(string, amount) { return string.substring(0, string.length - amount) } it('create basic history entries 1', async function () { const item = await Factory.createSyncedNote(application) expect(history.sessionHistoryForItem(item).length).to.equal(0) /** Sync with same contents, should not create new entry */ await Factory.markDirtyAndSyncItem(application, item) expect(history.sessionHistoryForItem(item).length).to.equal(0) /** Sync with different contents, should create new entry */ await application.changeAndSaveItem.execute( item, (mutator) => { mutator.title = Math.random() }, undefined, undefined, syncOptions, ) expect(history.sessionHistoryForItem(item).length).to.equal(1) }) it('first change should create revision with previous value', async function () { const identifier = application.identifier const item = await Factory.createSyncedNote(application) /** Simulate loading new application session */ const context = await Factory.createAppContext({ identifier }) await context.launch() expect(context.history.sessionHistoryForItem(item).length).to.equal(0) await context.application.changeAndSaveItem.execute( item, (mutator) => { mutator.title = Math.random() }, undefined, undefined, syncOptions, ) const entries = context.history.sessionHistoryForItem(item) expect(entries.length).to.equal(1) expect(entries[0].payload.content.title).to.equal(item.content.title) await context.deinit() }) it('creating new item and making 1 change should create 0 revisions', async function () { const context = await Factory.createAppContext() await context.launch() const item = await context.application.items.createTemplateItem(ContentType.TYPES.Note, { references: [], }) await context.application.mutator.insertItem(item) expect(context.history.sessionHistoryForItem(item).length).to.equal(0) await context.application.changeAndSaveItem.execute( item, (mutator) => { mutator.title = Math.random() }, undefined, undefined, syncOptions, ) expect(context.history.sessionHistoryForItem(item).length).to.equal(0) await context.deinit() }) it('should optimize basic entries', async function () { let item = await Factory.createSyncedNote(application) /** * Add 1 character. This typically would be discarded as an entry, but it * won't here because it's the first change, which we want to keep. */ await setTextAndSync(application, item, item.content.text + '1') expect(history.sessionHistoryForItem(item).length).to.equal(1) /** * Changing it by one character should keep this entry, * since it's now the last (and will keep the first) */ item = await setTextAndSync(application, item, item.content.text + '2') expect(history.sessionHistoryForItem(item).length).to.equal(2) /** * Change it over the largeCharacterChange threshold. It should keep this * revision, but now remove the previous revision, since it's no longer * the last, and is a small change. */ item = await setTextAndSync(application, item, item.content.text + Factory.randomString(largeCharacterChange + 1)) expect(history.sessionHistoryForItem(item).length).to.equal(2) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(largeCharacterChange + 1)) expect(history.sessionHistoryForItem(item).length).to.equal(2) /** Delete over threshold text. */ item = await setTextAndSync(application, item, deleteCharsFromString(item.content.text, largeCharacterChange + 1)) expect(history.sessionHistoryForItem(item).length).to.equal(3) /** * Delete just 1 character. It should now retain the previous revision, as well as the * one previous to that. */ item = await setTextAndSync(application, item, deleteCharsFromString(item.content.text, 1)) expect(history.sessionHistoryForItem(item).length).to.equal(4) item = await setTextAndSync(application, item, deleteCharsFromString(item.content.text, 1)) expect(history.sessionHistoryForItem(item).length).to.equal(5) }) it('should keep the entry right before a large deletion, regardless of its delta', async function () { const payload = new DecryptedPayload( createNoteParams({ text: Factory.randomString(100), }), ) let item = await application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) await application.mutator.setItemDirty(item) await application.sync.sync(syncOptions) /** It should keep the first and last by default */ item = await setTextAndSync(application, item, item.content.text) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(1)) expect(history.sessionHistoryForItem(item).length).to.equal(2) item = await setTextAndSync(application, item, deleteCharsFromString(item.content.text, largeCharacterChange + 1)) expect(history.sessionHistoryForItem(item).length).to.equal(2) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(1)) expect(history.sessionHistoryForItem(item).length).to.equal(3) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(largeCharacterChange + 1)) expect(history.sessionHistoryForItem(item).length).to.equal(4) }) it('entries should be ordered from newest to oldest', async function () { const payload = new DecryptedPayload( createNoteParams({ text: Factory.randomString(200), }), ) let item = await application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) await application.mutator.setItemDirty(item) await application.sync.sync(syncOptions) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(1)) item = await setTextAndSync(application, item, deleteCharsFromString(item.content.text, largeCharacterChange + 1)) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(1)) item = await setTextAndSync(application, item, item.content.text + Factory.randomString(largeCharacterChange + 1)) /** First entry should be the latest revision. */ const latestRevision = history.sessionHistoryForItem(item)[0] /** Last entry should be the initial revision. */ const initialRevision = history.sessionHistoryForItem(item)[history.sessionHistoryForItem(item).length - 1] expect(latestRevision).to.not.equal(initialRevision) expect(latestRevision.textCharDiffLength).to.equal(1) expect(initialRevision.textCharDiffLength).to.equal(200) /** Finally, the latest revision updated_at value date should be more recent than the initial revision one. */ expect(latestRevision.itemFromPayload().userModifiedDate).to.be.greaterThan( initialRevision.itemFromPayload().userModifiedDate, ) }).timeout(10000) it('unsynced entries should use payload created_at for preview titles', async function () { const payload = Factory.createNotePayload() await application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) const item = application.items.findItem(payload.uuid) await application.changeAndSaveItem.execute( item, (mutator) => { mutator.title = Math.random() }, undefined, undefined, syncOptions, ) const historyItem = history.sessionHistoryForItem(item)[0] expect(historyItem.previewTitle()).to.equal(historyItem.payload.created_at.toLocaleString()) }) }) describe('remote', function () { this.timeout(Factory.TwentySecondTimeout) beforeEach(async function () { localStorage.clear() application = await Factory.createInitAppWithFakeCrypto() history = application.dependencies.get(TYPES.HistoryManager) email = UuidGenerator.GenerateUuid() password = UuidGenerator.GenerateUuid() await Factory.registerUserToApplication({ application: application, email: email, password: password, }) }) afterEach(async function () { await Factory.safeDeinit(application) localStorage.clear() }) it('response from server should be failed if not signed in', async function () { await application.user.signOut() application = await Factory.createInitAppWithFakeCrypto() history = application.dependencies.get(TYPES.HistoryManager) const item = await Factory.createSyncedNote(application) await application.sync.sync(syncOptions) const itemHistoryOrError = await application.listRevisions.execute({ itemUuid: item.uuid }) expect(itemHistoryOrError.isFailed()).to.equal(true) }) it('create basic history entries 2', async function () { const item = await Factory.createSyncedNote(application) await Factory.sleep(Factory.ServerRevisionCreationDelay) let itemHistoryOrError = await application.listRevisions.execute({ itemUuid: item.uuid }) let itemHistory = itemHistoryOrError.getValue() /** Server history should save initial revision */ expect(itemHistory.length).to.equal(1) /** Sync within 5 seconds (ENV VAR dependend on self-hosted setup), should not create a new entry */ await Factory.markDirtyAndSyncItem(application, item) itemHistoryOrError = await application.listRevisions.execute({ itemUuid: item.uuid }) itemHistory = itemHistoryOrError.getValue() expect(itemHistory.length).to.equal(1) /** Sync with different contents, should not create a new entry */ await application.changeAndSaveItem.execute( item, (mutator) => { mutator.title = Math.random() }, undefined, undefined, syncOptions, ) await Factory.sleep(Factory.ServerRevisionCreationDelay) itemHistoryOrError = await application.listRevisions.execute({ itemUuid: item.uuid }) itemHistory = itemHistoryOrError.getValue() expect(itemHistory.length).to.equal(1) }) it('returns revisions from server', async function () { let item = await Factory.createSyncedNote(application) await Factory.sleep(Factory.ServerRevisionFrequency) /** Sync with different contents, should create new entry */ const newTitleAfterFirstChange = `The title should be: ${Math.random()}` await application.changeAndSaveItem.execute( item, (mutator) => { mutator.title = newTitleAfterFirstChange }, undefined, undefined, syncOptions, ) await Factory.sleep(Factory.ServerRevisionCreationDelay) const itemHistoryOrError = await application.listRevisions.execute({ itemUuid: item.uuid }) const itemHistory = itemHistoryOrError.getValue() expect(itemHistory.length).to.equal(2) const oldestEntry = lastElement(itemHistory) let revisionFromServerOrError = await application.getRevision.execute({ itemUuid: item.uuid, revisionUuid: oldestEntry.uuid, }) const revisionFromServer = revisionFromServerOrError.getValue() expect(revisionFromServer).to.be.ok let payloadFromServer = revisionFromServer.payload expect(payloadFromServer.errorDecrypting).to.be.undefined expect(payloadFromServer.uuid).to.eq(item.payload.uuid) expect(payloadFromServer.content).to.eql(item.payload.content) item = application.items.findItem(item.uuid) expect(payloadFromServer.content).to.not.eql(item.payload.content) }) it('duplicate revisions should not have the originals uuid', async function () { const note = await Factory.createSyncedNote(application) await Factory.markDirtyAndSyncItem(application, note) const dupe = await application.mutator.duplicateItem(note, true) await Factory.markDirtyAndSyncItem(application, dupe) await Factory.sleep(Factory.ServerRevisionCreationDelay) const dupeHistoryOrError = await application.listRevisions.execute({ itemUuid: dupe.uuid }) const dupeHistory = dupeHistoryOrError.getValue() const dupeRevisionOrError = await application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: dupeHistory[0].uuid, }) const dupeRevision = dupeRevisionOrError.getValue() expect(dupeRevision.payload.uuid).to.equal(dupe.uuid) }) it('revisions count matches original for duplicated items', async function () { const note = await Factory.createSyncedNote(application) await Factory.sleep(Factory.ServerRevisionFrequency) await Factory.markDirtyAndSyncItem(application, note) await Factory.sleep(Factory.ServerRevisionFrequency) await Factory.markDirtyAndSyncItem(application, note) await Factory.sleep(Factory.ServerRevisionFrequency) await Factory.markDirtyAndSyncItem(application, note) const dupe = await application.mutator.duplicateItem(note, true) await Factory.markDirtyAndSyncItem(application, dupe) await Factory.sleep(Factory.ServerRevisionCreationDelay) const expectedRevisions = 4 const noteHistoryOrError = await application.listRevisions.execute({ itemUuid: note.uuid }) const noteHistory = noteHistoryOrError.getValue() const dupeHistoryOrError = await application.listRevisions.execute({ itemUuid: dupe.uuid }) const dupeHistory = dupeHistoryOrError.getValue() expect(noteHistory.length).to.equal(expectedRevisions) expect(dupeHistory.length).to.equal(expectedRevisions + 1) }).timeout(Factory.SixtySecondTimeout) it('can decrypt revisions for duplicate_of items', async function () { const note = await Factory.createSyncedNote(application) await Factory.sleep(Factory.ServerRevisionFrequency) const changedText = `${Math.random()}` await application.changeAndSaveItem.execute(note, (mutator) => { mutator.title = changedText }) await Factory.markDirtyAndSyncItem(application, note) const dupe = await application.mutator.duplicateItem(note, true) await Factory.markDirtyAndSyncItem(application, dupe) await Factory.sleep(Factory.ServerRevisionCreationDelay) const itemHistoryOrError = await application.listRevisions.execute({ itemUuid: dupe.uuid }) const itemHistory = itemHistoryOrError.getValue() expect(itemHistory.length).to.be.above(1) const newestRevision = itemHistory[0] const fetchedOrError = await application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: newestRevision.uuid, }) const fetched = fetchedOrError.getValue() expect(fetched.payload.errorDecrypting).to.not.be.ok expect(fetched.payload.content.title).to.equal(changedText) }) }) })