www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

syncLocalTest.js (59872B)


      1 "use strict";
      2 
      3 describe("Zotero.Sync.Data.Local", function() {
      4 	describe("#getAPIKey()/#setAPIKey()", function () {
      5 		it("should get and set an API key", function* () {
      6 			var apiKey1 = Zotero.Utilities.randomString(24);
      7 			var apiKey2 = Zotero.Utilities.randomString(24);
      8 			Zotero.Sync.Data.Local.setAPIKey(apiKey1);
      9 			yield assert.eventually.equal(Zotero.Sync.Data.Local.getAPIKey(apiKey1), apiKey1);
     10 			Zotero.Sync.Data.Local.setAPIKey(apiKey2);
     11 			yield assert.eventually.equal(Zotero.Sync.Data.Local.getAPIKey(apiKey2), apiKey2);
     12 		})
     13 		
     14 		
     15 		it("should clear an API key by setting an empty string", function* () {
     16 			var apiKey = Zotero.Utilities.randomString(24);
     17 			Zotero.Sync.Data.Local.setAPIKey(apiKey);
     18 			Zotero.Sync.Data.Local.setAPIKey("");
     19 			yield assert.eventually.strictEqual(Zotero.Sync.Data.Local.getAPIKey(apiKey), "");
     20 		})
     21 	})
     22 	
     23 	
     24 	describe("#checkUser()", function () {
     25 		var resetDataDirFile;
     26 		
     27 		before(function() {
     28 			resetDataDirFile = OS.Path.join(Zotero.DataDirectory.dir, 'reset-data-directory');
     29 			sinon.stub(Zotero.Utilities.Internal, 'quitZotero');
     30 		});	
     31 		
     32 		beforeEach(function* () {
     33 			yield OS.File.remove(resetDataDirFile, {ignoreAbsent: true});
     34 			Zotero.Utilities.Internal.quitZotero.reset();
     35 		});
     36 		
     37 		after(function() {
     38 			Zotero.Utilities.Internal.quitZotero.restore();
     39 		});
     40 	
     41 		it("should prompt for data reset and create a temp 'reset-data-directory' file on accept", function* (){
     42 			yield Zotero.Users.setCurrentUserID(1);
     43 			yield Zotero.Users.setCurrentUsername("A");
     44 			
     45 			var handled = false;
     46 			waitForDialog(function (dialog) {
     47 				var text = dialog.document.documentElement.textContent;
     48 				var matches = text.match(/‘[^’]*’/g);
     49 				assert.equal(matches.length, 3);
     50 				assert.equal(matches[0], "‘A’");
     51 				assert.equal(matches[1], "‘B’");
     52 				assert.equal(matches[2], "‘A’");
     53 				
     54 				dialog.document.getElementById('zotero-hardConfirmationDialog-checkbox').checked = true;
     55 				dialog.document.getElementById('zotero-hardConfirmationDialog-checkbox')
     56 					.dispatchEvent(new Event('command'));
     57 				
     58 				handled = true;
     59 			}, 'accept', 'chrome://zotero/content/hardConfirmationDialog.xul');
     60 			var cont = yield Zotero.Sync.Data.Local.checkUser(window, 2, "B");
     61 			var resetDataDirFileExists = yield OS.File.exists(resetDataDirFile);
     62 			assert.isTrue(handled);
     63 			assert.isTrue(cont);
     64 			assert.isTrue(resetDataDirFileExists);
     65 		});
     66 		
     67 		it("should prompt for data reset and cancel", function* () {
     68 			yield Zotero.Users.setCurrentUserID(1);
     69 			yield Zotero.Users.setCurrentUsername("A");
     70 			
     71 			waitForDialog(false, 'cancel', 'chrome://zotero/content/hardConfirmationDialog.xul');
     72 			var cont = yield Zotero.Sync.Data.Local.checkUser(window, 2, "B");
     73 			var resetDataDirFileExists = yield OS.File.exists(resetDataDirFile);
     74 			assert.isFalse(cont);
     75 			assert.isFalse(resetDataDirFileExists);
     76 			
     77 			assert.equal(Zotero.Users.getCurrentUserID(), 1);
     78 			assert.equal(Zotero.Users.getCurrentUsername(), "A");
     79 		});
     80 		
     81 		// extra1 functionality not used at the moment
     82 		it.skip("should prompt for data reset and allow to choose a new data directory", function* (){
     83 			sinon.stub(Zotero.DataDirectory, 'forceChange').returns(true);
     84 			yield Zotero.Users.setCurrentUserID(1);
     85 			yield Zotero.Users.setCurrentUsername("A");
     86 			
     87 			waitForDialog(null, 'extra1', 'chrome://zotero/content/hardConfirmationDialog.xul');
     88 			waitForDialog();
     89 			var cont = yield Zotero.Sync.Data.Local.checkUser(window, 2, "B");
     90 			var resetDataDirFileExists = yield OS.File.exists(resetDataDirFile);
     91 			assert.isTrue(cont);
     92 			assert.isTrue(Zotero.DataDirectory.forceChange.called);
     93 			assert.isFalse(resetDataDirFileExists);
     94 			
     95 			Zotero.DataDirectory.forceChange.restore();
     96 		});
     97 		
     98 		it("should migrate relations using local user key", function* () {
     99 			yield Zotero.DB.queryAsync("DELETE FROM settings WHERE setting='account'");
    100 			yield Zotero.Users.init();
    101 			
    102 			var item1 = yield createDataObject('item');
    103 			var item2 = createUnsavedDataObject('item');
    104 			item2.addRelatedItem(item1);
    105 			yield item2.save();
    106 			
    107 			var pred = Zotero.Relations.relatedItemPredicate;
    108 			assert.isTrue(
    109 				item2.toJSON().relations[pred][0].startsWith('http://zotero.org/users/local/')
    110 			);
    111 			
    112 			waitForDialog(false, 'accept', 'chrome://zotero/content/hardConfirmationDialog.xul');
    113 			yield Zotero.Sync.Data.Local.checkUser(window, 1, "A");
    114 			
    115 			assert.isTrue(
    116 				item2.toJSON().relations[pred][0].startsWith('http://zotero.org/users/1/items/')
    117 			);
    118 		});
    119 	});
    120 	
    121 	
    122 	describe("#checkLibraryForAccess()", function () {
    123 		//
    124 		// editable
    125 		//
    126 		it("should prompt if library is changing from editable to non-editable and reset library on accept", function* () {
    127 			var group = yield createGroup();
    128 			var libraryID = group.libraryID;
    129 			var promise = waitForDialog(function (dialog) {
    130 				var text = dialog.document.documentElement.textContent;
    131 				assert.include(text, group.name);
    132 			});
    133 			
    134 			var mock = sinon.mock(Zotero.Sync.Data.Local);
    135 			mock.expects("_libraryHasUnsyncedData").once().returns(Zotero.Promise.resolve(true));
    136 			mock.expects("resetUnsyncedLibraryData").once().returns(Zotero.Promise.resolve());
    137 			mock.expects("resetUnsyncedLibraryFiles").never();
    138 			
    139 			assert.isTrue(
    140 				yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, false, false)
    141 			);
    142 			yield promise;
    143 			
    144 			mock.verify();
    145 		});
    146 		
    147 		it("should prompt if library is changing from editable to non-editable but not reset library on cancel", function* () {
    148 			var group = yield createGroup();
    149 			var libraryID = group.libraryID;
    150 			var promise = waitForDialog(function (dialog) {
    151 				var text = dialog.document.documentElement.textContent;
    152 				assert.include(text, group.name);
    153 			}, "cancel");
    154 			
    155 			var mock = sinon.mock(Zotero.Sync.Data.Local);
    156 			mock.expects("_libraryHasUnsyncedData").once().returns(Zotero.Promise.resolve(true));
    157 			mock.expects("resetUnsyncedLibraryData").never();
    158 			mock.expects("resetUnsyncedLibraryFiles").never();
    159 			
    160 			assert.isFalse(
    161 				yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, false, false)
    162 			);
    163 			yield promise;
    164 			
    165 			mock.verify();
    166 		});
    167 		
    168 		it("should not prompt if library is changing from editable to non-editable", function* () {
    169 			var group = yield createGroup({ editable: false, filesEditable: false });
    170 			var libraryID = group.libraryID;
    171 			yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, true, true);
    172 		});
    173 		
    174 		//
    175 		// filesEditable
    176 		//
    177 		it("should prompt if library is changing from filesEditable to non-filesEditable and reset library files on accept", function* () {
    178 			var group = yield createGroup();
    179 			var libraryID = group.libraryID;
    180 			var promise = waitForDialog(function (dialog) {
    181 				var text = dialog.document.documentElement.textContent;
    182 				assert.include(text, group.name);
    183 			});
    184 			
    185 			var mock = sinon.mock(Zotero.Sync.Data.Local);
    186 			mock.expects("_libraryHasUnsyncedFiles").once().returns(Zotero.Promise.resolve(true));
    187 			mock.expects("resetUnsyncedLibraryData").never();
    188 			mock.expects("resetUnsyncedLibraryFiles").once().returns(Zotero.Promise.resolve());
    189 			
    190 			assert.isTrue(
    191 				yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, true, false)
    192 			);
    193 			yield promise;
    194 			
    195 			mock.verify();
    196 		});
    197 		
    198 		it("should prompt if library is changing from filesEditable to non-filesEditable but not reset library files on cancel", function* () {
    199 			var group = yield createGroup();
    200 			var libraryID = group.libraryID;
    201 			var promise = waitForDialog(function (dialog) {
    202 				var text = dialog.document.documentElement.textContent;
    203 				assert.include(text, group.name);
    204 			}, "cancel");
    205 			
    206 			var mock = sinon.mock(Zotero.Sync.Data.Local);
    207 			mock.expects("_libraryHasUnsyncedFiles").once().returns(Zotero.Promise.resolve(true));
    208 			mock.expects("resetUnsyncedLibraryData").never();
    209 			mock.expects("resetUnsyncedLibraryFiles").never();
    210 			
    211 			assert.isFalse(
    212 				yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, true, false)
    213 			);
    214 			yield promise;
    215 			
    216 			mock.verify();
    217 		});
    218 	});
    219 	
    220 	
    221 	describe("#_libraryHasUnsyncedData()", function () {
    222 		it("should return true for unsynced setting", function* () {
    223 			var group = yield createGroup();
    224 			var libraryID = group.libraryID;
    225 			yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" });
    226 			assert.isTrue(yield Zotero.Sync.Data.Local._libraryHasUnsyncedData(libraryID));
    227 		});
    228 		
    229 		it("should return true for unsynced item", function* () {
    230 			var group = yield createGroup();
    231 			var libraryID = group.libraryID;
    232 			yield createDataObject('item', { libraryID });
    233 			assert.isTrue(yield Zotero.Sync.Data.Local._libraryHasUnsyncedData(libraryID));
    234 		});
    235 		
    236 		it("should return false if no changes", function* () {
    237 			var group = yield createGroup();
    238 			var libraryID = group.libraryID;
    239 			assert.isFalse(yield Zotero.Sync.Data.Local._libraryHasUnsyncedData(libraryID));
    240 		});
    241 	});
    242 	
    243 	
    244 	describe("#resetUnsyncedLibraryData()", function () {
    245 		it("should revert group and mark for full sync", function* () {
    246 			var group = yield createGroup({
    247 				version: 1,
    248 				libraryVersion: 2
    249 			});
    250 			var libraryID = group.libraryID;
    251 			
    252 			// New setting
    253 			yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" });
    254 			
    255 			// Changed collection
    256 			var changedCollection = yield createDataObject('collection', { libraryID, version: 1 });
    257 			var originalCollectionName = changedCollection.name;
    258 			yield Zotero.Sync.Data.Local.saveCacheObject(
    259 				'collection', libraryID, changedCollection.toJSON()
    260 			);
    261 			yield modifyDataObject(changedCollection);
    262 			
    263 			// Unchanged item
    264 			var unchangedItem = yield createDataObject('item', { libraryID, version: 1, synced: true });
    265 			yield Zotero.Sync.Data.Local.saveCacheObject(
    266 				'item', libraryID, unchangedItem.toJSON()
    267 			);
    268 			
    269 			// Changed item
    270 			var changedItem = yield createDataObject('item', { libraryID, version: 1 });
    271 			var originalChangedItemTitle = changedItem.getField('title');
    272 			yield Zotero.Sync.Data.Local.saveCacheObject('item', libraryID, changedItem.toJSON());
    273 			yield modifyDataObject(changedItem);
    274 			
    275 			// New item
    276 			var newItem = yield createDataObject('item', { libraryID, version: 1 });
    277 			var newItemKey = newItem.key;
    278 			
    279 			// Delete item
    280 			var deletedItem = yield createDataObject('item', { libraryID });
    281 			var deletedItemKey = deletedItem.key;
    282 			yield deletedItem.eraseTx();
    283 			
    284 			// Make group read-only
    285 			group.editable = false;
    286 			yield group.saveTx();
    287 			
    288 			yield Zotero.Sync.Data.Local.resetUnsyncedLibraryData(libraryID);
    289 			
    290 			assert.isNull(Zotero.SyncedSettings.get(group.libraryID, "testSetting"));
    291 			
    292 			assert.equal(changedCollection.name, originalCollectionName);
    293 			assert.isTrue(changedCollection.synced);
    294 			
    295 			assert.isTrue(unchangedItem.synced);
    296 			
    297 			assert.equal(changedItem.getField('title'), originalChangedItemTitle);
    298 			assert.isTrue(changedItem.synced);
    299 			
    300 			assert.isFalse(Zotero.Items.get(newItemKey));
    301 			
    302 			assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, deletedItemKey));
    303 			
    304 			assert.equal(group.libraryVersion, -1);
    305 		});
    306 		
    307 		
    308 		describe("#resetUnsyncedLibraryFiles", function () {
    309 			it("should delete unsynced files", function* () {
    310 				var group = yield createGroup({
    311 					version: 1,
    312 					libraryVersion: 2
    313 				});
    314 				var libraryID = group.libraryID;
    315 				
    316 				// File attachment that's totally in sync -- leave alone
    317 				var attachment1 = yield importFileAttachment('test.png', { libraryID });
    318 				attachment1.attachmentSyncState = "in_sync";
    319 				attachment1.attachmentSyncedModificationTime = yield attachment1.attachmentModificationTime;
    320 				attachment1.attachmentSyncedHash = yield attachment1.attachmentHash;
    321 				attachment1.synced = true;
    322 				yield attachment1.saveTx({
    323 					skipSyncedUpdate: true
    324 				});
    325 				
    326 				// File attachment that's in sync with changed file -- delete file and mark for download
    327 				var attachment2 = yield importFileAttachment('test.png', { libraryID });
    328 				attachment2.synced = true;
    329 				yield attachment2.saveTx({
    330 					skipSyncedUpdate: true
    331 				});
    332 				
    333 				// File attachment that's unsynced -- delete item and file
    334 				var attachment3 = yield importFileAttachment('test.pdf', { libraryID });
    335 				
    336 				// Has to be called before resetUnsyncedLibraryFiles()
    337 				assert.isTrue(yield Zotero.Sync.Data.Local._libraryHasUnsyncedFiles(libraryID));
    338 				
    339 				yield Zotero.Sync.Data.Local.resetUnsyncedLibraryFiles(libraryID);
    340 				
    341 				assert.isTrue(yield attachment1.fileExists());
    342 				assert.isFalse(yield attachment2.fileExists());
    343 				assert.isFalse(yield attachment3.fileExists());
    344 				assert.equal(
    345 					attachment1.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC
    346 				);
    347 				assert.equal(
    348 					attachment2.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD
    349 				);
    350 				assert.isFalse(Zotero.Items.get(attachment3.id));
    351 			});
    352 		});
    353 		
    354 		it("should revert modified file attachment item", async function () {
    355 			var group = await createGroup({
    356 				version: 1,
    357 				libraryVersion: 2
    358 			});
    359 			var libraryID = group.libraryID;
    360 			
    361 			// File attachment that's changed but file is in sync -- reset item, keep file
    362 			var attachment = await importFileAttachment('test.png', { libraryID });
    363 			var originalTitle = attachment.getField('title');
    364 			attachment.attachmentSyncedModificationTime = await attachment.attachmentModificationTime;
    365 			attachment.attachmentSyncedHash = await attachment.attachmentHash;
    366 			attachment.attachmentSyncState = "in_sync";
    367 			attachment.synced = true;
    368 			attachment.version = 2;
    369 			await attachment.saveTx({
    370 				skipSyncedUpdate: true
    371 			});
    372 			// Save original in cache
    373 			await Zotero.Sync.Data.Local.saveCacheObject(
    374 				'item',
    375 				libraryID,
    376 				Object.assign(
    377 					attachment.toJSON(),
    378 					// TEMP: md5 and mtime aren't currently included in JSON, and without it the
    379 					// file gets marked for download when the item gets reset from the cache
    380 					{
    381 						md5: attachment.attachmentHash,
    382 						mtime: attachment.attachmentSyncedModificationTime
    383 					}
    384 				)
    385 			);
    386 			// Modify title
    387 			attachment.setField('title', "New Title");
    388 			await attachment.saveTx();
    389 			
    390 			await Zotero.Sync.Data.Local.resetUnsyncedLibraryFiles(libraryID);
    391 			
    392 			assert.isTrue(await attachment.fileExists());
    393 			assert.equal(attachment.getField('title'), originalTitle);
    394 			assert.equal(
    395 				attachment.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC
    396 			);
    397 		});
    398 	});
    399 	
    400 	
    401 	describe("#getLatestCacheObjectVersions", function () {
    402 		before(function* () {
    403 			yield resetDB({
    404 				thisArg: this,
    405 				skipBundledFiles: true
    406 			});
    407 			
    408 			yield Zotero.Sync.Data.Local.saveCacheObjects(
    409 				'item',
    410 				Zotero.Libraries.userLibraryID,
    411 				[
    412 					{
    413 						key: 'AAAAAAAA',
    414 						version: 2,
    415 						title: "A2"
    416 					},
    417 					{
    418 						key: 'AAAAAAAA',
    419 						version: 1,
    420 						title: "A1"
    421 					},
    422 					{
    423 						key: 'BBBBBBBB',
    424 						version: 1,
    425 						title: "B1"
    426 					},
    427 					{
    428 						key: 'BBBBBBBB',
    429 						version: 2,
    430 						title: "B2"
    431 					},
    432 					{
    433 						key: 'CCCCCCCC',
    434 						version: 3,
    435 						title: "C"
    436 					}
    437 				]
    438 			);
    439 		})
    440 		
    441 		it("should return latest version of all objects if no keys passed", function* () {
    442 			var versions = yield Zotero.Sync.Data.Local.getLatestCacheObjectVersions(
    443 				'item',
    444 				Zotero.Libraries.userLibraryID
    445 			);
    446 			var keys = Object.keys(versions);
    447 			assert.lengthOf(keys, 3);
    448 			assert.sameMembers(keys, ['AAAAAAAA', 'BBBBBBBB', 'CCCCCCCC']);
    449 			assert.equal(versions.AAAAAAAA, 2);
    450 			assert.equal(versions.BBBBBBBB, 2);
    451 			assert.equal(versions.CCCCCCCC, 3);
    452 		})
    453 		
    454 		it("should return latest version of objects with passed keys", function* () {
    455 			var versions = yield Zotero.Sync.Data.Local.getLatestCacheObjectVersions(
    456 				'item',
    457 				Zotero.Libraries.userLibraryID,
    458 				['AAAAAAAA', 'CCCCCCCC']
    459 			);
    460 			var keys = Object.keys(versions);
    461 			assert.lengthOf(keys, 2);
    462 			assert.sameMembers(keys, ['AAAAAAAA', 'CCCCCCCC']);
    463 			assert.equal(versions.AAAAAAAA, 2);
    464 			assert.equal(versions.CCCCCCCC, 3);
    465 		})
    466 	})
    467 	
    468 	
    469 	describe("#processObjectsFromJSON()", function () {
    470 		var types = Zotero.DataObjectUtilities.getTypes();
    471 		
    472 		beforeEach(function* () {
    473 			yield resetDB({
    474 				thisArg: this,
    475 				skipBundledFiles: true
    476 			});
    477 		})
    478 		
    479 		it("should update local version number and mark as synced if remote version is identical", function* () {
    480 			var libraryID = Zotero.Libraries.userLibraryID;
    481 			
    482 			for (let type of types) {
    483 				let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
    484 				let obj = yield createDataObject(type);
    485 				let data = obj.toJSON();
    486 				data.key = obj.key;
    487 				data.version = 10;
    488 				let json = {
    489 					key: obj.key,
    490 					version: 10,
    491 					data: data
    492 				};
    493 				yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    494 					type, libraryID, [json], { stopOnError: true }
    495 				);
    496 				let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key);
    497 				assert.equal(localObj.version, 10);
    498 				assert.isTrue(localObj.synced);
    499 			}
    500 		})
    501 		
    502 		it("should keep local item changes while applying non-conflicting remote changes", function* () {
    503 			var libraryID = Zotero.Libraries.userLibraryID;
    504 			
    505 			var type = 'item';
    506 			let obj = yield createDataObject(type, { version: 5 });
    507 			let data = obj.toJSON();
    508 			yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [data]);
    509 			
    510 			// Change local title
    511 			yield modifyDataObject(obj)
    512 			var changedTitle = obj.getField('title');
    513 			
    514 			// Create remote version without title but with changed place
    515 			data.key = obj.key;
    516 			data.version = 10;
    517 			var changedPlace = data.place = 'New York';
    518 			let json = {
    519 				key: obj.key,
    520 				version: 10,
    521 				data: data
    522 			};
    523 			yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    524 				type, libraryID, [json], { stopOnError: true }
    525 			);
    526 			assert.equal(obj.version, 10);
    527 			assert.equal(obj.getField('title'), changedTitle);
    528 			assert.equal(obj.getField('place'), changedPlace);
    529 		})
    530 		
    531 		it("should save item with overriding local conflict as unsynced", async function () {
    532 			var libraryID = Zotero.Libraries.userLibraryID;
    533 			
    534 			var isbn = '978-0-335-22006-9';
    535 			var type = 'item';
    536 			let obj = createUnsavedDataObject(type, { version: 5 });
    537 			obj.setField('ISBN', isbn);
    538 			await obj.saveTx();
    539 			let data = obj.toJSON();
    540 			
    541 			data.key = obj.key;
    542 			data.version = 10;
    543 			data.ISBN = '9780335220069';
    544 			let json = {
    545 				key: obj.key,
    546 				version: 10,
    547 				data
    548 			};
    549 			var results = await Zotero.Sync.Data.Local.processObjectsFromJSON(
    550 				type, libraryID, [json], { stopOnError: true }
    551 			);
    552 			assert.isTrue(results[0].processed);
    553 			assert.isUndefined(results[0].changes);
    554 			assert.isUndefined(results[0].conflicts);
    555 			assert.equal(obj.version, 10);
    556 			assert.equal(obj.getField('ISBN'), isbn);
    557 			assert.isFalse(obj.synced);
    558 			// Sync cache should match remote
    559 			var cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(type, libraryID, data.key, data.version);
    560 			assert.propertyVal(cacheJSON.data, "ISBN", data.ISBN);
    561 		});
    562 		
    563 		it("should restore locally deleted collections and searches that changed remotely", async function () {
    564 			var libraryID = Zotero.Libraries.userLibraryID;
    565 			
    566 			for (let type of ['collection', 'search']) {
    567 				let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
    568 				let obj = await createDataObject(type, { version: 1 });
    569 				let data = obj.toJSON();
    570 				
    571 				await obj.eraseTx();
    572 				
    573 				data.key = obj.key;
    574 				data.version = 2;
    575 				let json = {
    576 					key: obj.key,
    577 					version: 2,
    578 					data
    579 				};
    580 				let results = await Zotero.Sync.Data.Local.processObjectsFromJSON(
    581 					type, libraryID, [json], { stopOnError: true }
    582 				);
    583 				assert.isTrue(results[0].processed);
    584 				assert.notOk(results[0].conflict);
    585 				assert.isTrue(results[0].restored);
    586 				assert.isUndefined(results[0].changes);
    587 				assert.isUndefined(results[0].conflicts);
    588 				obj = objectsClass.getByLibraryAndKey(libraryID, data.key);
    589 				assert.equal(obj.version, 2);
    590 				assert.isTrue(obj.synced);
    591 				assert.isFalse(await Zotero.Sync.Data.Local.getDateDeleted(type, libraryID, data.key));
    592 			}
    593 		});
    594 		
    595 		it("should delete older versions in sync cache after processing", function* () {
    596 			var libraryID = Zotero.Libraries.userLibraryID;
    597 			
    598 			for (let type of types) {
    599 				let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
    600 				
    601 				// Save original version
    602 				let obj = yield createDataObject(type, { version: 5 });
    603 				let data = obj.toJSON();
    604 				yield Zotero.Sync.Data.Local.saveCacheObjects(
    605 					type, libraryID, [data]
    606 				);
    607 				
    608 				// Save newer version
    609 				data.version = 10;
    610 				yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    611 					type, libraryID, [data], { stopOnError: true }
    612 				);
    613 				
    614 				let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key);
    615 				assert.equal(localObj.version, 10);
    616 				
    617 				let versions = yield Zotero.Sync.Data.Local.getCacheObjectVersions(
    618 					type, libraryID, obj.key
    619 				);
    620 				assert.sameMembers(
    621 					versions,
    622 					[10],
    623 					"should have only latest version of " + type + " in cache"
    624 				);
    625 			}
    626 		});
    627 		
    628 		it("should delete object from sync queue after processing", function* () {
    629 			var objectType = 'item';
    630 			var libraryID = Zotero.Libraries.userLibraryID;
    631 			var key = Zotero.DataObjectUtilities.generateKey();
    632 			
    633 			yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, libraryID, [key]);
    634 			
    635 			var versions = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
    636 			assert.include(versions, key);
    637 			
    638 			var json = {
    639 				key,
    640 				version: 10,
    641 				data: {
    642 					key,
    643 					version: 10,
    644 					itemType: "book",
    645 					title: "Test"
    646 				}
    647 			};
    648 			
    649 			yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    650 				objectType, libraryID, [json], { stopOnError: true }
    651 			);
    652 			
    653 			var versions = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
    654 			assert.notInclude(versions, key);
    655 		});
    656 		
    657 		it("should mark new attachment items and library for download", function* () {
    658 			var library = Zotero.Libraries.userLibrary;
    659 			var libraryID = library.id;
    660 			Zotero.Sync.Storage.Local.setModeForLibrary(libraryID, 'zfs');
    661 			
    662 			var key = Zotero.DataObjectUtilities.generateKey();
    663 			var version = 10;
    664 			var json = {
    665 				key,
    666 				version,
    667 				data: {
    668 					key,
    669 					version,
    670 					itemType: 'attachment',
    671 					linkMode: 'imported_file',
    672 					md5: '57f8a4fda823187b91e1191487b87fe6',
    673 					mtime: 1442261130615
    674 				}
    675 			};
    676 			
    677 			yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    678 				'item', libraryID, [json], { stopOnError: true }
    679 			);
    680 			var item = Zotero.Items.getByLibraryAndKey(libraryID, key);
    681 			assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD);
    682 			assert.isTrue(library.storageDownloadNeeded);
    683 		})
    684 		
    685 		it("should mark updated attachment items for download", function* () {
    686 			var library = Zotero.Libraries.userLibrary;
    687 			var libraryID = library.id;
    688 			Zotero.Sync.Storage.Local.setModeForLibrary(libraryID, 'zfs');
    689 			
    690 			var item = yield importFileAttachment('test.png');
    691 			item.version = 5;
    692 			item.synced = true;
    693 			yield item.saveTx();
    694 			
    695 			// Set file as synced
    696 			item.attachmentSyncedModificationTime = yield item.attachmentModificationTime;
    697 			item.attachmentSyncedHash = yield item.attachmentHash;
    698 			item.attachmentSyncState = "in_sync";
    699 			yield item.saveTx({ skipAll: true });
    700 			
    701 			// Simulate download of version with updated attachment
    702 			var json = item.toResponseJSON();
    703 			json.version = 10;
    704 			json.data.version = 10;
    705 			json.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
    706 			json.data.mtime = new Date().getTime() + 10000;
    707 			yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    708 				'item', libraryID, [json], { stopOnError: true }
    709 			);
    710 			
    711 			assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD);
    712 			assert.isTrue(library.storageDownloadNeeded);
    713 		})
    714 		
    715 		it("should ignore attachment metadata when resolving metadata conflict", function* () {
    716 			var libraryID = Zotero.Libraries.userLibraryID;
    717 			Zotero.Sync.Storage.Local.setModeForLibrary(libraryID, 'zfs');
    718 			
    719 			var item = yield importFileAttachment('test.png');
    720 			item.version = 5;
    721 			yield item.saveTx();
    722 			var json = item.toResponseJSON();
    723 			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
    724 			
    725 			// Set file as synced
    726 			item.attachmentSyncedModificationTime = yield item.attachmentModificationTime;
    727 			item.attachmentSyncedHash = yield item.attachmentHash;
    728 			item.attachmentSyncState = "in_sync";
    729 			yield item.saveTx({ skipAll: true });
    730 			
    731 			// Modify title locally, leaving item unsynced
    732 			var newTitle = Zotero.Utilities.randomString();
    733 			item.setField('title', newTitle);
    734 			yield item.saveTx();
    735 			
    736 			// Simulate download of version with original title but updated attachment
    737 			json.version = 10;
    738 			json.data.version = 10;
    739 			json.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
    740 			json.data.mtime = new Date().getTime() + 10000;
    741 			yield Zotero.Sync.Data.Local.processObjectsFromJSON(
    742 				'item', libraryID, [json], { stopOnError: true }
    743 			);
    744 			
    745 			assert.equal(item.getField('title'), newTitle);
    746 			assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD);
    747 		})
    748 		
    749 		it("should roll back partial object changes on error", function* () {
    750 			var libraryID = Zotero.Libraries.userLibraryID;
    751 			var key1 = "AAAAAAAA";
    752 			var key2 = "BBBBBBBB";
    753 			var json = [
    754 				{
    755 					key: key1,
    756 					version: 1,
    757 					data: {
    758 						key: key1,
    759 						version: 1,
    760 						itemType: "book",
    761 						title: "Test A"
    762 					}
    763 				},
    764 				{
    765 					key: key2,
    766 					version: 1,
    767 					data: {
    768 						key: key2,
    769 						version: 1,
    770 						itemType: "invalidType",
    771 						title: "Test B"
    772 					}
    773 				}
    774 			];
    775 			yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, json);
    776 			
    777 			// Shouldn't roll back the successful item
    778 			yield assert.eventually.equal(Zotero.DB.valueQueryAsync(
    779 				"SELECT COUNT(*) FROM items WHERE libraryID=? AND key=?", [libraryID, key1]
    780 			), 1);
    781 			// Should rollback the unsuccessful item
    782 			yield assert.eventually.equal(Zotero.DB.valueQueryAsync(
    783 				"SELECT COUNT(*) FROM items WHERE libraryID=? AND key=?", [libraryID, key2]
    784 			), 0);
    785 		});
    786 	})
    787 	
    788 	describe("Sync Queue", function () {
    789 		var lib1, lib2;
    790 		
    791 		before(function* () {
    792 			lib1 = Zotero.Libraries.userLibraryID;
    793 			lib2 = (yield getGroup()).libraryID;
    794 		});
    795 		
    796 		beforeEach(function* () {
    797 			yield Zotero.DB.queryAsync("DELETE FROM syncQueue");
    798 		});
    799 		
    800 		after(function* () {
    801 			yield Zotero.DB.queryAsync("DELETE FROM syncQueue");
    802 		});
    803 		
    804 		describe("#addObjectsToSyncQueue()", function () {
    805 			it("should add new objects and update lastCheck and tries for existing objects", function* () {
    806 				var objectType = 'item';
    807 				var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
    808 				var now = Zotero.Date.getUnixTimestamp();
    809 				var key1 = Zotero.DataObjectUtilities.generateKey();
    810 				var key2 = Zotero.DataObjectUtilities.generateKey();
    811 				var key3 = Zotero.DataObjectUtilities.generateKey();
    812 				var key4 = Zotero.DataObjectUtilities.generateKey();
    813 				yield Zotero.DB.queryAsync(
    814 					"INSERT INTO syncQueue (libraryID, key, syncObjectTypeID, lastCheck, tries) "
    815 						+ "VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
    816 					[
    817 						lib1, key1, syncObjectTypeID, now - 3700, 0,
    818 						lib1, key2, syncObjectTypeID, now - 7000, 1,
    819 						lib2, key3, syncObjectTypeID, now - 86400, 2
    820 					]
    821 				);
    822 				
    823 				yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, lib1, [key1, key2]);
    824 				yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, lib2, [key4]);
    825 				
    826 				var sql = "SELECT lastCheck, tries FROM syncQueue WHERE libraryID=? "
    827 					+ `AND syncObjectTypeID=${syncObjectTypeID} AND key=?`;
    828 				var row;
    829 				// key1
    830 				row = yield Zotero.DB.rowQueryAsync(sql, [lib1, key1]);
    831 				assert.approximately(row.lastCheck, now, 1);
    832 				assert.equal(row.tries, 1);
    833 				// key2
    834 				row = yield Zotero.DB.rowQueryAsync(sql, [lib1, key2]);
    835 				assert.approximately(row.lastCheck, now, 1);
    836 				assert.equal(row.tries, 2);
    837 				// key3
    838 				row = yield Zotero.DB.rowQueryAsync(sql, [lib2, key3]);
    839 				assert.equal(row.lastCheck, now - 86400);
    840 				assert.equal(row.tries, 2);
    841 				// key4
    842 				row = yield Zotero.DB.rowQueryAsync(sql, [lib2, key4]);
    843 				assert.approximately(row.lastCheck, now, 1);
    844 				assert.equal(row.tries, 0);
    845 			});
    846 		});
    847 		
    848 		describe("#getObjectsToTryFromSyncQueue()", function () {
    849 			it("should get objects that should be retried", function* () {
    850 				var objectType = 'item';
    851 				var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
    852 				var now = Zotero.Date.getUnixTimestamp();
    853 				var key1 = Zotero.DataObjectUtilities.generateKey();
    854 				var key2 = Zotero.DataObjectUtilities.generateKey();
    855 				var key3 = Zotero.DataObjectUtilities.generateKey();
    856 				var key4 = Zotero.DataObjectUtilities.generateKey();
    857 				yield Zotero.DB.queryAsync(
    858 					"INSERT INTO syncQueue (libraryID, key, syncObjectTypeID, lastCheck, tries) "
    859 						+ "VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
    860 					[
    861 						lib1, key1, syncObjectTypeID, now - (30 * 60) - 10, 0, // more than half an hour, so should be retried
    862 						lib1, key2, syncObjectTypeID, now - (16 * 60 * 60) + 10, 4, // less than 16 hours, shouldn't be retried
    863 						lib2, key3, syncObjectTypeID, now - 86400 * 7, 20 // more than 64 hours, so should be retried
    864 					]
    865 				);
    866 				
    867 				var keys = yield Zotero.Sync.Data.Local.getObjectsToTryFromSyncQueue('item', lib1);
    868 				assert.sameMembers(keys, [key1]);
    869 				var keys = yield Zotero.Sync.Data.Local.getObjectsToTryFromSyncQueue('item', lib2);
    870 				assert.sameMembers(keys, [key3]);
    871 			});
    872 		});
    873 		
    874 		describe("#removeObjectsFromSyncQueue()", function () {
    875 			it("should remove objects from the sync queue", function* () {
    876 				var objectType = 'item';
    877 				var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
    878 				var now = Zotero.Date.getUnixTimestamp();
    879 				var key1 = Zotero.DataObjectUtilities.generateKey();
    880 				var key2 = Zotero.DataObjectUtilities.generateKey();
    881 				var key3 = Zotero.DataObjectUtilities.generateKey();
    882 				yield Zotero.DB.queryAsync(
    883 					"INSERT INTO syncQueue (libraryID, key, syncObjectTypeID, lastCheck, tries) "
    884 						+ "VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
    885 					[
    886 						lib1, key1, syncObjectTypeID, now, 0,
    887 						lib1, key2, syncObjectTypeID, now, 4,
    888 						lib2, key3, syncObjectTypeID, now, 20
    889 					]
    890 				);
    891 				
    892 				yield Zotero.Sync.Data.Local.removeObjectsFromSyncQueue('item', lib1, [key1]);
    893 				
    894 				var sql = "SELECT COUNT(*) FROM syncQueue WHERE libraryID=? "
    895 					+ `AND syncObjectTypeID=${syncObjectTypeID} AND key=?`;
    896 				assert.notOk(yield Zotero.DB.valueQueryAsync(sql, [lib1, key1]));
    897 				assert.ok(yield Zotero.DB.valueQueryAsync(sql, [lib1, key2]));
    898 				assert.ok(yield Zotero.DB.valueQueryAsync(sql, [lib2, key3]));
    899 			})
    900 		});
    901 		
    902 		describe("#resetSyncQueueTries", function () {
    903 			var spy;
    904 			
    905 			after(function () {
    906 				if (spy) {
    907 					spy.restore();
    908 				}
    909 			})
    910 			
    911 			it("should be run on version upgrade", function* () {
    912 				var sql = "REPLACE INTO settings (setting, key, value) VALUES ('client', 'lastVersion', ?)";
    913 				yield Zotero.DB.queryAsync(sql, "5.0foo");
    914 				
    915 				spy = sinon.spy(Zotero.Sync.Data.Local, "resetSyncQueueTries");
    916 				yield Zotero.Schema.updateSchema();
    917 				assert.ok(spy.called);
    918 			});
    919 		});
    920 	});
    921 	
    922 	
    923 	describe("#showConflictResolutionWindow()", function () {
    924 		it("should show title of note parent", function* () {
    925 			var parentItem = yield createDataObject('item', { title: "Parent" });
    926 			var note = new Zotero.Item('note');
    927 			note.parentKey = parentItem.key;
    928 			note.setNote("Test");
    929 			yield note.saveTx();
    930 			
    931 			var promise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
    932 				var doc = dialog.document;
    933 				var wizard = doc.documentElement;
    934 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
    935 				
    936 				// Show title for middle and right panes
    937 				var parentText = Zotero.getString('pane.item.parentItem') + " Parent";
    938 				assert.equal(mergeGroup.leftpane._id('parent-row').textContent, "");
    939 				assert.equal(mergeGroup.rightpane._id('parent-row').textContent, parentText);
    940 				assert.equal(mergeGroup.mergepane._id('parent-row').textContent, parentText);
    941 				
    942 				wizard.getButton('finish').click();
    943 			});
    944 			
    945 			Zotero.Sync.Data.Local.showConflictResolutionWindow([
    946 				{
    947 					libraryID: note.libraryID,
    948 					key: note.key,
    949 					processed: false,
    950 					conflict: true,
    951 					left: {
    952 						deleted: true,
    953 						dateDeleted: "2016-07-07 12:34:56"
    954 					},
    955 					right: note.toJSON()
    956 				}
    957 			]);
    958 			
    959 			yield promise;
    960 		});
    961 	});
    962 	
    963 	
    964 	describe("#_reconcileChanges()", function () {
    965 		describe("items", function () {
    966 			it("should ignore non-conflicting local changes and return remote changes", function () {
    967 				var cacheJSON = {
    968 					key: "AAAAAAAA",
    969 					version: 1234,
    970 					itemType: "book",
    971 					title: "Title 1",
    972 					url: "http://zotero.org/",
    973 					publicationTitle: "Publisher", // Remove locally
    974 					extra: "Extra", // Removed on both
    975 					dateModified: "2015-05-14 12:34:56",
    976 					collections: [
    977 						'AAAAAAAA', // Removed locally
    978 						'DDDDDDDD', // Removed remotely,
    979 						'EEEEEEEE' // Removed from both
    980 					],
    981 					relations: {
    982 						a: 'A', // Unchanged string
    983 						c: ['C1', 'C2'], // Unchanged array
    984 						d: 'D', // String removed locally
    985 						e: ['E'], // Array removed locally
    986 						f: 'F1', // String changed locally
    987 						g: [
    988 							'G1', // Unchanged
    989 							'G2', // Removed remotely
    990 							'G3' // Removed from both
    991 						],
    992 						h: 'H', // String removed remotely
    993 						i: ['I'], // Array removed remotely
    994 					},
    995 					tags: [
    996 						{ tag: 'A' }, // Removed locally
    997 						{ tag: 'D' }, // Removed remotely
    998 						{ tag: 'E' } // Removed from both
    999 					]
   1000 				};
   1001 				var json1 = {
   1002 					key: "AAAAAAAA",
   1003 					version: 1234,
   1004 					itemType: "book",
   1005 					title: "Title 2", // Changed locally
   1006 					url: "https://www.zotero.org/", // Same change on local and remote
   1007 					place: "Place", // Added locally
   1008 					dateModified: "2015-05-14 14:12:34", // Changed locally and remotely, but ignored
   1009 					collections: [
   1010 						'BBBBBBBB', // Added locally
   1011 						'DDDDDDDD',
   1012 						'FFFFFFFF' // Added on both
   1013 					],
   1014 					relations: {
   1015 						'a': 'A',
   1016 						'b': 'B', // String added locally
   1017 						'f': 'F2',
   1018 						'g': [
   1019 							'G1',
   1020 							'G2',
   1021 							'G6' // Added locally and remotely
   1022 						],
   1023 						h: 'H', // String removed remotely
   1024 						i: ['I'], // Array removed remotely
   1025 	
   1026 					},
   1027 					tags: [
   1028 						{ tag: 'B' },
   1029 						{ tag: 'D' },
   1030 						{ tag: 'F', type: 1 }, // Added on both
   1031 						{ tag: 'G' }, // Added on both, but with different types
   1032 						{ tag: 'H', type: 1 } // Added on both, but with different types
   1033 					]
   1034 				};
   1035 				var json2 = {
   1036 					key: "AAAAAAAA",
   1037 					version: 1235,
   1038 					itemType: "book",
   1039 					title: "Title 1",
   1040 					url: "https://www.zotero.org/",
   1041 					publicationTitle: "Publisher",
   1042 					date: "2015-05-15", // Added remotely
   1043 					dateModified: "2015-05-14 13:45:12",
   1044 					collections: [
   1045 						'AAAAAAAA',
   1046 						'CCCCCCCC', // Added remotely
   1047 						'FFFFFFFF'
   1048 					],
   1049 					relations: {
   1050 						'a': 'A',
   1051 						'd': 'D',
   1052 						'e': ['E'],
   1053 						'f': 'F1',
   1054 						'g': [
   1055 							'G1',
   1056 							'G4', // Added remotely
   1057 							'G6'
   1058 						],
   1059 					},
   1060 					tags: [
   1061 						{ tag: 'A' },
   1062 						{ tag: 'C' },
   1063 						{ tag: 'F', type: 1 },
   1064 						{ tag: 'G', type: 1 },
   1065 						{ tag: 'H' }
   1066 					]
   1067 				};
   1068 				var ignoreFields = ['dateAdded', 'dateModified'];
   1069 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1070 					'item', cacheJSON, json1, json2, ignoreFields
   1071 				);
   1072 				assert.sameDeepMembers(
   1073 					result.changes,
   1074 					[
   1075 						{
   1076 							field: "date",
   1077 							op: "add",
   1078 							value: "2015-05-15"
   1079 						},
   1080 						{
   1081 							field: "collections",
   1082 							op: "member-add",
   1083 							value: "CCCCCCCC"
   1084 						},
   1085 						{
   1086 							field: "collections",
   1087 							op: "member-remove",
   1088 							value: "DDDDDDDD"
   1089 						},
   1090 						// Relations
   1091 						{
   1092 							field: "relations",
   1093 							op: "property-member-remove",
   1094 							value: {
   1095 								key: 'g',
   1096 								value: 'G2'
   1097 							}
   1098 						},
   1099 						{
   1100 							field: "relations",
   1101 							op: "property-member-add",
   1102 							value: {
   1103 								key: 'g',
   1104 								value: 'G4'
   1105 							}
   1106 						},
   1107 						{
   1108 							field: "relations",
   1109 							op: "property-member-remove",
   1110 							value: {
   1111 								key: 'h',
   1112 								value: 'H'
   1113 							}
   1114 						},
   1115 						{
   1116 							field: "relations",
   1117 							op: "property-member-remove",
   1118 							value: {
   1119 								key: 'i',
   1120 								value: 'I'
   1121 							}
   1122 						},
   1123 						// Tags
   1124 						{
   1125 							field: "tags",
   1126 							op: "member-add",
   1127 							value: {
   1128 								tag: 'C'
   1129 							}
   1130 						},
   1131 						{
   1132 							field: "tags",
   1133 							op: "member-remove",
   1134 							value: {
   1135 								tag: 'D'
   1136 							}
   1137 						},
   1138 						{
   1139 							field: "tags",
   1140 							op: "member-remove",
   1141 							value: {
   1142 								tag: 'H',
   1143 								type: 1
   1144 							}
   1145 						},
   1146 						{
   1147 							field: "tags",
   1148 							op: "member-add",
   1149 							value: {
   1150 								tag: 'H'
   1151 							}
   1152 						}
   1153 					]
   1154 				);
   1155 				assert.lengthOf(result.conflicts, 0);
   1156 			})
   1157 			
   1158 			it("should return empty arrays when no remote changes to apply", function () {
   1159 				// Similar to above but without differing remote changes
   1160 				var cacheJSON = {
   1161 					key: "AAAAAAAA",
   1162 					version: 1234,
   1163 					itemType: "book",
   1164 					title: "Title 1",
   1165 					url: "http://zotero.org/",
   1166 					publicationTitle: "Publisher", // Remove locally
   1167 					extra: "Extra", // Removed on both
   1168 					dateModified: "2015-05-14 12:34:56",
   1169 					collections: [
   1170 						'AAAAAAAA', // Removed locally
   1171 						'DDDDDDDD',
   1172 						'EEEEEEEE' // Removed from both
   1173 					],
   1174 					tags: [
   1175 						{
   1176 							tag: 'A' // Removed locally
   1177 						},
   1178 						{
   1179 							tag: 'D' // Removed remotely
   1180 						},
   1181 						{
   1182 							tag: 'E' // Removed from both
   1183 						}
   1184 					]
   1185 				};
   1186 				var json1 = {
   1187 					key: "AAAAAAAA",
   1188 					version: 1234,
   1189 					itemType: "book",
   1190 					title: "Title 2", // Changed locally
   1191 					url: "https://www.zotero.org/", // Same change on local and remote
   1192 					place: "Place", // Added locally
   1193 					dateModified: "2015-05-14 14:12:34", // Changed locally and remotely, but ignored
   1194 					collections: [
   1195 						'BBBBBBBB', // Added locally
   1196 						'DDDDDDDD',
   1197 						'FFFFFFFF' // Added on both
   1198 					],
   1199 					tags: [
   1200 						{
   1201 							tag: 'B'
   1202 						},
   1203 						{
   1204 							tag: 'D'
   1205 						},
   1206 						{
   1207 							tag: 'F', // Added on both
   1208 							type: 1
   1209 						},
   1210 						{
   1211 							tag: 'G' // Added on both, but with different types
   1212 						}
   1213 					]
   1214 				};
   1215 				var json2 = {
   1216 					key: "AAAAAAAA",
   1217 					version: 1235,
   1218 					itemType: "book",
   1219 					title: "Title 1",
   1220 					url: "https://www.zotero.org/",
   1221 					publicationTitle: "Publisher",
   1222 					dateModified: "2015-05-14 13:45:12",
   1223 					collections: [
   1224 						'AAAAAAAA',
   1225 						'DDDDDDDD',
   1226 						'FFFFFFFF'
   1227 					],
   1228 					tags: [
   1229 						{
   1230 							tag: 'A'
   1231 						},
   1232 						{
   1233 							tag: 'D'
   1234 						},
   1235 						{
   1236 							tag: 'F',
   1237 							type: 1
   1238 						},
   1239 						{
   1240 							tag: 'G',
   1241 							type: 1
   1242 						}
   1243 					]
   1244 				};
   1245 				var ignoreFields = ['dateAdded', 'dateModified'];
   1246 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1247 					'item', cacheJSON, json1, json2, ignoreFields
   1248 				);
   1249 				assert.lengthOf(result.changes, 0);
   1250 				assert.lengthOf(result.conflicts, 0);
   1251 			})
   1252 			
   1253 			it("should return conflict when changes can't be automatically resolved", function () {
   1254 				var cacheJSON = {
   1255 					key: "AAAAAAAA",
   1256 					version: 1234,
   1257 					title: "Title 1",
   1258 					dateModified: "2015-05-14 12:34:56"
   1259 				};
   1260 				var json1 = {
   1261 					key: "AAAAAAAA",
   1262 					version: 1234,
   1263 					title: "Title 2",
   1264 					dateModified: "2015-05-14 14:12:34"
   1265 				};
   1266 				var json2 = {
   1267 					key: "AAAAAAAA",
   1268 					version: 1235,
   1269 					title: "Title 3",
   1270 					dateModified: "2015-05-14 13:45:12"
   1271 				};
   1272 				var ignoreFields = ['dateAdded', 'dateModified'];
   1273 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1274 					'item', cacheJSON, json1, json2, ignoreFields
   1275 				);
   1276 				Zotero.debug('=-=-=-=');
   1277 				Zotero.debug(result);
   1278 				assert.lengthOf(result.changes, 0);
   1279 				assert.sameDeepMembers(
   1280 					result.conflicts,
   1281 					[
   1282 						[
   1283 							{
   1284 								field: "title",
   1285 								op: "modify",
   1286 								value: "Title 2"
   1287 							},
   1288 							{
   1289 								field: "title",
   1290 								op: "modify",
   1291 								value: "Title 3"
   1292 							}
   1293 						]
   1294 					]
   1295 				);
   1296 			})
   1297 			
   1298 			it("should automatically merge array/object members and generate conflicts for field changes in absence of cached version", function () {
   1299 				var json1 = {
   1300 					key: "AAAAAAAA",
   1301 					version: 1234,
   1302 					itemType: "book",
   1303 					title: "Title",
   1304 					creators: [
   1305 						{
   1306 							name: "Center for History and New Media",
   1307 							creatorType: "author"
   1308 						}
   1309 					],
   1310 					place: "Place", // Local
   1311 					dateModified: "2015-05-14 14:12:34", // Changed on both, but ignored
   1312 					collections: [
   1313 						'AAAAAAAA' // Local
   1314 					],
   1315 					relations: {
   1316 						'a': 'A',
   1317 						'b': 'B', // Local
   1318 						'e': 'E1',
   1319 						'f': [
   1320 							'F1',
   1321 							'F2' // Local
   1322 						],
   1323 						h: 'H', // String removed remotely
   1324 						i: ['I'], // Array removed remotely
   1325 					},
   1326 					tags: [
   1327 						{ tag: 'A' }, // Local
   1328 						{ tag: 'C' },
   1329 						{ tag: 'F', type: 1 },
   1330 						{ tag: 'G' }, // Different types
   1331 						{ tag: 'H', type: 1 } // Different types
   1332 					]
   1333 				};
   1334 				var json2 = {
   1335 					key: "AAAAAAAA",
   1336 					version: 1235,
   1337 					itemType: "book",
   1338 					title: "Title",
   1339 					creators: [
   1340 						{
   1341 							creatorType: "author", // Different property order shouldn't matter
   1342 							name: "Center for History and New Media"
   1343 						}
   1344 					],
   1345 					date: "2015-05-15", // Remote
   1346 					dateModified: "2015-05-14 13:45:12",
   1347 					collections: [
   1348 						'BBBBBBBB' // Remote
   1349 					],
   1350 					relations: {
   1351 						'a': 'A',
   1352 						'c': 'C', // Remote
   1353 						'd': ['D'], // Remote
   1354 						'e': 'E2',
   1355 						'f': [
   1356 							'F1',
   1357 							'F3' // Remote
   1358 						],
   1359 					},
   1360 					tags: [
   1361 						{ tag: 'B' }, // Remote
   1362 						{ tag: 'C' },
   1363 						{ tag: 'F', type: 1 },
   1364 						{ tag: 'G', type: 1 }, // Different types
   1365 						{ tag: 'H' } // Different types
   1366 					]
   1367 				};
   1368 				var ignoreFields = ['dateAdded', 'dateModified'];
   1369 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1370 					'item', false, json1, json2, ignoreFields
   1371 				);
   1372 				Zotero.debug(result);
   1373 				assert.sameDeepMembers(
   1374 					result.changes,
   1375 					[
   1376 						// Collections
   1377 						{
   1378 							field: "collections",
   1379 							op: "member-add",
   1380 							value: "BBBBBBBB"
   1381 						},
   1382 						// Relations
   1383 						{
   1384 							field: "relations",
   1385 							op: "property-member-add",
   1386 							value: {
   1387 								key: 'c',
   1388 								value: 'C'
   1389 							}
   1390 						},
   1391 						{
   1392 							field: "relations",
   1393 							op: "property-member-add",
   1394 							value: {
   1395 								key: 'd',
   1396 								value: 'D'
   1397 							}
   1398 						},
   1399 						{
   1400 							field: "relations",
   1401 							op: "property-member-add",
   1402 							value: {
   1403 								key: 'e',
   1404 								value: 'E2'
   1405 							}
   1406 						},
   1407 						{
   1408 							field: "relations",
   1409 							op: "property-member-add",
   1410 							value: {
   1411 								key: 'f',
   1412 								value: 'F3'
   1413 							}
   1414 						},
   1415 						// Tags
   1416 						{
   1417 							field: "tags",
   1418 							op: "member-add",
   1419 							value: {
   1420 								tag: 'B'
   1421 							}
   1422 						},
   1423 						{
   1424 							field: "tags",
   1425 							op: "member-add",
   1426 							value: {
   1427 								tag: 'G',
   1428 								type: 1
   1429 							}
   1430 						},
   1431 						{
   1432 							field: "tags",
   1433 							op: "member-add",
   1434 							value: {
   1435 								tag: 'H'
   1436 							}
   1437 						}
   1438 					]
   1439 				);
   1440 				assert.sameDeepMembers(
   1441 					result.conflicts,
   1442 					[
   1443 						[
   1444 							{
   1445 								field: "place",
   1446 								op: "add",
   1447 								value: "Place"
   1448 							},
   1449 							{
   1450 								field: "place",
   1451 								op: "delete"
   1452 							}
   1453 						],
   1454 						[
   1455 							{
   1456 								field: "date",
   1457 								op: "delete"
   1458 							},
   1459 							{
   1460 								field: "date",
   1461 								op: "add",
   1462 								value: "2015-05-15"
   1463 							}
   1464 						]
   1465 					]
   1466 				);
   1467 			})
   1468 			
   1469 			it("should automatically use remote version for unresolvable conflicts when both sides are in trash", function () {
   1470 				var cacheJSON = {
   1471 					key: "AAAAAAAA",
   1472 					version: 1234,
   1473 					title: "Title 1",
   1474 					dateModified: "2015-05-14 12:34:56"
   1475 				};
   1476 				var json1 = {
   1477 					key: "AAAAAAAA",
   1478 					version: 1234,
   1479 					title: "Title 2",
   1480 					deleted: true,
   1481 					dateModified: "2015-05-14 14:12:34"
   1482 				};
   1483 				var json2 = {
   1484 					key: "AAAAAAAA",
   1485 					version: 1235,
   1486 					title: "Title 3",
   1487 					deleted: true,
   1488 					dateModified: "2015-05-14 13:45:12"
   1489 				};
   1490 				var ignoreFields = ['dateAdded', 'dateModified'];
   1491 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1492 					'item', cacheJSON, json1, json2, ignoreFields
   1493 				);
   1494 				assert.lengthOf(result.changes, 1);
   1495 				assert.sameDeepMembers(
   1496 					result.changes,
   1497 					[
   1498 						{
   1499 							field: "title",
   1500 							op: "modify",
   1501 							value: "Title 3"
   1502 						},
   1503 					]
   1504 				);
   1505 			});
   1506 			
   1507 			it("should automatically apply inPublications setting from remote", function () {
   1508 				var cacheJSON = {
   1509 					key: "AAAAAAAA",
   1510 					version: 1234,
   1511 					title: "Title 1",
   1512 					dateModified: "2017-04-02 12:34:56"
   1513 				};
   1514 				var json1 = {
   1515 					key: "AAAAAAAA",
   1516 					version: 1234,
   1517 					title: "Title 1",
   1518 					dateModified: "2017-04-02 12:34:56"
   1519 				};
   1520 				var json2 = {
   1521 					key: "AAAAAAAA",
   1522 					version: 1235,
   1523 					title: "Title 1",
   1524 					inPublications: true,
   1525 					dateModified: "2017-04-03 12:34:56"
   1526 				};
   1527 				var ignoreFields = ['dateAdded', 'dateModified'];
   1528 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1529 					'item', cacheJSON, json1, json2, ignoreFields
   1530 				);
   1531 				assert.lengthOf(result.changes, 1);
   1532 				assert.sameDeepMembers(
   1533 					result.changes,
   1534 					[
   1535 						{
   1536 							field: "inPublications",
   1537 							op: "add",
   1538 							value: true
   1539 						}
   1540 					]
   1541 				);
   1542 			});
   1543 		})
   1544 		
   1545 		
   1546 		describe("collections", function () {
   1547 			it("should ignore non-conflicting local changes and return remote changes", function () {
   1548 				var cacheJSON = {
   1549 					key: "AAAAAAAA",
   1550 					version: 1234,
   1551 					name: "Name 1",
   1552 					parentCollection: null,
   1553 					relations: {
   1554 						A: "A", // Removed locally
   1555 						C: "C" // Removed on both
   1556 					}
   1557 				};
   1558 				var json1 = {
   1559 					key: "AAAAAAAA",
   1560 					version: 1234,
   1561 					name: "Name 2", // Changed locally
   1562 					parentCollection: null,
   1563 					relations: {}
   1564 				};
   1565 				var json2 = {
   1566 					key: "AAAAAAAA",
   1567 					version: 1234,
   1568 					name: "Name 1",
   1569 					parentCollection: "BBBBBBBB", // Added remotely
   1570 					relations: {
   1571 						A: "A",
   1572 						B: "B" // Added remotely
   1573 					}
   1574 				};
   1575 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1576 					'collection', cacheJSON, json1, json2
   1577 				);
   1578 				assert.sameDeepMembers(
   1579 					result.changes,
   1580 					[
   1581 						{
   1582 							field: "parentCollection",
   1583 							op: "add",
   1584 							value: "BBBBBBBB"
   1585 						},
   1586 						{
   1587 							field: "relations",
   1588 							op: "property-member-add",
   1589 							value: {
   1590 								key: "B",
   1591 								value: "B"
   1592 							}
   1593 						}
   1594 					]
   1595 				);
   1596 				assert.lengthOf(result.conflicts, 0);
   1597 			})
   1598 			
   1599 			it("should return empty arrays when no remote changes to apply", function () {
   1600 				// Similar to above but without differing remote changes
   1601 				var cacheJSON = {
   1602 					key: "AAAAAAAA",
   1603 					version: 1234,
   1604 					name: "Name 1",
   1605 					conditions: [
   1606 						{
   1607 							condition: "title",
   1608 							operator: "contains",
   1609 							value: "A"
   1610 						},
   1611 						{
   1612 							condition: "place",
   1613 							operator: "is",
   1614 							value: "Chicago"
   1615 						}
   1616 					]
   1617 				};
   1618 				var json1 = {
   1619 					key: "AAAAAAAA",
   1620 					version: 1234,
   1621 					name: "Name 2", // Changed locally
   1622 					conditions: [
   1623 						{
   1624 							condition: "title",
   1625 							operator: "contains",
   1626 							value: "A"
   1627 						},
   1628 						// Added locally
   1629 						{
   1630 							condition: "place",
   1631 							operator: "is",
   1632 							value: "New York"
   1633 						},
   1634 						{
   1635 							condition: "place",
   1636 							operator: "is",
   1637 							value: "Chicago"
   1638 						}
   1639 					]
   1640 				};
   1641 				var json2 = {
   1642 					key: "AAAAAAAA",
   1643 					version: 1234,
   1644 					name: "Name 1",
   1645 					conditions: [
   1646 						{
   1647 							condition: "title",
   1648 							operator: "contains",
   1649 							value: "A"
   1650 						},
   1651 						{
   1652 							condition: "place",
   1653 							operator: "is",
   1654 							value: "Chicago"
   1655 						}
   1656 					]
   1657 				};
   1658 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1659 					'search', cacheJSON, json1, json2
   1660 				);
   1661 				assert.lengthOf(result.changes, 0);
   1662 				assert.lengthOf(result.conflicts, 0);
   1663 			})
   1664 			
   1665 			it("should automatically resolve conflicts with remote version", function () {
   1666 				var cacheJSON = {
   1667 					key: "AAAAAAAA",
   1668 					version: 1234,
   1669 					name: "Name 1"
   1670 				};
   1671 				var json1 = {
   1672 					key: "AAAAAAAA",
   1673 					version: 1234,
   1674 					name: "Name 2"
   1675 				};
   1676 				var json2 = {
   1677 					key: "AAAAAAAA",
   1678 					version: 1234,
   1679 					name: "Name 3"
   1680 				};
   1681 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1682 					'search', cacheJSON, json1, json2
   1683 				);
   1684 				assert.sameDeepMembers(
   1685 					result.changes,
   1686 					[
   1687 						{
   1688 							field: "name",
   1689 							op: "modify",
   1690 							value: "Name 3"
   1691 						}
   1692 					]
   1693 				);
   1694 				assert.lengthOf(result.conflicts, 0);
   1695 			})
   1696 			
   1697 			it("should automatically resolve conflicts in absence of cached version", function () {
   1698 				var json1 = {
   1699 					key: "AAAAAAAA",
   1700 					version: 1234,
   1701 					name: "Name 1",
   1702 					conditions: [
   1703 						{
   1704 							condition: "title",
   1705 							operator: "contains",
   1706 							value: "A"
   1707 						},
   1708 						{
   1709 							condition: "place",
   1710 							operator: "is",
   1711 							value: "New York"
   1712 						}
   1713 					]
   1714 				};
   1715 				var json2 = {
   1716 					key: "AAAAAAAA",
   1717 					version: 1234,
   1718 					name: "Name 2",
   1719 					conditions: [
   1720 						{
   1721 							condition: "title",
   1722 							operator: "contains",
   1723 							value: "A"
   1724 						},
   1725 						{
   1726 							condition: "place",
   1727 							operator: "is",
   1728 							value: "Chicago"
   1729 						}
   1730 					]
   1731 				};
   1732 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1733 					'search', false, json1, json2
   1734 				);
   1735 				assert.sameDeepMembers(
   1736 					result.changes,
   1737 					[
   1738 						{
   1739 							field: "name",
   1740 							op: "modify",
   1741 							value: "Name 2"
   1742 						},
   1743 						{
   1744 							field: "conditions",
   1745 							op: "member-add",
   1746 							value: {
   1747 								condition: "place",
   1748 								operator: "is",
   1749 								value: "Chicago"
   1750 							}
   1751 						}
   1752 					]
   1753 				);
   1754 				assert.lengthOf(result.conflicts, 0);
   1755 			})
   1756 		})
   1757 		
   1758 		
   1759 		describe("searches", function () {
   1760 			it("should ignore non-conflicting local changes and return remote changes", function () {
   1761 				var cacheJSON = {
   1762 					key: "AAAAAAAA",
   1763 					version: 1234,
   1764 					name: "Name 1",
   1765 					conditions: [
   1766 						{
   1767 							condition: "title",
   1768 							operator: "contains",
   1769 							value: "A"
   1770 						},
   1771 						{
   1772 							condition: "place",
   1773 							operator: "is",
   1774 							value: "Chicago"
   1775 						}
   1776 					]
   1777 				};
   1778 				var json1 = {
   1779 					key: "AAAAAAAA",
   1780 					version: 1234,
   1781 					name: "Name 2", // Changed locally
   1782 					conditions: [
   1783 						{
   1784 							condition: "title",
   1785 							operator: "contains",
   1786 							value: "A"
   1787 						},
   1788 						// Removed remotely
   1789 						{
   1790 							condition: "place",
   1791 							operator: "is",
   1792 							value: "Chicago"
   1793 						}
   1794 					]
   1795 				};
   1796 				var json2 = {
   1797 					key: "AAAAAAAA",
   1798 					version: 1234,
   1799 					name: "Name 1",
   1800 					conditions: [
   1801 						{
   1802 							condition: "title",
   1803 							operator: "contains",
   1804 							value: "A"
   1805 						},
   1806 						// Added remotely
   1807 						{
   1808 							condition: "place",
   1809 							operator: "is",
   1810 							value: "New York"
   1811 						}
   1812 					]
   1813 				};
   1814 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1815 					'search', cacheJSON, json1, json2
   1816 				);
   1817 				assert.sameDeepMembers(
   1818 					result.changes,
   1819 					[
   1820 						{
   1821 							field: "conditions",
   1822 							op: "member-add",
   1823 							value: {
   1824 								condition: "place",
   1825 								operator: "is",
   1826 								value: "New York"
   1827 							}
   1828 						},
   1829 						{
   1830 							field: "conditions",
   1831 							op: "member-remove",
   1832 							value: {
   1833 								condition: "place",
   1834 								operator: "is",
   1835 								value: "Chicago"
   1836 							}
   1837 						}
   1838 					]
   1839 				);
   1840 				assert.lengthOf(result.conflicts, 0);
   1841 			})
   1842 			
   1843 			it("should return empty arrays when no remote changes to apply", function () {
   1844 				// Similar to above but without differing remote changes
   1845 				var cacheJSON = {
   1846 					key: "AAAAAAAA",
   1847 					version: 1234,
   1848 					name: "Name 1",
   1849 					conditions: [
   1850 						{
   1851 							condition: "title",
   1852 							operator: "contains",
   1853 							value: "A"
   1854 						},
   1855 						{
   1856 							condition: "place",
   1857 							operator: "is",
   1858 							value: "Chicago"
   1859 						}
   1860 					]
   1861 				};
   1862 				var json1 = {
   1863 					key: "AAAAAAAA",
   1864 					version: 1234,
   1865 					name: "Name 2", // Changed locally
   1866 					conditions: [
   1867 						{
   1868 							condition: "title",
   1869 							operator: "contains",
   1870 							value: "A"
   1871 						},
   1872 						// Added locally
   1873 						{
   1874 							condition: "place",
   1875 							operator: "is",
   1876 							value: "New York"
   1877 						},
   1878 						{
   1879 							condition: "place",
   1880 							operator: "is",
   1881 							value: "Chicago"
   1882 						}
   1883 					]
   1884 				};
   1885 				var json2 = {
   1886 					key: "AAAAAAAA",
   1887 					version: 1234,
   1888 					name: "Name 1",
   1889 					conditions: [
   1890 						{
   1891 							condition: "title",
   1892 							operator: "contains",
   1893 							value: "A"
   1894 						},
   1895 						{
   1896 							condition: "place",
   1897 							operator: "is",
   1898 							value: "Chicago"
   1899 						}
   1900 					]
   1901 				};
   1902 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1903 					'search', cacheJSON, json1, json2
   1904 				);
   1905 				assert.lengthOf(result.changes, 0);
   1906 				assert.lengthOf(result.conflicts, 0);
   1907 			})
   1908 			
   1909 			it("should automatically resolve conflicts with remote version", function () {
   1910 				var cacheJSON = {
   1911 					key: "AAAAAAAA",
   1912 					version: 1234,
   1913 					name: "Name 1"
   1914 				};
   1915 				var json1 = {
   1916 					key: "AAAAAAAA",
   1917 					version: 1234,
   1918 					name: "Name 2"
   1919 				};
   1920 				var json2 = {
   1921 					key: "AAAAAAAA",
   1922 					version: 1234,
   1923 					name: "Name 3"
   1924 				};
   1925 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1926 					'search', cacheJSON, json1, json2
   1927 				);
   1928 				assert.sameDeepMembers(
   1929 					result.changes,
   1930 					[
   1931 						{
   1932 							field: "name",
   1933 							op: "modify",
   1934 							value: "Name 3"
   1935 						}
   1936 					]
   1937 				);
   1938 				assert.lengthOf(result.conflicts, 0);
   1939 			})
   1940 			
   1941 			it("should automatically resolve conflicts in absence of cached version", function () {
   1942 				var json1 = {
   1943 					key: "AAAAAAAA",
   1944 					version: 1234,
   1945 					name: "Name 1",
   1946 					conditions: [
   1947 						{
   1948 							condition: "title",
   1949 							operator: "contains",
   1950 							value: "A"
   1951 						},
   1952 						{
   1953 							condition: "place",
   1954 							operator: "is",
   1955 							value: "New York"
   1956 						}
   1957 					]
   1958 				};
   1959 				var json2 = {
   1960 					key: "AAAAAAAA",
   1961 					version: 1234,
   1962 					name: "Name 2",
   1963 					conditions: [
   1964 						{
   1965 							condition: "title",
   1966 							operator: "contains",
   1967 							value: "A"
   1968 						},
   1969 						{
   1970 							condition: "place",
   1971 							operator: "is",
   1972 							value: "Chicago"
   1973 						}
   1974 					]
   1975 				};
   1976 				var result = Zotero.Sync.Data.Local._reconcileChanges(
   1977 					'search', false, json1, json2
   1978 				);
   1979 				assert.sameDeepMembers(
   1980 					result.changes,
   1981 					[
   1982 						{
   1983 							field: "name",
   1984 							op: "modify",
   1985 							value: "Name 2"
   1986 						},
   1987 						{
   1988 							field: "conditions",
   1989 							op: "member-add",
   1990 							value: {
   1991 								condition: "place",
   1992 								operator: "is",
   1993 								value: "Chicago"
   1994 							}
   1995 						}
   1996 					]
   1997 				);
   1998 				assert.lengthOf(result.conflicts, 0);
   1999 			})
   2000 		})
   2001 	})
   2002 	
   2003 	
   2004 	describe("#reconcileChangesWithoutCache()", function () {
   2005 		it("should return conflict for conflicting fields", function () {
   2006 			var json1 = {
   2007 				key: "AAAAAAAA",
   2008 				version: 1234,
   2009 				title: "Title 1",
   2010 				pages: 10,
   2011 				dateModified: "2015-05-14 14:12:34"
   2012 			};
   2013 			var json2 = {
   2014 				key: "AAAAAAAA",
   2015 				version: 1235,
   2016 				title: "Title 2",
   2017 				place: "New York",
   2018 				dateModified: "2015-05-14 13:45:12"
   2019 			};
   2020 			var ignoreFields = ['dateAdded', 'dateModified'];
   2021 			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
   2022 				'item', json1, json2, ignoreFields
   2023 			);
   2024 			assert.lengthOf(result.changes, 0);
   2025 			assert.sameDeepMembers(
   2026 				result.conflicts,
   2027 				[
   2028 					[
   2029 						{
   2030 							field: "title",
   2031 							op: "add",
   2032 							value: "Title 1"
   2033 						},
   2034 						{
   2035 							field: "title",
   2036 							op: "add",
   2037 							value: "Title 2"
   2038 						}
   2039 					],
   2040 					[
   2041 						{
   2042 							field: "pages",
   2043 							op: "add",
   2044 							value: 10
   2045 						},
   2046 						{
   2047 							field: "pages",
   2048 							op: "delete"
   2049 						}
   2050 					],
   2051 					[
   2052 						{
   2053 							field: "place",
   2054 							op: "delete"
   2055 						},
   2056 						{
   2057 							field: "place",
   2058 							op: "add",
   2059 							value: "New York"
   2060 						}
   2061 					]
   2062 				]
   2063 			);
   2064 		})
   2065 		
   2066 		it("should automatically use remote version for note markup differences when text content matches", function () {
   2067 			var val2 = "<p>Foo bar<br />bar   foo</p>";
   2068 			
   2069 			var json1 = {
   2070 				key: "AAAAAAAA",
   2071 				version: 0,
   2072 				itemType: "note",
   2073 				note: "Foo bar<br/>bar foo",
   2074 				dateModified: "2017-06-13 13:45:12"
   2075 			};
   2076 			var json2 = {
   2077 				key: "AAAAAAAA",
   2078 				version: 5,
   2079 				itemType: "note",
   2080 				note: val2,
   2081 				dateModified: "2017-06-13 13:45:12"
   2082 			};
   2083 			var ignoreFields = ['dateAdded', 'dateModified'];
   2084 			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
   2085 				'item', json1, json2, ignoreFields
   2086 			);
   2087 			assert.lengthOf(result.changes, 1);
   2088 			assert.sameDeepMembers(
   2089 				result.changes,
   2090 				[
   2091 					{
   2092 						field: "note",
   2093 						op: "add",
   2094 						value: val2
   2095 					}
   2096 				]
   2097 			);
   2098 			assert.lengthOf(result.conflicts, 0);
   2099 		});
   2100 		
   2101 		it("should show conflict for note markup differences when text content doesn't match", function () {
   2102 			var json1 = {
   2103 				key: "AAAAAAAA",
   2104 				version: 0,
   2105 				itemType: "note",
   2106 				note: "Foo bar?",
   2107 				dateModified: "2017-06-13 13:45:12"
   2108 			};
   2109 			var json2 = {
   2110 				key: "AAAAAAAA",
   2111 				version: 5,
   2112 				itemType: "note",
   2113 				note: "<p>Foo bar!</p>",
   2114 				dateModified: "2017-06-13 13:45:12"
   2115 			};
   2116 			var ignoreFields = ['dateAdded', 'dateModified'];
   2117 			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
   2118 				'item', json1, json2, ignoreFields
   2119 			);
   2120 			assert.lengthOf(result.changes, 0);
   2121 			assert.lengthOf(result.conflicts, 1);
   2122 		});
   2123 		
   2124 		it("should automatically use remote version for conflicting fields when both sides are in trash", function () {
   2125 			var json1 = {
   2126 				key: "AAAAAAAA",
   2127 				version: 1234,
   2128 				title: "Title 1",
   2129 				pages: 10,
   2130 				deleted: true,
   2131 				dateModified: "2015-05-14 14:12:34"
   2132 			};
   2133 			var json2 = {
   2134 				key: "AAAAAAAA",
   2135 				version: 1235,
   2136 				title: "Title 2",
   2137 				place: "New York",
   2138 				deleted: true,
   2139 				dateModified: "2015-05-14 13:45:12"
   2140 			};
   2141 			var ignoreFields = ['dateAdded', 'dateModified'];
   2142 			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
   2143 				'item', json1, json2, ignoreFields
   2144 			);
   2145 			assert.lengthOf(result.changes, 3);
   2146 			assert.sameDeepMembers(
   2147 				result.changes,
   2148 				[
   2149 					{
   2150 						field: "title",
   2151 						op: "modify",
   2152 						value: "Title 2"
   2153 					},
   2154 					{
   2155 						field: "pages",
   2156 						op: "delete"
   2157 					},
   2158 					{
   2159 						field: "place",
   2160 						op: "add",
   2161 						value: "New York"
   2162 					}
   2163 				]
   2164 			);
   2165 		});
   2166 		
   2167 		it("should automatically use local hyphenated ISBN value if only difference", function () {
   2168 			var json1 = {
   2169 				key: "AAAAAAAA",
   2170 				version: 1234,
   2171 				itemType: "book",
   2172 				ISBN: "978-0-335-22006-9"
   2173 			};
   2174 			var json2 = {
   2175 				key: "AAAAAAAA",
   2176 				version: 1235,
   2177 				itemType: "book",
   2178 				ISBN: "9780335220069"
   2179 			};
   2180 			var ignoreFields = ['dateAdded', 'dateModified'];
   2181 			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
   2182 				'item', json1, json2, ignoreFields
   2183 			);
   2184 			assert.lengthOf(result.changes, 0);
   2185 			assert.lengthOf(result.conflicts, 0);
   2186 			assert.isTrue(result.localChanged);
   2187 		});
   2188 		
   2189 		it("should automatically use remote hyphenated ISBN value if only difference", function () {
   2190 			var json1 = {
   2191 				key: "AAAAAAAA",
   2192 				version: 1234,
   2193 				itemType: "book",
   2194 				ISBN: "9780335220069"
   2195 			};
   2196 			var json2 = {
   2197 				key: "AAAAAAAA",
   2198 				version: 1235,
   2199 				itemType: "book",
   2200 				ISBN: "978-0-335-22006-9"
   2201 			};
   2202 			var ignoreFields = ['dateAdded', 'dateModified'];
   2203 			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
   2204 				'item', json1, json2, ignoreFields
   2205 			);
   2206 			assert.sameDeepMembers(
   2207 				result.changes,
   2208 				[
   2209 					{
   2210 						field: "ISBN",
   2211 						op: "add",
   2212 						value: "978-0-335-22006-9"
   2213 					}
   2214 				]
   2215 			);
   2216 			assert.lengthOf(result.conflicts, 0);
   2217 			assert.isFalse(result.localChanged);
   2218 		});
   2219 	})
   2220 })