1075 lines
37 KiB
JavaScript
1075 lines
37 KiB
JavaScript
import { BaseItemCounts } from '../lib/BaseItemCounts.js'
|
|
import * as Factory from '../lib/factory.js'
|
|
import * as Utils from '../lib/Utils.js'
|
|
|
|
chai.use(chaiAsPromised)
|
|
const expect = chai.expect
|
|
|
|
describe('online syncing', function () {
|
|
this.timeout(Factory.TenSecondTimeout)
|
|
|
|
let application
|
|
let email
|
|
let password
|
|
let expectedItemCount
|
|
let context
|
|
|
|
const syncOptions = {
|
|
checkIntegrity: true,
|
|
awaitAll: true,
|
|
}
|
|
|
|
beforeEach(async function () {
|
|
localStorage.clear()
|
|
expectedItemCount = BaseItemCounts.DefaultItemsWithAccount
|
|
|
|
context = await Factory.createAppContext()
|
|
await context.launch()
|
|
|
|
application = context.application
|
|
email = context.email
|
|
password = context.password
|
|
|
|
Factory.disableIntegrityAutoHeal(application)
|
|
|
|
await Factory.registerUserToApplication({
|
|
application: application,
|
|
email: email,
|
|
password: password,
|
|
})
|
|
})
|
|
|
|
afterEach(async function () {
|
|
expect(application.sync.isOutOfSync()).to.equal(false)
|
|
|
|
const items = application.items.allTrackedItems()
|
|
expect(items.length).to.equal(expectedItemCount)
|
|
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
expect(rawPayloads.length).to.equal(expectedItemCount)
|
|
await Factory.safeDeinit(application)
|
|
localStorage.clear()
|
|
|
|
application = undefined
|
|
context = undefined
|
|
})
|
|
|
|
function noteObjectsFromObjects(items) {
|
|
return items.filter((item) => item.content_type === ContentType.TYPES.Note)
|
|
}
|
|
|
|
it('should register and sync basic model online', async function () {
|
|
let note = await Factory.createSyncedNote(application)
|
|
expectedItemCount++
|
|
expect(application.items.getDirtyItems().length).to.equal(0)
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.dirty).to.not.be.ok
|
|
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
const notePayloads = noteObjectsFromObjects(rawPayloads)
|
|
expect(notePayloads.length).to.equal(1)
|
|
for (const rawNote of notePayloads) {
|
|
expect(rawNote.dirty).to.not.be.ok
|
|
}
|
|
})
|
|
|
|
it('should login and retrieve synced item', async function () {
|
|
const note = await Factory.createSyncedNote(application)
|
|
expectedItemCount++
|
|
application = await Factory.signOutApplicationAndReturnNew(application)
|
|
|
|
await Factory.loginToApplication({
|
|
application: application,
|
|
email: email,
|
|
password: password,
|
|
})
|
|
|
|
const notes = application.items.getDisplayableNotes()
|
|
expect(notes.length).to.equal(1)
|
|
expect(notes[0].title).to.equal(note.title)
|
|
})
|
|
|
|
it('can complete multipage sync on sign in', async function () {
|
|
const count = 0
|
|
|
|
await Factory.createManyMappedNotes(application, count)
|
|
|
|
expectedItemCount += count
|
|
|
|
await application.sync.sync(syncOptions)
|
|
|
|
application = await context.signout()
|
|
|
|
expect(application.items.items.length).to.equal(BaseItemCounts.DefaultItems)
|
|
|
|
const promise = Factory.loginToApplication({
|
|
application: application,
|
|
email: email,
|
|
password: password,
|
|
})
|
|
|
|
/** Throw in some random syncs to cause trouble */
|
|
const syncCount = 30
|
|
|
|
for (let i = 0; i < syncCount; i++) {
|
|
application.sync.sync(syncOptions)
|
|
await Factory.sleep(0.01)
|
|
}
|
|
await promise
|
|
expect(promise).to.be.fulfilled
|
|
|
|
/** Allow any unwaited syncs in for loop to complete */
|
|
await Factory.sleep(0.5)
|
|
}).timeout(20000)
|
|
|
|
it('having offline data then signing in should not alternate uuid and merge with account', async function () {
|
|
application = await Factory.signOutApplicationAndReturnNew(application)
|
|
const note = await Factory.createMappedNote(application)
|
|
expectedItemCount++
|
|
await Factory.loginToApplication({
|
|
application: application,
|
|
email: email,
|
|
password: password,
|
|
mergeLocal: true,
|
|
})
|
|
|
|
const notes = application.items.getDisplayableNotes()
|
|
expect(notes.length).to.equal(1)
|
|
/** uuid should have been alternated */
|
|
expect(notes[0].uuid).to.equal(note.uuid)
|
|
})
|
|
|
|
it('resolve on next timing strategy', async function () {
|
|
const syncCount = 7
|
|
let successes = 0
|
|
let events = 0
|
|
|
|
application.sync.ut_beginLatencySimulator(250)
|
|
application.sync.addEventObserver((event, data) => {
|
|
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) {
|
|
events++
|
|
}
|
|
})
|
|
|
|
const promises = []
|
|
for (let i = 0; i < syncCount; i++) {
|
|
promises.push(
|
|
application.sync
|
|
.sync({
|
|
queueStrategy: SyncQueueStrategy.ResolveOnNext,
|
|
})
|
|
.then(() => {
|
|
successes++
|
|
}),
|
|
)
|
|
}
|
|
|
|
await Promise.all(promises)
|
|
expect(successes).to.equal(syncCount)
|
|
// Only a fully executed sync request creates a sync:completed event.
|
|
// We don't know how many will execute above.
|
|
expect(events).to.be.at.least(1)
|
|
|
|
application.sync.ut_endLatencySimulator()
|
|
// Since the syncs all happen after one another, extra syncs may be queued on that we are not awaiting.
|
|
await Factory.sleep(0.5)
|
|
})
|
|
|
|
it('force spawn new timing strategy', async function () {
|
|
const syncCount = 7
|
|
let successes = 0
|
|
let events = 0
|
|
|
|
application.sync.ut_beginLatencySimulator(250)
|
|
|
|
application.sync.addEventObserver((event, data) => {
|
|
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) {
|
|
events++
|
|
}
|
|
})
|
|
|
|
const promises = []
|
|
for (let i = 0; i < syncCount; i++) {
|
|
promises.push(
|
|
application.sync
|
|
.sync({
|
|
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
|
})
|
|
.then(() => {
|
|
successes++
|
|
}),
|
|
)
|
|
}
|
|
await Promise.all(promises)
|
|
expect(successes).to.equal(syncCount)
|
|
expect(events).to.equal(syncCount)
|
|
application.sync.ut_endLatencySimulator()
|
|
})
|
|
|
|
it('retrieving new items should not mark them as dirty', async function () {
|
|
const originalNote = await Factory.createSyncedNote(application)
|
|
expectedItemCount++
|
|
|
|
application = await Factory.signOutApplicationAndReturnNew(application)
|
|
const promise = new Promise((resolve) => {
|
|
application.sync.addEventObserver(async (event) => {
|
|
if (event === SyncEvent.PaginatedSyncRequestCompleted) {
|
|
const note = application.items.findItem(originalNote.uuid)
|
|
if (note) {
|
|
expect(note.dirty).to.not.be.ok
|
|
resolve()
|
|
}
|
|
}
|
|
})
|
|
})
|
|
await application.signIn(email, password, undefined, undefined, undefined, true)
|
|
await promise
|
|
})
|
|
|
|
it('allows saving of data after sign out', async function () {
|
|
expect(application.items.getDisplayableItemsKeys().length).to.equal(1)
|
|
application = await Factory.signOutApplicationAndReturnNew(application)
|
|
expect(application.items.getDisplayableItemsKeys().length).to.equal(1)
|
|
const note = await Factory.createMappedNote(application)
|
|
expectedItemCount++
|
|
await application.mutator.setItemDirty(note)
|
|
await application.sync.sync(syncOptions)
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
const notePayload = noteObjectsFromObjects(rawPayloads)
|
|
expect(notePayload.length).to.equal(1)
|
|
expect(application.items.getDisplayableNotes().length).to.equal(1)
|
|
|
|
// set item to be merged for when sign in occurs
|
|
await application.sync.markAllItemsAsNeedingSyncAndPersist()
|
|
expect(application.sync.isOutOfSync()).to.equal(false)
|
|
expect(application.items.getDirtyItems().length).to.equal(BaseItemCounts.DefaultItems + 1)
|
|
|
|
// Sign back in for next tests
|
|
await Factory.loginToApplication({
|
|
application: application,
|
|
email: email,
|
|
password: password,
|
|
})
|
|
|
|
expect(application.items.getDirtyItems().length).to.equal(0)
|
|
expect(application.items.getDisplayableItemsKeys().length).to.equal(1)
|
|
expect(application.sync.isOutOfSync()).to.equal(false)
|
|
expect(application.items.getDisplayableNotes().length).to.equal(1)
|
|
|
|
for (const item of application.items.getDisplayableNotes()) {
|
|
expect(item.content.title).to.be.ok
|
|
}
|
|
|
|
const updatedRawPayloads = await application.storage.getAllRawPayloads()
|
|
for (const payload of updatedRawPayloads) {
|
|
// if an item comes back from the server, it is saved to disk immediately without a dirty value.
|
|
expect(payload.dirty).to.not.be.ok
|
|
}
|
|
})
|
|
|
|
it('mapping should not mutate items with error decrypting state', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
|
|
expectedItemCount++
|
|
|
|
const originalTitle = note.content.title
|
|
|
|
await application.mutator.setItemDirty(note)
|
|
await application.sync.sync(syncOptions)
|
|
|
|
const encrypted = CreateEncryptedServerSyncPushPayload(
|
|
await application.encryption.encryptSplitSingle({
|
|
usesItemsKeyWithKeyLookup: {
|
|
items: [note.payloadRepresentation()],
|
|
},
|
|
}),
|
|
)
|
|
|
|
const errorred = new EncryptedPayload({
|
|
...encrypted,
|
|
errorDecrypting: true,
|
|
})
|
|
|
|
const items = await application.mutator.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged)
|
|
|
|
const mappedItem = application.items.findAnyItem(errorred.uuid)
|
|
|
|
expect(typeof mappedItem.content).to.equal('string')
|
|
|
|
const decryptedPayload = await application.encryption.decryptSplitSingle({
|
|
usesItemsKeyWithKeyLookup: {
|
|
items: [errorred],
|
|
},
|
|
})
|
|
|
|
const mappedItems2 = await application.mutator.emitItemsFromPayloads(
|
|
[decryptedPayload],
|
|
PayloadEmitSource.LocalChanged,
|
|
)
|
|
|
|
const mappedItem2 = mappedItems2[0]
|
|
expect(typeof mappedItem2.content).to.equal('object')
|
|
expect(mappedItem2.content.title).to.equal(originalTitle)
|
|
})
|
|
|
|
it('signing into account with pre-existing items', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
await Factory.markDirtyAndSyncItem(application, note)
|
|
expectedItemCount += 1
|
|
|
|
application = await Factory.signOutApplicationAndReturnNew(application)
|
|
await application.signIn(email, password, undefined, undefined, undefined, true)
|
|
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
})
|
|
|
|
it('removes item from storage upon deletion', async function () {
|
|
let note = await Factory.createMappedNote(application)
|
|
expectedItemCount++
|
|
|
|
await application.mutator.setItemDirty(note)
|
|
await application.sync.sync(syncOptions)
|
|
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.dirty).to.equal(false)
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
|
|
await application.mutator.setItemToBeDeleted(note)
|
|
note = application.items.findAnyItem(note.uuid)
|
|
expect(note.dirty).to.equal(true)
|
|
expectedItemCount--
|
|
|
|
await application.sync.sync(syncOptions)
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note).to.not.be.ok
|
|
|
|
// We expect that this item is now gone for good, and no duplicate has been created.
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
await Factory.sleep(0.5)
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
expect(rawPayloads.length).to.equal(expectedItemCount)
|
|
})
|
|
|
|
it('retrieving item with no content should correctly map local state', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
await application.sync.sync(syncOptions)
|
|
|
|
const syncToken = await application.sync.getLastSyncToken()
|
|
|
|
expectedItemCount++
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
|
|
// client A
|
|
await application.mutator.setItemToBeDeleted(note)
|
|
await application.sync.sync(syncOptions)
|
|
|
|
// Subtract 1
|
|
expectedItemCount--
|
|
|
|
// client B
|
|
// Clearing sync tokens wont work as server wont return deleted items.
|
|
// Set saved sync token instead
|
|
await application.sync.setLastSyncToken(syncToken)
|
|
await application.sync.sync(syncOptions)
|
|
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
})
|
|
|
|
it('changing an item while it is being synced should sync again', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
|
|
expectedItemCount++
|
|
|
|
/** Begin syncing it with server but introduce latency so we can sneak in a delete */
|
|
application.sync.ut_beginLatencySimulator(500)
|
|
|
|
const sync = application.sync.sync()
|
|
|
|
/** Sleep so sync call can begin preparations but not fully begin */
|
|
|
|
await Factory.sleep(0.1)
|
|
|
|
await application.mutator.changeItem(note, (mutator) => {
|
|
mutator.title = 'latest title'
|
|
})
|
|
|
|
await sync
|
|
|
|
application.sync.ut_endLatencySimulator()
|
|
|
|
await application.sync.sync(syncOptions)
|
|
|
|
const latestNote = application.items.findItem(note.uuid)
|
|
expect(latestNote.title).to.equal('latest title')
|
|
})
|
|
|
|
it('deleting an item while it is being synced should keep deletion state', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
|
|
expectedItemCount++
|
|
|
|
/** Begin syncing it with server but introduce latency so we can sneak in a delete */
|
|
application.sync.ut_beginLatencySimulator(500)
|
|
|
|
const sync = application.sync.sync()
|
|
|
|
/** Sleep so sync call can begin preparations but not fully begin */
|
|
|
|
await Factory.sleep(0.1)
|
|
|
|
await application.mutator.setItemToBeDeleted(note)
|
|
|
|
expectedItemCount--
|
|
|
|
await sync
|
|
|
|
application.sync.ut_endLatencySimulator()
|
|
|
|
await application.sync.sync(syncOptions)
|
|
|
|
/** We expect that item has been deleted */
|
|
const allItems = application.items.items
|
|
expect(allItems.length).to.equal(expectedItemCount)
|
|
})
|
|
|
|
it('items that are never synced and deleted should not be uploaded to server', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
await application.mutator.setItemToBeDeleted(note)
|
|
|
|
let success = true
|
|
let didCompleteRelevantSync = false
|
|
let beginCheckingResponse = false
|
|
application.sync.addEventObserver((eventName, data) => {
|
|
if (eventName === SyncEvent.DownloadFirstSyncCompleted) {
|
|
beginCheckingResponse = true
|
|
}
|
|
if (!beginCheckingResponse) {
|
|
return
|
|
}
|
|
if (!didCompleteRelevantSync && eventName === SyncEvent.PaginatedSyncRequestCompleted) {
|
|
didCompleteRelevantSync = true
|
|
const response = data
|
|
const matching = response.savedPayloads.find((p) => p.uuid === note.uuid)
|
|
if (matching) {
|
|
success = false
|
|
}
|
|
}
|
|
})
|
|
await application.sync.sync({ mode: SyncMode.DownloadFirst })
|
|
expect(didCompleteRelevantSync).to.equal(true)
|
|
expect(success).to.equal(true)
|
|
})
|
|
|
|
it('items that are deleted after download first sync complete should not be uploaded to server', async function () {
|
|
/** The singleton manager may delete items are download first. We dont want those uploaded to server. */
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
|
|
let success = true
|
|
let didCompleteRelevantSync = false
|
|
let beginCheckingResponse = false
|
|
application.sync.addEventObserver(async (eventName, data) => {
|
|
if (eventName === SyncEvent.DownloadFirstSyncCompleted) {
|
|
await application.mutator.setItemToBeDeleted(note)
|
|
beginCheckingResponse = true
|
|
}
|
|
if (!beginCheckingResponse) {
|
|
return
|
|
}
|
|
if (!didCompleteRelevantSync && eventName === SyncEvent.PaginatedSyncRequestCompleted) {
|
|
didCompleteRelevantSync = true
|
|
const response = data
|
|
const matching = response.savedPayloads.find((p) => p.uuid === note.uuid)
|
|
if (matching) {
|
|
success = false
|
|
}
|
|
}
|
|
})
|
|
await application.sync.sync({ mode: SyncMode.DownloadFirst })
|
|
expect(didCompleteRelevantSync).to.equal(true)
|
|
expect(success).to.equal(true)
|
|
})
|
|
|
|
it('marking an item dirty then saving to disk should retain that dirty state when restored', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
|
|
expectedItemCount++
|
|
|
|
await application.sync.markAllItemsAsNeedingSyncAndPersist()
|
|
|
|
application.items.resetState()
|
|
application.payloads.resetState()
|
|
|
|
await application.sync.clearSyncPositionTokens()
|
|
|
|
expect(application.items.items.length).to.equal(0)
|
|
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
|
|
const encryptedPayloads = rawPayloads.map((rawPayload) => {
|
|
return new EncryptedPayload(rawPayload)
|
|
})
|
|
|
|
const encryptionSplit = SplitPayloadsByEncryptionType(encryptedPayloads)
|
|
|
|
const keyedSplit = CreateDecryptionSplitWithKeyLookup(encryptionSplit)
|
|
|
|
const decryptionResults = await application.encryption.decryptSplit(keyedSplit)
|
|
|
|
await application.mutator.emitItemsFromPayloads(decryptionResults, PayloadEmitSource.LocalChanged)
|
|
|
|
expect(application.items.allTrackedItems().length).to.equal(expectedItemCount)
|
|
|
|
const foundNote = application.items.findAnyItem(note.uuid)
|
|
|
|
expect(foundNote.dirty).to.equal(true)
|
|
|
|
await application.sync.sync(syncOptions)
|
|
})
|
|
|
|
/** This test takes 30s+ on a Docker server environment and should be skipped for now */
|
|
it.skip('should handle uploading with sync pagination', async function () {
|
|
const largeItemCount = SyncUpDownLimit + 10
|
|
for (let i = 0; i < largeItemCount; i++) {
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
}
|
|
|
|
expectedItemCount += largeItemCount
|
|
|
|
await application.sync.sync(syncOptions)
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
expect(rawPayloads.length).to.equal(expectedItemCount)
|
|
}).timeout(Factory.SixtySecondTimeout)
|
|
|
|
/** This test takes 30s+ on a Docker server environment and should be skipped for now */
|
|
it.skip('should handle downloading with sync pagination', async function () {
|
|
const largeItemCount = SyncUpDownLimit + 10
|
|
for (let i = 0; i < largeItemCount; i++) {
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
}
|
|
/** Upload */
|
|
application.sync.sync({ awaitAll: true, checkIntegrity: false })
|
|
await context.awaitNextSucessfulSync()
|
|
expectedItemCount += largeItemCount
|
|
|
|
/** Clear local data */
|
|
await application.payloads.resetState()
|
|
await application.items.resetState()
|
|
await application.sync.clearSyncPositionTokens()
|
|
await application.storage.clearAllPayloads()
|
|
expect(application.items.items.length).to.equal(0)
|
|
|
|
/** Download all data */
|
|
application.sync.sync(syncOptions)
|
|
await context.awaitNextSucessfulSync()
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
expect(rawPayloads.length).to.equal(expectedItemCount)
|
|
}).timeout(Factory.SixtySecondTimeout)
|
|
|
|
it('syncing an item should storage it encrypted', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
await application.sync.sync(syncOptions)
|
|
expectedItemCount++
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
const notePayload = rawPayloads.find((p) => p.content_type === ContentType.TYPES.Note)
|
|
expect(typeof notePayload.content).to.equal('string')
|
|
})
|
|
|
|
it('syncing an item before data load should storage it encrypted', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
expectedItemCount++
|
|
|
|
/** Simulate database not loaded */
|
|
await application.sync.clearSyncPositionTokens()
|
|
application.sync.ut_setDatabaseLoaded(false)
|
|
application.sync.sync(syncOptions)
|
|
await Factory.sleep(0.3)
|
|
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
const notePayload = rawPayloads.find((p) => p.content_type === ContentType.TYPES.Note)
|
|
expect(typeof notePayload.content).to.equal('string')
|
|
})
|
|
|
|
it('saving an item after sync should persist it with content property', async function () {
|
|
const note = await Factory.createMappedNote(application)
|
|
const text = Factory.randomString(10000)
|
|
await application.changeAndSaveItem.execute(
|
|
note,
|
|
(mutator) => {
|
|
mutator.text = text
|
|
},
|
|
undefined,
|
|
undefined,
|
|
syncOptions,
|
|
)
|
|
expectedItemCount++
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
const notePayload = rawPayloads.find((p) => p.content_type === ContentType.TYPES.Note)
|
|
expect(typeof notePayload.content).to.equal('string')
|
|
expect(notePayload.content.length).to.be.above(text.length)
|
|
})
|
|
|
|
it('syncing a new item before local data has loaded should still persist the item to disk', async function () {
|
|
application.sync.ut_setDatabaseLoaded(false)
|
|
/** You don't want to clear model manager state as we'll lose encrypting items key */
|
|
// await application.payloads.resetState();
|
|
await application.sync.clearSyncPositionTokens()
|
|
expect(application.items.getDirtyItems().length).to.equal(0)
|
|
|
|
let note = await Factory.createMappedNote(application)
|
|
note = await application.mutator.changeItem(note, (mutator) => {
|
|
mutator.text = `${Math.random()}`
|
|
})
|
|
/** This sync request should exit prematurely as we called ut_setDatabaseNotLoaded */
|
|
/** Do not await. Sleep instead. */
|
|
application.sync.sync(syncOptions)
|
|
await Factory.sleep(0.3)
|
|
expectedItemCount++
|
|
|
|
/** Item should still be dirty */
|
|
expect(note.dirty).to.equal(true)
|
|
expect(application.items.getDirtyItems().length).to.equal(1)
|
|
|
|
const rawPayloads = await application.storage.getAllRawPayloads()
|
|
expect(rawPayloads.length).to.equal(expectedItemCount)
|
|
const rawPayload = rawPayloads.find((p) => p.uuid === note.uuid)
|
|
expect(rawPayload.uuid).to.equal(note.uuid)
|
|
expect(rawPayload.dirty).equal(true)
|
|
expect(typeof rawPayload.content).to.equal('string')
|
|
|
|
/** Clear state data and upload item from storage to server */
|
|
await application.sync.clearSyncPositionTokens()
|
|
await application.payloads.resetState()
|
|
await application.items.resetState()
|
|
await application.sync.loadDatabasePayloads()
|
|
await application.sync.sync(syncOptions)
|
|
|
|
const newRawPayloads = await application.storage.getAllRawPayloads()
|
|
expect(newRawPayloads.length).to.equal(expectedItemCount)
|
|
|
|
const currentItem = application.items.findItem(note.uuid)
|
|
expect(currentItem.content.text).to.equal(note.content.text)
|
|
expect(currentItem.text).to.equal(note.text)
|
|
expect(currentItem.dirty).to.not.be.ok
|
|
})
|
|
|
|
it('load local items should respect sort priority', function () {
|
|
const contentTypes = ['A', 'B', 'C']
|
|
const itemCount = 6
|
|
const originalPayloads = []
|
|
for (let i = 0; i < itemCount; i++) {
|
|
const payload = Factory.createStorageItemPayload(contentTypes[Math.floor(i / 2)])
|
|
originalPayloads.push(payload)
|
|
}
|
|
const { contentTypePriorityPayloads } = GetSortedPayloadsByPriority(originalPayloads, {
|
|
contentTypePriority: ['C', 'A', 'B'],
|
|
})
|
|
expect(contentTypePriorityPayloads[0].content_type).to.equal('C')
|
|
expect(contentTypePriorityPayloads[2].content_type).to.equal('A')
|
|
expect(contentTypePriorityPayloads[4].content_type).to.equal('B')
|
|
})
|
|
|
|
it('should sign in and retrieve large number of items', async function () {
|
|
const largeItemCount = 50
|
|
await Factory.createManyMappedNotes(application, largeItemCount)
|
|
expectedItemCount += largeItemCount
|
|
await application.sync.sync(syncOptions)
|
|
|
|
application = await Factory.signOutApplicationAndReturnNew(application)
|
|
await application.signIn(email, password, undefined, undefined, undefined, true)
|
|
|
|
application.sync.ut_setDatabaseLoaded(false)
|
|
await application.sync.loadDatabasePayloads()
|
|
await application.sync.sync(syncOptions)
|
|
|
|
const items = await application.items.items
|
|
expect(items.length).to.equal(expectedItemCount)
|
|
}).timeout(20000)
|
|
|
|
it('valid sync date tracking', async function () {
|
|
let note = await Factory.createMappedNote(application)
|
|
note = await application.mutator.setItemDirty(note)
|
|
expectedItemCount++
|
|
|
|
expect(note.dirty).to.equal(true)
|
|
expect(note.payload.dirtyIndex).to.be.at.most(getCurrentDirtyIndex())
|
|
|
|
note = await application.mutator.changeItem(note, (mutator) => {
|
|
mutator.text = `${Math.random()}`
|
|
})
|
|
const sync = application.sync.sync(syncOptions)
|
|
await Factory.sleep(0.1)
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.lastSyncBegan).to.be.below(new Date())
|
|
await sync
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.dirty).to.equal(false)
|
|
expect(note.lastSyncEnd).to.be.at.least(note.lastSyncBegan)
|
|
})
|
|
|
|
it('syncing twice without waiting should only execute 1 online sync', async function () {
|
|
const expectedEvents = 1
|
|
let actualEvents = 0
|
|
application.sync.addEventObserver((event, data) => {
|
|
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded && data.source === SyncSource.External) {
|
|
actualEvents++
|
|
}
|
|
})
|
|
const first = application.sync.sync()
|
|
const second = application.sync.sync()
|
|
await Promise.all([first, second])
|
|
/** Sleep so that any automatic syncs that are triggered are also sent to handler above */
|
|
await Factory.sleep(0.5)
|
|
expect(actualEvents).to.equal(expectedEvents)
|
|
})
|
|
|
|
it('should keep an item dirty thats been modified after low latency sync request began', async function () {
|
|
/**
|
|
* If you begin a sync request that takes 20s to complete, then begin modifying an item
|
|
* many times and attempt to sync, it will await the initial sync to complete.
|
|
* When that completes, it will decide whether an item is still dirty or not.
|
|
* It will do based on comparing whether item.dirtyIndex > item.globalDirtyIndexAtLastSync
|
|
*/
|
|
let note = await Factory.createMappedNote(application)
|
|
await application.mutator.setItemDirty(note)
|
|
expectedItemCount++
|
|
|
|
// client A. Don't await, we want to do other stuff.
|
|
application.sync.ut_beginLatencySimulator(1500)
|
|
const slowSync = application.sync.sync(syncOptions)
|
|
await Factory.sleep(0.1)
|
|
expect(note.dirty).to.equal(true)
|
|
|
|
// While that sync is going on, we want to modify this item many times.
|
|
const text = `${Math.random()}`
|
|
note = await application.mutator.changeItem(note, (mutator) => {
|
|
mutator.text = text
|
|
})
|
|
await application.mutator.setItemDirty(note)
|
|
await application.mutator.setItemDirty(note)
|
|
await application.mutator.setItemDirty(note)
|
|
expect(note.payload.dirtyIndex).to.be.above(note.payload.globalDirtyIndexAtLastSync)
|
|
|
|
// Now do a regular sync with no latency.
|
|
application.sync.ut_endLatencySimulator()
|
|
const midSync = application.sync.sync(syncOptions)
|
|
|
|
await slowSync
|
|
await midSync
|
|
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.dirty).to.equal(false)
|
|
expect(note.lastSyncEnd).to.be.above(note.lastSyncBegan)
|
|
expect(note.content.text).to.equal(text)
|
|
|
|
// client B
|
|
await application.payloads.resetState()
|
|
await application.items.resetState()
|
|
await application.sync.clearSyncPositionTokens()
|
|
await application.sync.sync(syncOptions)
|
|
|
|
// Expect that the server value and client value match, and no conflicts are created.
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
const foundItem = application.items.findItem(note.uuid)
|
|
expect(foundItem.content.text).to.equal(text)
|
|
expect(foundItem.text).to.equal(text)
|
|
})
|
|
|
|
it('should sync an item twice if its marked dirty while a sync is ongoing', async function () {
|
|
/** We can't track how many times an item is synced, only how many times its mapped */
|
|
const expectedSaveCount = 2
|
|
let actualSaveCount = 0
|
|
|
|
/** Create an item and sync it */
|
|
let note = await Factory.createMappedNote(application)
|
|
|
|
application.items.addObserver(ContentType.TYPES.Note, ({ source }) => {
|
|
if (source === PayloadEmitSource.RemoteSaved) {
|
|
actualSaveCount++
|
|
}
|
|
})
|
|
|
|
expectedItemCount++
|
|
application.sync.ut_beginLatencySimulator(150)
|
|
|
|
/** Dont await */
|
|
const syncRequest = application.sync.sync(syncOptions)
|
|
|
|
/** Dirty the item 100ms into 150ms request */
|
|
const newText = `${Math.random()}`
|
|
|
|
setTimeout(
|
|
async function () {
|
|
await application.mutator.changeItem(note, (mutator) => {
|
|
mutator.text = newText
|
|
})
|
|
}.bind(this),
|
|
100,
|
|
)
|
|
|
|
/**
|
|
* Await sync request. A sync request will perform another request if there
|
|
* are still more dirty items, so awaiting this will perform two syncs.
|
|
*/
|
|
await syncRequest
|
|
expect(actualSaveCount).to.equal(expectedSaveCount)
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.text).to.equal(newText)
|
|
})
|
|
|
|
it('marking item dirty after dirty items are prepared for sync but before they are synced should sync again', async function () {
|
|
/**
|
|
* There is a twilight zone where items needing sync are popped, and then say about 100ms of processing before
|
|
* we set those items' lastSyncBegan. If the item is dirtied in between these times, then item.dirtyIndex will be less than
|
|
* item.globalDirtyIndexAtLastSync, and it will not by synced again.
|
|
*/
|
|
|
|
const expectedSaveCount = 2
|
|
let actualSaveCount = 0
|
|
|
|
/** Create an item and sync it */
|
|
let note = await Factory.createMappedNote(application)
|
|
|
|
application.items.addObserver(ContentType.TYPES.Note, ({ source }) => {
|
|
if (source === PayloadEmitSource.RemoteSaved) {
|
|
actualSaveCount++
|
|
}
|
|
})
|
|
expectedItemCount++
|
|
|
|
/** Dont await */
|
|
const syncRequest = application.sync.sync(syncOptions)
|
|
|
|
/** Dirty the item before lastSyncBegan is set */
|
|
let didPerformMutatation = false
|
|
const newText = `${Math.random()}`
|
|
|
|
application.sync.addEventObserver(async (eventName) => {
|
|
if (eventName === SyncEvent.SyncDidBeginProcessing && !didPerformMutatation) {
|
|
didPerformMutatation = true
|
|
await application.mutator.changeItem(note, (mutator) => {
|
|
mutator.text = newText
|
|
})
|
|
}
|
|
})
|
|
|
|
await syncRequest
|
|
|
|
expect(actualSaveCount).to.equal(expectedSaveCount)
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.text).to.equal(newText)
|
|
})
|
|
|
|
it('marking item dirty during presync save should sync again', async function () {
|
|
const expectedSaveCount = 2
|
|
let actualSaveCount = 0
|
|
|
|
/** Create an item and sync it */
|
|
let note = await Factory.createMappedNote(application)
|
|
let didPerformMutatation = false
|
|
const newText = `${Math.random()}`
|
|
|
|
application.items.addObserver(ContentType.TYPES.Note, async ({ changed, source }) => {
|
|
if (source === PayloadEmitSource.RemoteSaved) {
|
|
actualSaveCount++
|
|
} else if (source === PayloadEmitSource.PreSyncSave && !didPerformMutatation) {
|
|
didPerformMutatation = true
|
|
|
|
const mutated = changed[0].payload.copy({
|
|
content: { ...note.payload.content, text: newText },
|
|
dirty: true,
|
|
dirtyIndex: changed[0].payload.globalDirtyIndexAtLastSync + 1,
|
|
})
|
|
|
|
await application.mutator.emitItemFromPayload(mutated)
|
|
}
|
|
})
|
|
|
|
expectedItemCount++
|
|
|
|
/** Dont await */
|
|
const syncRequest = application.sync.sync(syncOptions)
|
|
await syncRequest
|
|
expect(actualSaveCount).to.equal(expectedSaveCount)
|
|
note = application.items.findItem(note.uuid)
|
|
expect(note.text).to.equal(newText)
|
|
})
|
|
|
|
it('retreiving a remote deleted item should succeed', async function () {
|
|
const note = await Factory.createSyncedNote(application)
|
|
const preDeleteSyncToken = await application.sync.getLastSyncToken()
|
|
await application.mutator.deleteItem(note)
|
|
await application.sync.sync()
|
|
await application.sync.setLastSyncToken(preDeleteSyncToken)
|
|
await application.sync.sync(syncOptions)
|
|
expect(application.items.items.length).to.equal(expectedItemCount)
|
|
})
|
|
|
|
it('errored items should not be synced', async function () {
|
|
const note = await Factory.createSyncedNote(application)
|
|
expectedItemCount++
|
|
const lastSyncBegan = note.lastSyncBegan
|
|
const lastSyncEnd = note.lastSyncEnd
|
|
|
|
const encrypted = await application.encryption.encryptSplitSingle({
|
|
usesItemsKeyWithKeyLookup: {
|
|
items: [note.payload],
|
|
},
|
|
})
|
|
|
|
const errored = encrypted.copy({
|
|
errorDecrypting: true,
|
|
dirty: true,
|
|
})
|
|
|
|
await application.payloads.emitPayload(errored)
|
|
await application.sync.sync(syncOptions)
|
|
|
|
const updatedNote = application.items.findAnyItem(note.uuid)
|
|
expect(updatedNote.lastSyncBegan.getTime()).to.equal(lastSyncBegan.getTime())
|
|
expect(updatedNote.lastSyncEnd.getTime()).to.equal(lastSyncEnd.getTime())
|
|
})
|
|
|
|
it('should not allow receiving decrypted payloads from server', async function () {
|
|
const invalidPayload = new DecryptedPayload(
|
|
{ ...Factory.createNotePayload(), uuid: 'rejected' },
|
|
PayloadSource.RemoteRetrieved,
|
|
)
|
|
|
|
const validPayload = new EncryptedPayload({
|
|
uuid: '123',
|
|
content_type: 'Note',
|
|
content: '004:...',
|
|
})
|
|
|
|
expectedItemCount++
|
|
|
|
const response = new ServerSyncResponse({
|
|
data: {
|
|
retrieved_items: [invalidPayload.ejected(), validPayload.ejected()],
|
|
},
|
|
})
|
|
|
|
await application.sync.handleSuccessServerResponse({ payloadsSavedOrSaving: [], options: {} }, response)
|
|
|
|
expect(application.payloads.findOne(invalidPayload.uuid)).to.not.be.ok
|
|
expect(application.payloads.findOne(validPayload.uuid)).to.be.ok
|
|
})
|
|
|
|
it('retrieved items should have both updated_at and updated_at_timestamps', async function () {
|
|
const note = await Factory.createSyncedNote(application)
|
|
|
|
expectedItemCount++
|
|
|
|
expect(note.payload.created_at_timestamp).to.be.ok
|
|
expect(note.payload.created_at).to.be.ok
|
|
expect(note.payload.updated_at_timestamp).to.be.ok
|
|
expect(note.payload.updated_at).to.be.ok
|
|
})
|
|
|
|
it('syncing an item with non-supported content type should not result in infinite loop', async function () {
|
|
/**
|
|
* When a client tries to sync an item with a server-unrecognized content type, it will
|
|
* be returned by the server as an error conflict.
|
|
*/
|
|
const payload = new DecryptedPayload({
|
|
uuid: Utils.generateUuid(),
|
|
content_type: 'Foo',
|
|
dirty: true,
|
|
content: {},
|
|
})
|
|
expectedItemCount++
|
|
await application.mutator.emitItemsFromPayloads([payload])
|
|
await application.sync.sync(syncOptions)
|
|
|
|
/** Item should no longer be dirty, otherwise it would keep syncing */
|
|
const item = application.items.findItem(payload.uuid)
|
|
expect(item.dirty).to.not.be.ok
|
|
})
|
|
|
|
it('should call onPresyncSave before sync begins', async function () {
|
|
const events = []
|
|
application.sync.addEventObserver((event) => {
|
|
if (event === SyncEvent.SyncDidBeginProcessing) {
|
|
events.push('sync-will-begin')
|
|
}
|
|
})
|
|
|
|
await application.sync.sync({
|
|
onPresyncSave: () => {
|
|
events.push('on-presync-save')
|
|
},
|
|
})
|
|
|
|
expect(events[0]).to.equal('on-presync-save')
|
|
expect(events[1]).to.equal('sync-will-begin')
|
|
})
|
|
|
|
it('deleting an item permanently should include it in PayloadEmitSource.PreSyncSave item change observer', async function () {
|
|
let conditionMet = false
|
|
|
|
application.items.streamItems([ContentType.TYPES.Note], async ({ removed, source }) => {
|
|
if (source === PayloadEmitSource.PreSyncSave && removed.length === 1) {
|
|
conditionMet = true
|
|
}
|
|
})
|
|
|
|
const note = await Factory.createSyncedNote(application)
|
|
await application.mutator.deleteItem(note)
|
|
await application.sync.sync()
|
|
|
|
expect(conditionMet).to.equal(true)
|
|
})
|
|
|
|
it('deleting a note on one client should update notes count on the other', async function () {
|
|
const contextA = context
|
|
const contextB = await Factory.createAppContextWithFakeCrypto('AppB', contextA.email, contextA.password)
|
|
|
|
await contextB.launch()
|
|
await contextB.signIn()
|
|
|
|
const note = await contextA.createSyncedNote()
|
|
await contextB.sync()
|
|
|
|
expect(contextB.application.items.allCountableNotesCount()).to.equal(1)
|
|
|
|
await contextA.deleteItemAndSync(note)
|
|
await contextB.sync()
|
|
|
|
expect(contextB.application.items.allCountableNotesCount()).to.equal(0)
|
|
|
|
await contextB.deinit()
|
|
})
|
|
|
|
it('should sync note when running raw sync request for external use', async function () {
|
|
const contextA = context
|
|
const contextB = await Factory.createAppContextWithFakeCrypto('AppB', contextA.email, contextA.password)
|
|
|
|
await contextB.launch()
|
|
await contextB.signIn()
|
|
|
|
const notePayload = Factory.createNote()
|
|
|
|
const rawSyncRequest = await application.sync.getRawSyncRequestForExternalUse([notePayload])
|
|
expect(rawSyncRequest).to.be.ok
|
|
|
|
const response = await application.http.runHttp(rawSyncRequest)
|
|
expect(response.status).to.equal(200)
|
|
|
|
await contextB.sync()
|
|
|
|
const note = contextB.application.items.findItem(notePayload.uuid)
|
|
expect(note).to.be.ok
|
|
|
|
await contextB.deinit()
|
|
})
|
|
})
|