www

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

syncEngineTest.js (126866B)


      1 "use strict";
      2 
      3 describe("Zotero.Sync.Data.Engine", function () {
      4 	Components.utils.import("resource://zotero/config.js");
      5 	
      6 	var apiKey = Zotero.Utilities.randomString(24);
      7 	var baseURL = "http://local.zotero/";
      8 	var engine, server, client, caller, stub, spy;
      9 	var userID = 1;
     10 	
     11 	var responses = {};
     12 	
     13 	var setup = Zotero.Promise.coroutine(function* (options = {}) {
     14 		server = sinon.fakeServer.create();
     15 		server.respondImmediately = true;
     16 		var background = options.background === undefined ? true : options.background;
     17 		var stopOnError = options.stopOnError === undefined ?  true : options.stopOnError;
     18 		
     19 		Components.utils.import("resource://zotero/concurrentCaller.js");
     20 		var caller = new ConcurrentCaller(1);
     21 		caller.setLogger(msg => Zotero.debug(msg));
     22 		caller.stopOnError = stopOnError;
     23 		
     24 		var client = new Zotero.Sync.APIClient({
     25 			baseURL,
     26 			apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
     27 			apiKey,
     28 			caller,
     29 			background
     30 		});
     31 		
     32 		var engine = new Zotero.Sync.Data.Engine({
     33 			userID,
     34 			apiClient: client,
     35 			libraryID: options.libraryID || Zotero.Libraries.userLibraryID,
     36 			stopOnError
     37 		});
     38 		
     39 		return { engine, client, caller };
     40 	});
     41 	
     42 	function setResponse(response) {
     43 		setHTTPResponse(server, baseURL, response, responses);
     44 	}
     45 	
     46 	function setDefaultResponses(options = {}) {
     47 		var target = options.target || 'users/1';
     48 		var headers = {
     49 			"Last-Modified-Version": options.libraryVersion || 5
     50 		};
     51 		var lastLibraryVersion = options.lastLibraryVersion || 4;
     52 		setResponse({
     53 			method: "GET",
     54 			url: `${target}/settings?since=${lastLibraryVersion}`,
     55 			status: 200,
     56 			headers,
     57 			json: {}
     58 		});
     59 		setResponse({
     60 			method: "GET",
     61 			url: `${target}/collections?format=versions&since=${lastLibraryVersion}`,
     62 			status: 200,
     63 			headers,
     64 			json: {}
     65 		});
     66 		setResponse({
     67 			method: "GET",
     68 			url: `${target}/searches?format=versions&since=${lastLibraryVersion}`,
     69 			status: 200,
     70 			headers,
     71 			json: {}
     72 		});
     73 		setResponse({
     74 			method: "GET",
     75 			url: `${target}/items/top?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
     76 			status: 200,
     77 			headers,
     78 			json: {}
     79 		});
     80 		setResponse({
     81 			method: "GET",
     82 			url: `${target}/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
     83 			status: 200,
     84 			headers,
     85 			json: {}
     86 		});
     87 		setResponse({
     88 			method: "GET",
     89 			url: `${target}/deleted?since=${lastLibraryVersion}`,
     90 			status: 200,
     91 			headers,
     92 			json: {}
     93 		});
     94 	}
     95 	
     96 	function makeCollectionJSON(options) {
     97 		return {
     98 			key: options.key,
     99 			version: options.version,
    100 			data: {
    101 				key: options.key,
    102 				version: options.version,
    103 				name: options.name,
    104 				parentCollection: options.parentCollection
    105 			}
    106 		};
    107 	}
    108 	
    109 	function makeSearchJSON(options) {
    110 		return {
    111 			key: options.key,
    112 			version: options.version,
    113 			data: {
    114 				key: options.key,
    115 				version: options.version,
    116 				name: options.name,
    117 				conditions: options.conditions ? options.conditions : [
    118 					{
    119 						condition: 'title',
    120 						operator: 'contains',
    121 						value: 'test'
    122 					}
    123 				]
    124 			}
    125 		};
    126 	}
    127 	
    128 	function makeItemJSON(options) {
    129 		var json = {
    130 			key: options.key,
    131 			version: options.version,
    132 			data: {
    133 				key: options.key,
    134 				version: options.version,
    135 				itemType: options.itemType || 'book',
    136 				title: options.title || options.name
    137 			}
    138 		};
    139 		Object.assign(json.data, options);
    140 		delete json.data.name;
    141 		return json;
    142 	}
    143 	
    144 	// Allow functions to be called programmatically
    145 	var makeJSONFunctions = {
    146 		collection: makeCollectionJSON,
    147 		search: makeSearchJSON,
    148 		item: makeItemJSON
    149 	};
    150 	
    151 	var assertInCache = Zotero.Promise.coroutine(function* (obj) {
    152 		var cacheObject = yield Zotero.Sync.Data.Local.getCacheObject(
    153 			obj.objectType, obj.libraryID, obj.key, obj.version
    154 		);
    155 		assert.isObject(cacheObject);
    156 		assert.propertyVal(cacheObject, 'key', obj.key);
    157 	});
    158 	
    159 	var assertNotInCache = Zotero.Promise.coroutine(function* (obj) {
    160 		assert.isFalse(yield Zotero.Sync.Data.Local.getCacheObject(
    161 			obj.objectType, obj.libraryID, obj.key, obj.version
    162 		));
    163 	});
    164 	
    165 	//
    166 	// Tests
    167 	//
    168 	beforeEach(function* () {
    169 		yield resetDB({
    170 			thisArg: this,
    171 			skipBundledFiles: true
    172 		});
    173 		
    174 		Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
    175 		
    176 		yield Zotero.Users.setCurrentUserID(userID);
    177 		yield Zotero.Users.setCurrentUsername("testuser");
    178 	})
    179 	
    180 	after(function () {
    181 		Zotero.HTTP.mock = null;
    182 	});
    183 	
    184 	describe("Syncing", function () {
    185 		it("should download items into a new library", function* () {
    186 			({ engine, client, caller } = yield setup());
    187 			
    188 			var headers = {
    189 				"Last-Modified-Version": 3
    190 			};
    191 			setResponse({
    192 				method: "GET",
    193 				url: "users/1/settings",
    194 				status: 200,
    195 				headers: headers,
    196 				json: {
    197 					tagColors: {
    198 						value: [
    199 							{
    200 								name: "A",
    201 								color: "#CC66CC"
    202 							}
    203 						],
    204 						version: 2
    205 					}
    206 				}
    207 			});
    208 			setResponse({
    209 				method: "GET",
    210 				url: "users/1/collections?format=versions",
    211 				status: 200,
    212 				headers: headers,
    213 				json: {
    214 					"AAAAAAAA": 1
    215 				}
    216 			});
    217 			setResponse({
    218 				method: "GET",
    219 				url: "users/1/searches?format=versions",
    220 				status: 200,
    221 				headers: headers,
    222 				json: {
    223 					"AAAAAAAA": 2
    224 				}
    225 			});
    226 			setResponse({
    227 				method: "GET",
    228 				url: "users/1/items/top?format=versions&includeTrashed=1",
    229 				status: 200,
    230 				headers: headers,
    231 				json: {
    232 					"AAAAAAAA": 3
    233 				}
    234 			});
    235 			setResponse({
    236 				method: "GET",
    237 				url: "users/1/items?format=versions&includeTrashed=1",
    238 				status: 200,
    239 				headers: headers,
    240 				json: {
    241 					"AAAAAAAA": 3,
    242 					"BBBBBBBB": 3
    243 				}
    244 			});
    245 			setResponse({
    246 				method: "GET",
    247 				url: "users/1/collections?format=json&collectionKey=AAAAAAAA",
    248 				status: 200,
    249 				headers: headers,
    250 				json: [
    251 					makeCollectionJSON({
    252 						key: "AAAAAAAA",
    253 						version: 1,
    254 						name: "A"
    255 					})
    256 				]
    257 			});
    258 			setResponse({
    259 				method: "GET",
    260 				url: "users/1/searches?format=json&searchKey=AAAAAAAA",
    261 				status: 200,
    262 				headers: headers,
    263 				json: [
    264 					makeSearchJSON({
    265 						key: "AAAAAAAA",
    266 						version: 2,
    267 						name: "A"
    268 					})
    269 				]
    270 			});
    271 			setResponse({
    272 				method: "GET",
    273 				url: "users/1/items?format=json&itemKey=AAAAAAAA&includeTrashed=1",
    274 				status: 200,
    275 				headers: headers,
    276 				json: [
    277 					makeItemJSON({
    278 						key: "AAAAAAAA",
    279 						version: 3,
    280 						itemType: "book",
    281 						title: "A"
    282 					})
    283 				]
    284 			});
    285 			setResponse({
    286 				method: "GET",
    287 				url: "users/1/items?format=json&itemKey=BBBBBBBB&includeTrashed=1",
    288 				status: 200,
    289 				headers: headers,
    290 				json: [
    291 					makeItemJSON({
    292 						key: "BBBBBBBB",
    293 						version: 3,
    294 						itemType: "note",
    295 						parentItem: "AAAAAAAA",
    296 						note: "This is a note."
    297 					})
    298 				]
    299 			});
    300 			setResponse({
    301 				method: "GET",
    302 				url: "users/1/deleted?since=0",
    303 				status: 200,
    304 				headers: headers,
    305 				json: {}
    306 			});
    307 			yield engine.start();
    308 			
    309 			var userLibraryID = Zotero.Libraries.userLibraryID;
    310 			
    311 			// Check local library version
    312 			assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
    313 			
    314 			// Make sure local objects exist
    315 			var setting = Zotero.SyncedSettings.get(userLibraryID, "tagColors");
    316 			assert.lengthOf(setting, 1);
    317 			assert.equal(setting[0].name, 'A');
    318 			var settingMetadata = Zotero.SyncedSettings.getMetadata(userLibraryID, "tagColors");
    319 			assert.equal(settingMetadata.version, 2);
    320 			assert.isTrue(settingMetadata.synced);
    321 			
    322 			var obj = yield Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
    323 			assert.equal(obj.name, 'A');
    324 			assert.equal(obj.version, 1);
    325 			assert.isTrue(obj.synced);
    326 			yield assertInCache(obj);
    327 			
    328 			obj = yield Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
    329 			assert.equal(obj.name, 'A');
    330 			assert.equal(obj.version, 2);
    331 			assert.isTrue(obj.synced);
    332 			yield assertInCache(obj);
    333 			
    334 			obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
    335 			assert.equal(obj.getField('title'), 'A');
    336 			assert.equal(obj.version, 3);
    337 			assert.isTrue(obj.synced);
    338 			var parentItemID = obj.id;
    339 			yield assertInCache(obj);
    340 			
    341 			obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "BBBBBBBB");
    342 			assert.equal(obj.getNote(), 'This is a note.');
    343 			assert.equal(obj.parentItemID, parentItemID);
    344 			assert.equal(obj.version, 3);
    345 			assert.isTrue(obj.synced);
    346 			yield assertInCache(obj);
    347 		})
    348 		
    349 		it("should download items into a new read-only group", function* () {
    350 			var group = yield createGroup({
    351 				editable: false,
    352 				filesEditable: false
    353 			});
    354 			var libraryID = group.libraryID;
    355 			var itemToDelete = yield createDataObject(
    356 				'item', { libraryID, synced: true }, { skipEditCheck: true }
    357 			)
    358 			var itemToDeleteID = itemToDelete.id;
    359 			
    360 			({ engine, client, caller } = yield setup({ libraryID }));
    361 			
    362 			var headers = {
    363 				"Last-Modified-Version": 3
    364 			};
    365 			setResponse({
    366 				method: "GET",
    367 				url: `groups/${group.id}/settings`,
    368 				status: 200,
    369 				headers: headers,
    370 				json: {
    371 					tagColors: {
    372 						value: [
    373 							{
    374 								name: "A",
    375 								color: "#CC66CC"
    376 							}
    377 						],
    378 						version: 2
    379 					}
    380 				}
    381 			});
    382 			setResponse({
    383 				method: "GET",
    384 				url: `groups/${group.id}/collections?format=versions`,
    385 				status: 200,
    386 				headers: headers,
    387 				json: {
    388 					"AAAAAAAA": 1
    389 				}
    390 			});
    391 			setResponse({
    392 				method: "GET",
    393 				url: `groups/${group.id}/searches?format=versions`,
    394 				status: 200,
    395 				headers: headers,
    396 				json: {
    397 					"AAAAAAAA": 2
    398 				}
    399 			});
    400 			setResponse({
    401 				method: "GET",
    402 				url: `groups/${group.id}/items/top?format=versions&includeTrashed=1`,
    403 				status: 200,
    404 				headers: headers,
    405 				json: {
    406 					"AAAAAAAA": 3
    407 				}
    408 			});
    409 			setResponse({
    410 				method: "GET",
    411 				url: `groups/${group.id}/items?format=versions&includeTrashed=1`,
    412 				status: 200,
    413 				headers: headers,
    414 				json: {
    415 					"AAAAAAAA": 3,
    416 					"BBBBBBBB": 3
    417 				}
    418 			});
    419 			setResponse({
    420 				method: "GET",
    421 				url: `groups/${group.id}/collections?format=json&collectionKey=AAAAAAAA`,
    422 				status: 200,
    423 				headers: headers,
    424 				json: [
    425 					makeCollectionJSON({
    426 						key: "AAAAAAAA",
    427 						version: 1,
    428 						name: "A"
    429 					})
    430 				]
    431 			});
    432 			setResponse({
    433 				method: "GET",
    434 				url: `groups/${group.id}/searches?format=json&searchKey=AAAAAAAA`,
    435 				status: 200,
    436 				headers: headers,
    437 				json: [
    438 					makeSearchJSON({
    439 						key: "AAAAAAAA",
    440 						version: 2,
    441 						name: "A"
    442 					})
    443 				]
    444 			});
    445 			setResponse({
    446 				method: "GET",
    447 				url: `groups/${group.id}/items?format=json&itemKey=AAAAAAAA&includeTrashed=1`,
    448 				status: 200,
    449 				headers: headers,
    450 				json: [
    451 					makeItemJSON({
    452 						key: "AAAAAAAA",
    453 						version: 3,
    454 						itemType: "book",
    455 						title: "A"
    456 					})
    457 				]
    458 			});
    459 			setResponse({
    460 				method: "GET",
    461 				url: `groups/${group.id}/items?format=json&itemKey=BBBBBBBB&includeTrashed=1`,
    462 				status: 200,
    463 				headers: headers,
    464 				json: [
    465 					makeItemJSON({
    466 						key: "BBBBBBBB",
    467 						version: 3,
    468 						itemType: "note",
    469 						parentItem: "AAAAAAAA",
    470 						note: "This is a note."
    471 					})
    472 				]
    473 			});
    474 			setResponse({
    475 				method: "GET",
    476 				url: `groups/${group.id}/deleted?since=0`,
    477 				status: 200,
    478 				headers: headers,
    479 				json: {
    480 					"items": [itemToDelete.key]
    481 				}
    482 			});
    483 			yield engine.start();
    484 			
    485 			// Check local library version
    486 			assert.equal(group.libraryVersion, 3);
    487 			
    488 			// Make sure local objects exist
    489 			var setting = Zotero.SyncedSettings.get(libraryID, "tagColors");
    490 			assert.lengthOf(setting, 1);
    491 			assert.equal(setting[0].name, 'A');
    492 			var settingMetadata = Zotero.SyncedSettings.getMetadata(libraryID, "tagColors");
    493 			assert.equal(settingMetadata.version, 2);
    494 			assert.isTrue(settingMetadata.synced);
    495 			
    496 			var obj = Zotero.Collections.getByLibraryAndKey(libraryID, "AAAAAAAA");
    497 			assert.equal(obj.name, 'A');
    498 			assert.equal(obj.version, 1);
    499 			assert.isTrue(obj.synced);
    500 			yield assertInCache(obj);
    501 			
    502 			obj = Zotero.Searches.getByLibraryAndKey(libraryID, "AAAAAAAA");
    503 			assert.equal(obj.name, 'A');
    504 			assert.equal(obj.version, 2);
    505 			assert.isTrue(obj.synced);
    506 			yield assertInCache(obj);
    507 			
    508 			obj = Zotero.Items.getByLibraryAndKey(libraryID, "AAAAAAAA");
    509 			assert.equal(obj.getField('title'), 'A');
    510 			assert.equal(obj.version, 3);
    511 			assert.isTrue(obj.synced);
    512 			var parentItemID = obj.id;
    513 			yield assertInCache(obj);
    514 			
    515 			obj = Zotero.Items.getByLibraryAndKey(libraryID, "BBBBBBBB");
    516 			assert.equal(obj.getNote(), 'This is a note.');
    517 			assert.equal(obj.parentItemID, parentItemID);
    518 			assert.equal(obj.version, 3);
    519 			assert.isTrue(obj.synced);
    520 			yield assertInCache(obj);
    521 			
    522 			assert.isFalse(Zotero.Items.exists(itemToDeleteID));
    523 		});
    524 		
    525 		it("should upload new full items and subsequent patches", function* () {
    526 			({ engine, client, caller } = yield setup());
    527 			
    528 			var library = Zotero.Libraries.userLibrary;
    529 			var libraryID = library.id;
    530 			var lastLibraryVersion = 5;
    531 			library.libraryVersion = library.storageVersion = lastLibraryVersion;
    532 			yield library.saveTx();
    533 			
    534 			yield Zotero.SyncedSettings.set(libraryID, "testSetting1", { foo: "bar" });
    535 			yield Zotero.SyncedSettings.set(libraryID, "testSetting2", { bar: "foo" });
    536 			
    537 			var types = Zotero.DataObjectUtilities.getTypes();
    538 			var objects = {};
    539 			var objectResponseJSON = {};
    540 			var objectVersions = {};
    541 			for (let type of types) {
    542 				objects[type] = [yield createDataObject(type, { setTitle: true })];
    543 				objectVersions[type] = {};
    544 				objectResponseJSON[type] = objects[type].map(o => o.toResponseJSON());
    545 			}
    546 			
    547 			server.respond(function (req) {
    548 				if (req.method == "POST") {
    549 					assert.equal(
    550 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
    551 					);
    552 					
    553 					// Both settings should be uploaded
    554 					if (req.url == baseURL + "users/1/settings") {
    555 						let json = JSON.parse(req.requestBody);
    556 						assert.lengthOf(Object.keys(json), 2);
    557 						assert.property(json, "testSetting1");
    558 						assert.property(json, "testSetting2");
    559 						assert.property(json.testSetting1, "value");
    560 						assert.property(json.testSetting2, "value");
    561 						assert.propertyVal(json.testSetting1.value, "foo", "bar");
    562 						assert.propertyVal(json.testSetting2.value, "bar", "foo");
    563 						req.respond(
    564 							204,
    565 							{
    566 								"Last-Modified-Version": ++lastLibraryVersion
    567 							},
    568 							""
    569 						);
    570 						return;
    571 					}
    572 					
    573 					for (let type of types) {
    574 						let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
    575 						if (req.url == baseURL + "users/1/" + typePlural) {
    576 							let json = JSON.parse(req.requestBody);
    577 							assert.lengthOf(json, 1);
    578 							assert.equal(json[0].key, objects[type][0].key);
    579 							assert.equal(json[0].version, 0);
    580 							if (type == 'item') {
    581 								assert.equal(json[0].title, objects[type][0].getField('title'));
    582 							}
    583 							else {
    584 								assert.equal(json[0].name, objects[type][0].name);
    585 							}
    586 							let objectJSON = objectResponseJSON[type][0];
    587 							objectJSON.version = ++lastLibraryVersion;
    588 							objectJSON.data.version = lastLibraryVersion;
    589 							req.respond(
    590 								200,
    591 								{
    592 									"Content-Type": "application/json",
    593 									"Last-Modified-Version": lastLibraryVersion
    594 								},
    595 								JSON.stringify({
    596 									successful: {
    597 										"0": objectJSON
    598 									},
    599 									unchanged: {},
    600 									failed: {}
    601 								})
    602 							);
    603 							objectVersions[type][objects[type][0].key] = lastLibraryVersion;
    604 							return;
    605 						}
    606 					}
    607 				}
    608 			})
    609 			
    610 			yield engine.start();
    611 			
    612 			yield Zotero.SyncedSettings.set(libraryID, "testSetting2", { bar: "bar" });
    613 			
    614 			assert.equal(library.libraryVersion, lastLibraryVersion);
    615 			assert.equal(library.storageVersion, lastLibraryVersion);
    616 			for (let type of types) {
    617 				// Make sure objects were set to the correct version and marked as synced
    618 				assert.lengthOf((yield Zotero.Sync.Data.Local.getUnsynced(type, libraryID)), 0);
    619 				let key = objects[type][0].key;
    620 				let version = objects[type][0].version;
    621 				assert.equal(version, objectVersions[type][key]);
    622 				// Make sure uploaded objects were added to cache
    623 				let cached = yield Zotero.Sync.Data.Local.getCacheObject(type, libraryID, key, version);
    624 				assert.typeOf(cached, 'object');
    625 				assert.equal(cached.key, key);
    626 				assert.equal(cached.version, version);
    627 				
    628 				yield modifyDataObject(objects[type][0]);
    629 			}
    630 			
    631 			({ engine, client, caller } = yield setup());
    632 			
    633 			server.respond(function (req) {
    634 				if (req.method == "POST") {
    635 					assert.equal(
    636 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
    637 					);
    638 					
    639 					// Modified setting should be uploaded
    640 					if (req.url == baseURL + "users/1/settings") {
    641 						let json = JSON.parse(req.requestBody);
    642 						assert.lengthOf(Object.keys(json), 1);
    643 						assert.property(json, "testSetting2");
    644 						assert.property(json.testSetting2, "value");
    645 						assert.propertyVal(json.testSetting2.value, "bar", "bar");
    646 						req.respond(
    647 							204,
    648 							{
    649 								"Last-Modified-Version": ++lastLibraryVersion
    650 							},
    651 							""
    652 						);
    653 						return;
    654 					}
    655 					
    656 					for (let type of types) {
    657 						let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
    658 						if (req.url == baseURL + "users/1/" + typePlural) {
    659 							let json = JSON.parse(req.requestBody);
    660 							assert.lengthOf(json, 1);
    661 							let j = json[0];
    662 							let o = objects[type][0];
    663 							assert.equal(j.key, o.key);
    664 							assert.equal(j.version, objectVersions[type][o.key]);
    665 							if (type == 'item') {
    666 								assert.equal(j.title, o.getField('title'));
    667 							}
    668 							else {
    669 								assert.equal(j.name, o.name);
    670 							}
    671 							
    672 							// Verify PATCH semantics instead of POST (i.e., only changed fields)
    673 							let changedFieldsExpected = ['key', 'version'];
    674 							if (type == 'item') {
    675 								changedFieldsExpected.push('title', 'dateModified');
    676 							}
    677 							else {
    678 								changedFieldsExpected.push('name');
    679 							}
    680 							let changedFields = Object.keys(j);
    681 							assert.lengthOf(
    682 								changedFields, changedFieldsExpected.length, "same " + type + " length"
    683 							);
    684 							assert.sameMembers(
    685 								changedFields, changedFieldsExpected, "same " + type + " members"
    686 							);
    687 							let objectJSON = objectResponseJSON[type][0];
    688 							objectJSON.version = ++lastLibraryVersion;
    689 							objectJSON.data.version = lastLibraryVersion;
    690 							req.respond(
    691 								200,
    692 								{
    693 									"Content-Type": "application/json",
    694 									"Last-Modified-Version": lastLibraryVersion
    695 								},
    696 								JSON.stringify({
    697 									successful: {
    698 										"0": objectJSON
    699 									},
    700 									unchanged: {},
    701 									failed: {}
    702 								})
    703 							);
    704 							objectVersions[type][o.key] = lastLibraryVersion;
    705 							return;
    706 						}
    707 					}
    708 				}
    709 			})
    710 			
    711 			yield engine.start();
    712 			
    713 			assert.equal(library.libraryVersion, lastLibraryVersion);
    714 			assert.equal(library.storageVersion, lastLibraryVersion);
    715 			for (let type of types) {
    716 				// Make sure objects were set to the correct version and marked as synced
    717 				assert.lengthOf((yield Zotero.Sync.Data.Local.getUnsynced(type, libraryID)), 0);
    718 				let o = objects[type][0];
    719 				let key = o.key;
    720 				let version = o.version;
    721 				assert.equal(version, objectVersions[type][key]);
    722 				// Make sure uploaded objects were added to cache
    723 				let cached = yield Zotero.Sync.Data.Local.getCacheObject(type, libraryID, key, version);
    724 				assert.typeOf(cached, 'object');
    725 				assert.equal(cached.key, key);
    726 				assert.equal(cached.version, version);
    727 				
    728 				switch (type) {
    729 				case 'collection':
    730 					assert.isFalse(cached.data.parentCollection);
    731 					break;
    732 				
    733 				case 'item':
    734 					assert.equal(cached.data.dateAdded, Zotero.Date.sqlToISO8601(o.dateAdded));
    735 					break;
    736 				
    737 				case 'search':
    738 					assert.isArray(cached.data.conditions);
    739 					break;
    740 				}
    741 				
    742 				// Make sure older versions have been removed from the cache
    743 				let versions = yield Zotero.Sync.Data.Local.getCacheObjectVersions(type, libraryID, key);
    744 				assert.sameMembers(versions, [version]);
    745 			}
    746 		})
    747 		
    748 		
    749 		it("should upload child item after parent item", function* () {
    750 			({ engine, client, caller } = yield setup());
    751 			
    752 			var library = Zotero.Libraries.userLibrary;
    753 			var lastLibraryVersion = 5;
    754 			library.libraryVersion = lastLibraryVersion;
    755 			yield library.saveTx();
    756 			
    757 			// Create top-level note, book, and child note
    758 			var item1 = new Zotero.Item('note');
    759 			item1.setNote('A');
    760 			yield item1.saveTx();
    761 			var item2 = yield createDataObject('item');
    762 			var item3 = new Zotero.Item('note');
    763 			item3.parentItemID = item2.id;
    764 			item3.setNote('B');
    765 			yield item3.saveTx();
    766 			// Move note under parent
    767 			item1.parentItemID = item2.id;
    768 			yield item1.saveTx();
    769 			var handled = false;
    770 			
    771 			server.respond(function (req) {
    772 				if (req.method == "POST" && req.url == baseURL + "users/1/items") {
    773 					let json = JSON.parse(req.requestBody);
    774 					assert.lengthOf(json, 3);
    775 					assert.equal(json[0].key, item2.key);
    776 					assert.equal(json[1].key, item1.key);
    777 					assert.equal(json[2].key, item3.key);
    778 					handled = true;
    779 					req.respond(
    780 						200,
    781 						{
    782 							"Content-Type": "application/json",
    783 							"Last-Modified-Version": ++lastLibraryVersion
    784 						},
    785 						JSON.stringify({
    786 							successful: {
    787 								"0": item2.toResponseJSON({ version: lastLibraryVersion }),
    788 								"1": item1.toResponseJSON({ version: lastLibraryVersion }),
    789 								"2": item3.toResponseJSON({ version: lastLibraryVersion })
    790 							},
    791 							unchanged: {},
    792 							failed: {}
    793 						})
    794 					);
    795 					return;
    796 				}
    797 			});
    798 			
    799 			yield engine.start();
    800 			assert.isTrue(handled);
    801 		});
    802 		
    803 		
    804 		it("should upload child collection after parent collection", function* () {
    805 			({ engine, client, caller } = yield setup());
    806 			
    807 			var library = Zotero.Libraries.userLibrary;
    808 			var lastLibraryVersion = 5;
    809 			library.libraryVersion = lastLibraryVersion;
    810 			yield library.saveTx();
    811 			
    812 			var collection1 = yield createDataObject('collection');
    813 			var collection2 = yield createDataObject('collection');
    814 			var collection3 = yield createDataObject('collection', { parentID: collection2.id });
    815 			// Move collection under the other
    816 			collection1.parentID = collection2.id;
    817 			yield collection1.saveTx();
    818 			
    819 			var handled = false;
    820 			
    821 			server.respond(function (req) {
    822 				if (req.method == "POST" && req.url == baseURL + "users/1/collections") {
    823 					let json = JSON.parse(req.requestBody);
    824 					assert.lengthOf(json, 3);
    825 					assert.equal(json[0].key, collection2.key);
    826 					assert.equal(json[1].key, collection1.key);
    827 					assert.equal(json[2].key, collection3.key);
    828 					handled = true;
    829 					req.respond(
    830 						200,
    831 						{
    832 							"Content-Type": "application/json",
    833 							"Last-Modified-Version": ++lastLibraryVersion
    834 						},
    835 						JSON.stringify({
    836 							successful: {
    837 								"0": collection2.toResponseJSON(),
    838 								"1": collection1.toResponseJSON(),
    839 								"2": collection3.toResponseJSON()
    840 							},
    841 							unchanged: {},
    842 							failed: {}
    843 						})
    844 					);
    845 					return;
    846 				}
    847 			});
    848 			
    849 			yield engine.start();
    850 			assert.isTrue(handled);
    851 		});
    852 		
    853 		
    854 		it("should update library version after settings upload", function* () {
    855 			({ engine, client, caller } = yield setup());
    856 			
    857 			var library = Zotero.Libraries.userLibrary;
    858 			var libraryID = library.id;
    859 			var lastLibraryVersion = 5;
    860 			library.libraryVersion = library.storageVersion = lastLibraryVersion;
    861 			yield library.saveTx();
    862 			
    863 			yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" });
    864 			
    865 			server.respond(function (req) {
    866 				if (req.method == "POST") {
    867 					assert.equal(
    868 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
    869 					);
    870 					
    871 					if (req.url == baseURL + "users/1/settings") {
    872 						let json = JSON.parse(req.requestBody);
    873 						req.respond(
    874 							204,
    875 							{
    876 								"Last-Modified-Version": ++lastLibraryVersion
    877 							},
    878 							""
    879 						);
    880 						return;
    881 					}
    882 				}
    883 			})
    884 			
    885 			yield engine.start();
    886 			
    887 			assert.isAbove(library.libraryVersion, 5);
    888 			assert.equal(library.libraryVersion, lastLibraryVersion);
    889 			assert.equal(library.storageVersion, lastLibraryVersion);
    890 		});
    891 		
    892 		
    893 		it("shouldn't update library storage version after settings upload if storage version was already behind", function* () {
    894 			({ engine, client, caller } = yield setup());
    895 			
    896 			var library = Zotero.Libraries.userLibrary;
    897 			var libraryID = library.id;
    898 			var lastLibraryVersion = 5;
    899 			library.libraryVersion = lastLibraryVersion;
    900 			library.storageVersion = 4;
    901 			yield library.saveTx();
    902 			
    903 			yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" });
    904 			
    905 			server.respond(function (req) {
    906 				if (req.method == "POST") {
    907 					assert.equal(
    908 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
    909 					);
    910 					
    911 					if (req.url == baseURL + "users/1/settings") {
    912 						let json = JSON.parse(req.requestBody);
    913 						req.respond(
    914 							204,
    915 							{
    916 								"Last-Modified-Version": ++lastLibraryVersion
    917 							},
    918 							""
    919 						);
    920 						return;
    921 					}
    922 				}
    923 			})
    924 			
    925 			yield engine.start();
    926 			
    927 			assert.isAbove(library.libraryVersion, 5);
    928 			assert.equal(library.libraryVersion, lastLibraryVersion);
    929 			assert.equal(library.storageVersion, 4);
    930 		});
    931 		
    932 		
    933 		it("shouldn't update library storage version after item upload if storage version was already behind", function* () {
    934 			({ engine, client, caller } = yield setup());
    935 			
    936 			var library = Zotero.Libraries.userLibrary;
    937 			var libraryID = library.id;
    938 			var lastLibraryVersion = 5;
    939 			library.libraryVersion = lastLibraryVersion;
    940 			library.storageVersion = 4;
    941 			yield library.saveTx();
    942 			
    943 			var item = yield createDataObject('item');
    944 			var itemResponseJSON = item.toResponseJSON();
    945 			
    946 			server.respond(function (req) {
    947 				if (req.method == "POST") {
    948 					assert.equal(
    949 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
    950 					);
    951 					
    952 					if (req.url == baseURL + "users/1/items") {
    953 						req.respond(
    954 							200,
    955 							{
    956 								"Content-Type": "application/json",
    957 								"Last-Modified-Version": lastLibraryVersion
    958 							},
    959 							JSON.stringify({
    960 								successful: {
    961 									"0": itemResponseJSON
    962 								},
    963 								unchanged: {},
    964 								failed: {}
    965 							})
    966 						);
    967 						return;
    968 					}
    969 				}
    970 			})
    971 			
    972 			yield engine.start();
    973 			
    974 			assert.equal(library.libraryVersion, lastLibraryVersion);
    975 			assert.equal(library.storageVersion, 4);
    976 		});
    977 		
    978 		
    979 		it("should process downloads after upload failure", function* () {
    980 			({ engine, client, caller } = yield setup({
    981 				stopOnError: false
    982 			}));
    983 			
    984 			var library = Zotero.Libraries.userLibrary;
    985 			var libraryID = library.id;
    986 			var lastLibraryVersion = 5;
    987 			library.libraryVersion = lastLibraryVersion;
    988 			yield library.saveTx();
    989 			
    990 			var collection = yield createDataObject('collection');
    991 			
    992 			var called = 0;
    993 			server.respond(function (req) {
    994 				if (called == 0) {
    995 					req.respond(
    996 						200,
    997 						{
    998 							"Last-Modified-Version": lastLibraryVersion
    999 						},
   1000 						JSON.stringify({
   1001 							successful: {},
   1002 							unchanged: {},
   1003 							failed: {
   1004 								0: {
   1005 									code: 400,
   1006 									message: "Upload failed"
   1007 								}
   1008 							}
   1009 						})
   1010 					);
   1011 				}
   1012 				called++;
   1013 			});
   1014 			
   1015 			var stub = sinon.stub(engine, "_startDownload")
   1016 				.returns(Zotero.Promise.resolve(engine.DOWNLOAD_RESULT_CONTINUE));
   1017 			
   1018 			var e = yield getPromiseError(engine.start());
   1019 			assert.equal(called, 1);
   1020 			// start() should still fail
   1021 			assert.ok(e);
   1022 			assert.equal(e.message, "Made no progress during upload -- stopping");
   1023 			// The collection shouldn't have been marked as synced
   1024 			assert.isFalse(collection.synced);
   1025 			// Download should have been performed
   1026 			assert.ok(stub.called);
   1027 			
   1028 			stub.restore();
   1029 		});
   1030 		
   1031 		
   1032 		it("shouldn't update library storage version if there were storage metadata changes", function* () {
   1033 			({ engine, client, caller } = yield setup());
   1034 			
   1035 			var library = Zotero.Libraries.userLibrary;
   1036 			var lastLibraryVersion = 2;
   1037 			library.libraryVersion = lastLibraryVersion;
   1038 			library.storageVersion = lastLibraryVersion;
   1039 			yield library.saveTx();
   1040 			
   1041 			var target = 'users/1';
   1042 			var newLibraryVersion = 5;
   1043 			var headers = {
   1044 				"Last-Modified-Version": newLibraryVersion
   1045 			};
   1046 			
   1047 			// Create an attachment response with storage metadata
   1048 			var item = new Zotero.Item('attachment');
   1049 			item.attachmentLinkMode = 'imported_file';
   1050 			item.attachmentFilename = 'test.txt';
   1051 			item.attachmentContentType = 'text/plain';
   1052 			item.attachmentCharset = 'utf-8';
   1053 			var itemResponseJSON = item.toResponseJSON();
   1054 			itemResponseJSON.key = itemResponseJSON.data.key = Zotero.DataObjectUtilities.generateKey();
   1055 			itemResponseJSON.version = itemResponseJSON.data.version = newLibraryVersion;
   1056 			itemResponseJSON.data.mtime = new Date().getTime();
   1057 			itemResponseJSON.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
   1058 			
   1059 			setDefaultResponses({
   1060 				target,
   1061 				lastLibraryVersion: lastLibraryVersion,
   1062 				libraryVersion: newLibraryVersion
   1063 			});
   1064 			
   1065 			setResponse({
   1066 				method: "GET",
   1067 				url: `${target}/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
   1068 				status: 200,
   1069 				headers,
   1070 				json: {
   1071 					[item.key]: newLibraryVersion
   1072 				}
   1073 			});
   1074 			
   1075 			setResponse({
   1076 				method: "GET",
   1077 				url: `${target}/items?format=json&itemKey=${item.key}&includeTrashed=1`,
   1078 				status: 200,
   1079 				headers,
   1080 				json: [itemResponseJSON]
   1081 			});
   1082 			
   1083 			yield engine.start();
   1084 			
   1085 			assert.equal(library.libraryVersion, newLibraryVersion);
   1086 			assert.equal(library.storageVersion, lastLibraryVersion);
   1087 		});
   1088 		
   1089 		
   1090 		it("should update library storage version if there were no storage metadata changes and storage version wasn't already behind", function* () {
   1091 			({ engine, client, caller } = yield setup());
   1092 			
   1093 			var library = Zotero.Libraries.userLibrary;
   1094 			var lastLibraryVersion = 2;
   1095 			library.libraryVersion = lastLibraryVersion;
   1096 			library.storageVersion = lastLibraryVersion;
   1097 			yield library.saveTx();
   1098 			
   1099 			var target = 'users/1';
   1100 			var newLibraryVersion = 5;
   1101 			var headers = {
   1102 				"Last-Modified-Version": newLibraryVersion
   1103 			};
   1104 			
   1105 			setDefaultResponses({
   1106 				target,
   1107 				lastLibraryVersion: lastLibraryVersion,
   1108 				libraryVersion: newLibraryVersion
   1109 			});
   1110 			
   1111 			yield engine.start();
   1112 			
   1113 			assert.equal(library.libraryVersion, newLibraryVersion);
   1114 			assert.equal(library.storageVersion, newLibraryVersion);
   1115 		});
   1116 		
   1117 		
   1118 		it("shouldn't update library storage version if there were no storage metadata changes but storage version was already behind", function* () {
   1119 			({ engine, client, caller } = yield setup());
   1120 			
   1121 			var library = Zotero.Libraries.userLibrary;
   1122 			var lastLibraryVersion = 2;
   1123 			library.libraryVersion = lastLibraryVersion;
   1124 			library.storageVersion = 1;
   1125 			yield library.saveTx();
   1126 			
   1127 			var target = 'users/1';
   1128 			var newLibraryVersion = 5;
   1129 			var headers = {
   1130 				"Last-Modified-Version": newLibraryVersion
   1131 			};
   1132 			
   1133 			setDefaultResponses({
   1134 				target,
   1135 				lastLibraryVersion: lastLibraryVersion,
   1136 				libraryVersion: newLibraryVersion
   1137 			});
   1138 			
   1139 			yield engine.start();
   1140 			
   1141 			assert.equal(library.libraryVersion, newLibraryVersion);
   1142 			assert.equal(library.storageVersion, 1);
   1143 		});
   1144 		
   1145 		
   1146 		it("shouldn't include mtime and md5 for attachments in ZFS libraries", function* () {
   1147 			({ engine, client, caller } = yield setup());
   1148 			
   1149 			var library = Zotero.Libraries.userLibrary;
   1150 			var lastLibraryVersion = 2;
   1151 			library.libraryVersion = lastLibraryVersion;
   1152 			yield library.saveTx();
   1153 			
   1154 			var item = new Zotero.Item('attachment');
   1155 			item.attachmentLinkMode = 'imported_file';
   1156 			item.attachmentFilename = 'test.txt';
   1157 			item.attachmentContentType = 'text/plain';
   1158 			item.attachmentCharset = 'utf-8';
   1159 			yield item.saveTx();
   1160 			
   1161 			var itemResponseJSON = item.toResponseJSON();
   1162 			itemResponseJSON.version = itemResponseJSON.data.version = lastLibraryVersion;
   1163 			
   1164 			server.respond(function (req) {
   1165 				if (req.method == "POST") {
   1166 					if (req.url == baseURL + "users/1/items") {
   1167 						let json = JSON.parse(req.requestBody);
   1168 						assert.lengthOf(json, 1);
   1169 						let itemJSON = json[0];
   1170 						assert.equal(itemJSON.key, item.key);
   1171 						assert.equal(itemJSON.version, 0);
   1172 						assert.property(itemJSON, "contentType");
   1173 						assert.property(itemJSON, "charset");
   1174 						assert.property(itemJSON, "filename");
   1175 						assert.notProperty(itemJSON, "mtime");
   1176 						assert.notProperty(itemJSON, "md5");
   1177 						req.respond(
   1178 							200,
   1179 							{
   1180 								"Content-Type": "application/json",
   1181 								"Last-Modified-Version": lastLibraryVersion
   1182 							},
   1183 							JSON.stringify({
   1184 								successful: {
   1185 									"0": itemResponseJSON
   1186 								},
   1187 								unchanged: {},
   1188 								failed: {}
   1189 							})
   1190 						);
   1191 						return;
   1192 					}
   1193 				}
   1194 			})
   1195 			
   1196 			yield engine.start();
   1197 		});
   1198 		
   1199 		
   1200 		it("should include storage properties for attachments in WebDAV libraries", function* () {
   1201 			({ engine, client, caller } = yield setup());
   1202 			
   1203 			var library = Zotero.Libraries.userLibrary;
   1204 			var lastLibraryVersion = 2;
   1205 			library.libraryVersion = lastLibraryVersion;
   1206 			yield library.saveTx();
   1207 			Zotero.Sync.Storage.Local.setModeForLibrary(library.id, 'webdav');
   1208 			
   1209 			var item = new Zotero.Item('attachment');
   1210 			item.attachmentLinkMode = 'imported_file';
   1211 			item.attachmentFilename = 'test.txt';
   1212 			item.attachmentContentType = 'text/plain';
   1213 			item.attachmentCharset = 'utf-8';
   1214 			yield item.saveTx();
   1215 			
   1216 			var itemResponseJSON = item.toResponseJSON();
   1217 			itemResponseJSON.version = itemResponseJSON.data.version = lastLibraryVersion;
   1218 			
   1219 			server.respond(function (req) {
   1220 				if (req.method == "POST") {
   1221 					if (req.url == baseURL + "users/1/items") {
   1222 						let json = JSON.parse(req.requestBody);
   1223 						assert.lengthOf(json, 1);
   1224 						let itemJSON = json[0];
   1225 						assert.equal(itemJSON.key, item.key);
   1226 						assert.equal(itemJSON.version, 0);
   1227 						assert.propertyVal(itemJSON, "contentType", item.attachmentContentType);
   1228 						assert.propertyVal(itemJSON, "charset", item.attachmentCharset);
   1229 						assert.propertyVal(itemJSON, "filename", item.attachmentFilename);
   1230 						assert.propertyVal(itemJSON, "mtime", null);
   1231 						assert.propertyVal(itemJSON, "md5", null);
   1232 						req.respond(
   1233 							200,
   1234 							{
   1235 								"Content-Type": "application/json",
   1236 								"Last-Modified-Version": lastLibraryVersion
   1237 							},
   1238 							JSON.stringify({
   1239 								successful: {
   1240 									"0": itemResponseJSON
   1241 								},
   1242 								unchanged: {},
   1243 								failed: {}
   1244 							})
   1245 						);
   1246 						return;
   1247 					}
   1248 				}
   1249 			})
   1250 			
   1251 			yield engine.start();
   1252 		});
   1253 		
   1254 		
   1255 		it("should include mtime and md5 synced to WebDAV in WebDAV libraries", function* () {
   1256 			({ engine, client, caller } = yield setup());
   1257 			
   1258 			var library = Zotero.Libraries.userLibrary;
   1259 			var lastLibraryVersion = 2;
   1260 			library.libraryVersion = lastLibraryVersion;
   1261 			yield library.saveTx();
   1262 			Zotero.Sync.Storage.Local.setModeForLibrary(library.id, 'webdav');
   1263 			
   1264 			var item = new Zotero.Item('attachment');
   1265 			item.attachmentLinkMode = 'imported_file';
   1266 			item.attachmentFilename = 'test1.txt';
   1267 			yield item.saveTx();
   1268 			
   1269 			var mtime = new Date().getTime();
   1270 			var md5 = '57f8a4fda823187b91e1191487b87fe6';
   1271 			
   1272 			item.attachmentSyncedModificationTime = mtime;
   1273 			item.attachmentSyncedHash = md5;
   1274 			yield item.saveTx({ skipAll: true });
   1275 			
   1276 			var itemResponseJSON = item.toResponseJSON();
   1277 			itemResponseJSON.version = itemResponseJSON.data.version = lastLibraryVersion;
   1278 			itemResponseJSON.data.mtime = mtime;
   1279 			itemResponseJSON.data.md5 = md5;
   1280 			
   1281 			server.respond(function (req) {
   1282 				if (req.method == "POST") {
   1283 					if (req.url == baseURL + "users/1/items") {
   1284 						let json = JSON.parse(req.requestBody);
   1285 						assert.lengthOf(json, 1);
   1286 						let itemJSON = json[0];
   1287 						assert.equal(itemJSON.key, item.key);
   1288 						assert.equal(itemJSON.version, 0);
   1289 						assert.equal(itemJSON.mtime, mtime);
   1290 						assert.equal(itemJSON.md5, md5);
   1291 						req.respond(
   1292 							200,
   1293 							{
   1294 								"Content-Type": "application/json",
   1295 								"Last-Modified-Version": lastLibraryVersion
   1296 							},
   1297 							JSON.stringify({
   1298 								successful: {
   1299 									"0": itemResponseJSON
   1300 								},
   1301 								unchanged: {},
   1302 								failed: {}
   1303 							})
   1304 						);
   1305 						return;
   1306 					}
   1307 				}
   1308 			})
   1309 			
   1310 			yield engine.start();
   1311 			
   1312 			// Check data in cache
   1313 			var json = yield Zotero.Sync.Data.Local.getCacheObject(
   1314 				'item', library.id, item.key, lastLibraryVersion
   1315 			);
   1316 			assert.equal(json.data.mtime, mtime);
   1317 			assert.equal(json.data.md5, md5);
   1318 		})
   1319 		
   1320 		it("should update local objects with remotely saved version after uploading if necessary", function* () {
   1321 			({ engine, client, caller } = yield setup());
   1322 			
   1323 			var library = Zotero.Libraries.userLibrary;
   1324 			var libraryID = library.id;
   1325 			var lastLibraryVersion = 5;
   1326 			library.libraryVersion = lastLibraryVersion;
   1327 			yield library.saveTx();
   1328 			
   1329 			var types = Zotero.DataObjectUtilities.getTypes();
   1330 			var objects = {};
   1331 			var objectResponseJSON = {};
   1332 			var objectNames = {};
   1333 			var itemDateModified = {};
   1334 			for (let type of types) {
   1335 				objects[type] = [
   1336 					yield createDataObject(
   1337 						type, { setTitle: true, dateModified: '2016-05-21 01:00:00' }
   1338 					)
   1339 				];
   1340 				objectNames[type] = {};
   1341 				objectResponseJSON[type] = objects[type].map(o => o.toResponseJSON());
   1342 				if (type == 'item') {
   1343 					let item = objects[type][0];
   1344 					itemDateModified[item.key] = item.dateModified;
   1345 				}
   1346 			}
   1347 			
   1348 			server.respond(function (req) {
   1349 				if (req.method == "POST") {
   1350 					assert.equal(
   1351 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
   1352 					);
   1353 					
   1354 					for (let type of types) {
   1355 						let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
   1356 						if (req.url == baseURL + "users/1/" + typePlural) {
   1357 							let key = objects[type][0].key;
   1358 							let objectJSON = objectResponseJSON[type][0];
   1359 							objectJSON.version = ++lastLibraryVersion;
   1360 							objectJSON.data.version = lastLibraryVersion;
   1361 							let prop = type == 'item' ? 'title' : 'name';
   1362 							objectNames[type][key] = objectJSON.data[prop] = Zotero.Utilities.randomString();
   1363 							req.respond(
   1364 								200,
   1365 								{
   1366 									"Content-Type": "application/json",
   1367 									"Last-Modified-Version": lastLibraryVersion
   1368 								},
   1369 								JSON.stringify({
   1370 									successful: {
   1371 										"0": objectJSON
   1372 									},
   1373 									unchanged: {},
   1374 									failed: {}
   1375 								})
   1376 							);
   1377 							return;
   1378 						}
   1379 					}
   1380 				}
   1381 			})
   1382 			
   1383 			yield engine.start();
   1384 			
   1385 			assert.equal(library.libraryVersion, lastLibraryVersion);
   1386 			for (let type of types) {
   1387 				// Make sure local objects were updated with new metadata and marked as synced
   1388 				assert.lengthOf((yield Zotero.Sync.Data.Local.getUnsynced(type, libraryID)), 0);
   1389 				let o = objects[type][0];
   1390 				let key = o.key;
   1391 				let version = o.version;
   1392 				let name = objectNames[type][key];
   1393 				if (type == 'item') {
   1394 					assert.equal(name, o.getField('title'));
   1395 					
   1396 					// But Date Modified shouldn't have changed for items
   1397 					assert.equal(itemDateModified[key], o.dateModified);
   1398 				}
   1399 				else {
   1400 					assert.equal(name, o.name);
   1401 				}
   1402 			}
   1403 		})
   1404 		
   1405 		it("should upload local deletions", function* () {
   1406 			var { engine, client, caller } = yield setup();
   1407 			var library = Zotero.Libraries.userLibrary;
   1408 			var lastLibraryVersion = 5;
   1409 			library.libraryVersion = library.storageVersion = lastLibraryVersion;
   1410 			yield library.saveTx();
   1411 			
   1412 			
   1413 			var types = Zotero.DataObjectUtilities.getTypes();
   1414 			var objects = {};
   1415 			for (let type of types) {
   1416 				let obj1 = yield createDataObject(type);
   1417 				let obj2 = yield createDataObject(type);
   1418 				objects[type] = [obj1.key, obj2.key];
   1419 				yield obj1.eraseTx();
   1420 				yield obj2.eraseTx();
   1421 			}
   1422 			
   1423 			var count = types.length;
   1424 			
   1425 			server.respond(function (req) {
   1426 				if (req.method == "DELETE") {
   1427 					assert.equal(
   1428 						req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
   1429 					);
   1430 					
   1431 					// TODO: Settings?
   1432 					
   1433 					// Data objects
   1434 					for (let type of types) {
   1435 						let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
   1436 						if (req.url.startsWith(baseURL + "users/1/" + typePlural)) {
   1437 							let matches = req.url.match(new RegExp("\\?" + type + "Key=(.+)"));
   1438 							let keys = decodeURIComponent(matches[1]).split(',');
   1439 							assert.sameMembers(keys, objects[type]);
   1440 							req.respond(
   1441 								204,
   1442 								{
   1443 									"Last-Modified-Version": ++lastLibraryVersion
   1444 								}
   1445 							);
   1446 							count--;
   1447 							return;
   1448 						}
   1449 					}
   1450 				}
   1451 			})
   1452 			
   1453 			yield engine.start();
   1454 			
   1455 			assert.equal(count, 0);
   1456 			for (let type of types) {
   1457 				yield assert.eventually.lengthOf(
   1458 					Zotero.Sync.Data.Local.getDeleted(type, library.id), 0
   1459 				);
   1460 			}
   1461 			assert.equal(library.libraryVersion, lastLibraryVersion);
   1462 			assert.equal(library.storageVersion, lastLibraryVersion);
   1463 		})
   1464 		
   1465 		it("should make only one request if in sync", function* () {
   1466 			var library = Zotero.Libraries.userLibrary;
   1467 			library.libraryVersion = 5;
   1468 			yield library.saveTx();
   1469 			({ engine, client, caller } = yield setup());
   1470 			
   1471 			server.respond(function (req) {
   1472 				if (req.method == "GET" && req.url == baseURL + "users/1/settings?since=5") {
   1473 					let since = req.requestHeaders["If-Modified-Since-Version"];
   1474 					if (since == 5) {
   1475 						req.respond(304);
   1476 						return;
   1477 					}
   1478 				}
   1479 			});
   1480 			yield engine.start();
   1481 		})
   1482 		
   1483 		it("should ignore errors when saving downloaded objects", function* () {
   1484 			({ engine, client, caller } = yield setup({
   1485 				stopOnError: false
   1486 			}));
   1487 			
   1488 			var headers = {
   1489 				"Last-Modified-Version": 3
   1490 			};
   1491 			setResponse({
   1492 				method: "GET",
   1493 				url: "users/1/settings",
   1494 				status: 200,
   1495 				headers: headers,
   1496 				json: {}
   1497 			});
   1498 			setResponse({
   1499 				method: "GET",
   1500 				url: "users/1/collections?format=versions",
   1501 				status: 200,
   1502 				headers: headers,
   1503 				json: {
   1504 					"AAAAAAAA": 1,
   1505 					"BBBBBBBB": 1,
   1506 					"CCCCCCCC": 1
   1507 				}
   1508 			});
   1509 			setResponse({
   1510 				method: "GET",
   1511 				url: "users/1/searches?format=versions",
   1512 				status: 200,
   1513 				headers: headers,
   1514 				json: {
   1515 					"DDDDDDDD": 2,
   1516 					"EEEEEEEE": 2,
   1517 					"FFFFFFFF": 2
   1518 				}
   1519 			});
   1520 			setResponse({
   1521 				method: "GET",
   1522 				url: "users/1/items/top?format=versions&includeTrashed=1",
   1523 				status: 200,
   1524 				headers: headers,
   1525 				json: {
   1526 					"GGGGGGGG": 3,
   1527 					"HHHHHHHH": 3
   1528 				}
   1529 			});
   1530 			setResponse({
   1531 				method: "GET",
   1532 				url: "users/1/items?format=versions&includeTrashed=1",
   1533 				status: 200,
   1534 				headers: headers,
   1535 				json: {
   1536 					"GGGGGGGG": 3,
   1537 					"HHHHHHHH": 3,
   1538 					"JJJJJJJJ": 3
   1539 				}
   1540 			});
   1541 			setResponse({
   1542 				method: "GET",
   1543 				url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC",
   1544 				status: 200,
   1545 				headers: headers,
   1546 				json: [
   1547 					makeCollectionJSON({
   1548 						key: "AAAAAAAA",
   1549 						version: 1,
   1550 						name: "A"
   1551 					}),
   1552 					makeCollectionJSON({
   1553 						key: "BBBBBBBB",
   1554 						version: 1,
   1555 						name: "B",
   1556 						// Missing parent -- collection should be queued
   1557 						parentCollection: "ZZZZZZZZ"
   1558 					}),
   1559 					makeCollectionJSON({
   1560 						key: "CCCCCCCC",
   1561 						version: 1,
   1562 						name: "C",
   1563 						// Unknown field -- should be ignored
   1564 						unknownField: 5
   1565 					})
   1566 				]
   1567 			});
   1568 			setResponse({
   1569 				method: "GET",
   1570 				url: "users/1/searches?format=json&searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF",
   1571 				status: 200,
   1572 				headers: headers,
   1573 				json: [
   1574 					makeSearchJSON({
   1575 						key: "DDDDDDDD",
   1576 						version: 2,
   1577 						name: "D",
   1578 						conditions: [
   1579 							{
   1580 								condition: "title",
   1581 								operator: "is",
   1582 								value: "a"
   1583 							}
   1584 						]
   1585 					}),
   1586 					makeSearchJSON({
   1587 						key: "EEEEEEEE",
   1588 						version: 2,
   1589 						name: "E",
   1590 						conditions: [
   1591 							{
   1592 								// Unknown search condition -- search should be queued
   1593 								condition: "unknownCondition",
   1594 								operator: "is",
   1595 								value: "a"
   1596 							}
   1597 						]
   1598 					}),
   1599 					makeSearchJSON({
   1600 						key: "FFFFFFFF",
   1601 						version: 2,
   1602 						name: "F",
   1603 						conditions: [
   1604 							{
   1605 								condition: "title",
   1606 								// Unknown search operator -- search should be queued
   1607 								operator: "unknownOperator",
   1608 								value: "a"
   1609 							}
   1610 						]
   1611 					})
   1612 				]
   1613 			});
   1614 			setResponse({
   1615 				method: "GET",
   1616 				url: "users/1/items?format=json&itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1",
   1617 				status: 200,
   1618 				headers: headers,
   1619 				json: [
   1620 					makeItemJSON({
   1621 						key: "GGGGGGGG",
   1622 						version: 3,
   1623 						itemType: "book",
   1624 						title: "G",
   1625 						// Unknown item field -- should be ignored
   1626 						unknownField: "B"
   1627 					}),
   1628 					makeItemJSON({
   1629 						key: "HHHHHHHH",
   1630 						version: 3,
   1631 						// Unknown item type -- item should be queued
   1632 						itemType: "unknownItemType",
   1633 						title: "H"
   1634 					})
   1635 				]
   1636 			});
   1637 			setResponse({
   1638 				method: "GET",
   1639 				url: "users/1/items?format=json&itemKey=JJJJJJJJ&includeTrashed=1",
   1640 				status: 200,
   1641 				headers: headers,
   1642 				json: [
   1643 					makeItemJSON({
   1644 						key: "JJJJJJJJ",
   1645 						version: 3,
   1646 						itemType: "note",
   1647 						// Parent that couldn't be saved -- item should be queued
   1648 						parentItem: "HHHHHHHH",
   1649 						note: "This is a note."
   1650 					})
   1651 				]
   1652 			});
   1653 			setResponse({
   1654 				method: "GET",
   1655 				url: "users/1/deleted?since=0",
   1656 				status: 200,
   1657 				headers: headers,
   1658 				json: {}
   1659 			});
   1660 			var spy = sinon.spy(engine, "onError");
   1661 			yield engine.start();
   1662 			
   1663 			var userLibraryID = Zotero.Libraries.userLibraryID;
   1664 			
   1665 			// Library version should have been updated
   1666 			assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
   1667 			
   1668 			// Check for saved objects
   1669 			yield assert.eventually.ok(Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA"));
   1670 			yield assert.eventually.ok(Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "DDDDDDDD"));
   1671 			yield assert.eventually.ok(Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "GGGGGGGG"));
   1672 			
   1673 			// Check for queued objects
   1674 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', userLibraryID);
   1675 			assert.sameMembers(keys, ['BBBBBBBB']);
   1676 			
   1677 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('search', userLibraryID);
   1678 			assert.sameMembers(keys, ['EEEEEEEE', 'FFFFFFFF']);
   1679 			
   1680 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', userLibraryID);
   1681 			assert.sameMembers(keys, ['HHHHHHHH', 'JJJJJJJJ']);
   1682 			
   1683 			assert.equal(spy.callCount, 3);
   1684 		});
   1685 		
   1686 		it("should delay on second upload conflict", function* () {
   1687 			var library = Zotero.Libraries.userLibrary;
   1688 			library.libraryVersion = 5;
   1689 			yield library.saveTx();
   1690 			({ engine, client, caller } = yield setup());
   1691 			
   1692 			// Try to upload, get 412
   1693 			// Download, get new version number
   1694 			// Try to upload again, get 412
   1695 			// Delay
   1696 			// Download, get new version number
   1697 			// Upload, get 200
   1698 			
   1699 			var item = yield createDataObject('item');
   1700 			
   1701 			var lastLibraryVersion = 5;
   1702 			var calls = 0;
   1703 			var t;
   1704 			server.respond(function (req) {
   1705 				if (req.method == "POST") {
   1706 					calls++;
   1707 				}
   1708 				
   1709 				// On first and second upload attempts, return 412
   1710 				if (req.method == "POST" && req.url.startsWith(baseURL + "users/1/items")) {
   1711 					if (calls == 1 || calls == 2) {
   1712 						if (calls == 2) {
   1713 							assert.isAbove(new Date() - t, 50);
   1714 						}
   1715 						t = new Date();
   1716 						req.respond(
   1717 							412,
   1718 							{
   1719 								"Last-Modified-Version": ++lastLibraryVersion
   1720 							},
   1721 							""
   1722 						);
   1723 					}
   1724 					else {
   1725 						req.respond(
   1726 							200,
   1727 							{
   1728 								"Last-Modified-Version": ++lastLibraryVersion
   1729 							},
   1730 							JSON.stringify({
   1731 								successful: {
   1732 									"0": item.toResponseJSON()
   1733 								},
   1734 								unchanged: {},
   1735 								failed: {}
   1736 							})
   1737 						);
   1738 					}
   1739 					return;
   1740 				}
   1741 				if (req.method == "GET") {
   1742 					req.respond(
   1743 						200,
   1744 						{
   1745 							"Last-Modified-Version": lastLibraryVersion
   1746 						},
   1747 						JSON.stringify({})
   1748 					);
   1749 					return;
   1750 				}
   1751 			});
   1752 			
   1753 			Zotero.Sync.Data.conflictDelayIntervals = [50, 70000];
   1754 			yield engine.start();
   1755 			
   1756 			assert.equal(calls, 3);
   1757 			assert.isTrue(item.synced);
   1758 			assert.equal(library.libraryVersion, lastLibraryVersion);
   1759 		});
   1760 	})
   1761 	
   1762 	describe("#_startDownload()", function () {
   1763 		it("shouldn't redownload objects that are already up to date", function* () {
   1764 			var userLibraryID = Zotero.Libraries.userLibraryID;
   1765 			//yield Zotero.Libraries.setVersion(userLibraryID, 5);
   1766 			({ engine, client, caller } = yield setup());
   1767 			
   1768 			var objects = {};
   1769 			for (let type of Zotero.DataObjectUtilities.getTypes()) {
   1770 				let obj = objects[type] = createUnsavedDataObject(type);
   1771 				obj.version = 5;
   1772 				obj.synced = true;
   1773 				yield obj.saveTx({ skipSyncedUpdate: true });
   1774 				
   1775 				yield Zotero.Sync.Data.Local.saveCacheObjects(
   1776 					type,
   1777 					userLibraryID,
   1778 					[
   1779 						{
   1780 							key: obj.key,
   1781 							version: obj.version,
   1782 							data: obj.toJSON()
   1783 						}
   1784 					]
   1785 				);
   1786 			}
   1787 			
   1788 			var json;
   1789 			var headers = {
   1790 				"Last-Modified-Version": 5
   1791 			};
   1792 			setResponse({
   1793 				method: "GET",
   1794 				url: "users/1/settings",
   1795 				status: 200,
   1796 				headers: headers,
   1797 				json: {}
   1798 			});
   1799 			json = {};
   1800 			json[objects.collection.key] = 5;
   1801 			setResponse({
   1802 				method: "GET",
   1803 				url: "users/1/collections?format=versions",
   1804 				status: 200,
   1805 				headers: headers,
   1806 				json: json
   1807 			});
   1808 			json = {};
   1809 			json[objects.search.key] = 5;
   1810 			setResponse({
   1811 				method: "GET",
   1812 				url: "users/1/searches?format=versions",
   1813 				status: 200,
   1814 				headers: headers,
   1815 				json: json
   1816 			});
   1817 			json = {};
   1818 			json[objects.item.key] = 5;
   1819 			setResponse({
   1820 				method: "GET",
   1821 				url: "users/1/items/top?format=versions&includeTrashed=1",
   1822 				status: 200,
   1823 				headers: headers,
   1824 				json: json
   1825 			});
   1826 			json = {};
   1827 			json[objects.item.key] = 5;
   1828 			setResponse({
   1829 				method: "GET",
   1830 				url: "users/1/items?format=versions&includeTrashed=1",
   1831 				status: 200,
   1832 				headers: headers,
   1833 				json: json
   1834 			});
   1835 			setResponse({
   1836 				method: "GET",
   1837 				url: "users/1/deleted?since=0",
   1838 				status: 200,
   1839 				headers: headers,
   1840 				json: {}
   1841 			});
   1842 			
   1843 			yield engine._startDownload();
   1844 		})
   1845 		
   1846 		it("should apply remote deletions", function* () {
   1847 			var library = Zotero.Libraries.userLibrary;
   1848 			library.libraryVersion = 5;
   1849 			yield library.saveTx();
   1850 			({ engine, client, caller } = yield setup());
   1851 			
   1852 			// Create objects and mark them as synced
   1853 			yield Zotero.SyncedSettings.set(
   1854 				library.id, 'tagColors', [{name: 'A', color: '#CC66CC'}], 1, true
   1855 			);
   1856 			var collection = createUnsavedDataObject('collection');
   1857 			collection.synced = true;
   1858 			var collectionID = yield collection.saveTx({ skipSyncedUpdate: true });
   1859 			var collectionKey = collection.key;
   1860 			var search = createUnsavedDataObject('search');
   1861 			search.synced = true;
   1862 			var searchID = yield search.saveTx({ skipSyncedUpdate: true });
   1863 			var searchKey = search.key;
   1864 			var item = createUnsavedDataObject('item');
   1865 			item.synced = true;
   1866 			var itemID = yield item.saveTx({ skipSyncedUpdate: true });
   1867 			var itemKey = item.key;
   1868 			
   1869 			var headers = {
   1870 				"Last-Modified-Version": 6
   1871 			};
   1872 			setResponse({
   1873 				method: "GET",
   1874 				url: "users/1/settings?since=5",
   1875 				status: 200,
   1876 				headers: headers,
   1877 				json: {}
   1878 			});
   1879 			setResponse({
   1880 				method: "GET",
   1881 				url: "users/1/collections?format=versions&since=5",
   1882 				status: 200,
   1883 				headers: headers,
   1884 				json: {}
   1885 			});
   1886 			setResponse({
   1887 				method: "GET",
   1888 				url: "users/1/searches?format=versions&since=5",
   1889 				status: 200,
   1890 				headers: headers,
   1891 				json: {}
   1892 			});
   1893 			setResponse({
   1894 				method: "GET",
   1895 				url: "users/1/items?format=versions&since=5&includeTrashed=1",
   1896 				status: 200,
   1897 				headers: headers,
   1898 				json: {}
   1899 			});
   1900 			setResponse({
   1901 				method: "GET",
   1902 				url: "users/1/items/top?format=versions&since=5&includeTrashed=1",
   1903 				status: 200,
   1904 				headers: headers,
   1905 				json: {}
   1906 			});
   1907 			setResponse({
   1908 				method: "GET",
   1909 				url: "users/1/deleted?since=5",
   1910 				status: 200,
   1911 				headers: headers,
   1912 				json: {
   1913 					settings: ['tagColors'],
   1914 					collections: [collection.key],
   1915 					searches: [search.key],
   1916 					items: [item.key]
   1917 				}
   1918 			});
   1919 			yield engine._startDownload();
   1920 			
   1921 			// Make sure objects were deleted
   1922 			assert.isNull(Zotero.SyncedSettings.get(library.id, 'tagColors'));
   1923 			assert.isFalse(Zotero.Collections.exists(collectionID));
   1924 			assert.isFalse(Zotero.Searches.exists(searchID));
   1925 			assert.isFalse(Zotero.Items.exists(itemID));
   1926 			
   1927 			// Make sure objects weren't added to sync delete log
   1928 			assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
   1929 				'setting', library.id, 'tagColors'
   1930 			));
   1931 			assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
   1932 				'collection', library.id, collectionKey
   1933 			));
   1934 			assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
   1935 				'search', library.id, searchKey
   1936 			));
   1937 			assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
   1938 				'item', library.id, itemKey
   1939 			));
   1940 		})
   1941 		
   1942 		it("should ignore remote deletions for non-item objects if local objects changed", function* () {
   1943 			var library = Zotero.Libraries.userLibrary;
   1944 			library.libraryVersion = 5;
   1945 			yield library.saveTx();
   1946 			({ engine, client, caller } = yield setup());
   1947 			
   1948 			// Create objects marked as unsynced
   1949 			yield Zotero.SyncedSettings.set(
   1950 				library.id, 'tagColors', [{name: 'A', color: '#CC66CC'}]
   1951 			);
   1952 			var collection = createUnsavedDataObject('collection');
   1953 			var collectionID = yield collection.saveTx();
   1954 			var collectionKey = collection.key;
   1955 			var search = createUnsavedDataObject('search');
   1956 			var searchID = yield search.saveTx();
   1957 			var searchKey = search.key;
   1958 			
   1959 			var headers = {
   1960 				"Last-Modified-Version": 6
   1961 			};
   1962 			setResponse({
   1963 				method: "GET",
   1964 				url: "users/1/settings?since=5",
   1965 				status: 200,
   1966 				headers: headers,
   1967 				json: {}
   1968 			});
   1969 			setResponse({
   1970 				method: "GET",
   1971 				url: "users/1/collections?format=versions&since=5",
   1972 				status: 200,
   1973 				headers: headers,
   1974 				json: {}
   1975 			});
   1976 			setResponse({
   1977 				method: "GET",
   1978 				url: "users/1/searches?format=versions&since=5",
   1979 				status: 200,
   1980 				headers: headers,
   1981 				json: {}
   1982 			});
   1983 			setResponse({
   1984 				method: "GET",
   1985 				url: "users/1/items/top?format=versions&since=5&includeTrashed=1",
   1986 				status: 200,
   1987 				headers: headers,
   1988 				json: {}
   1989 			});
   1990 			setResponse({
   1991 				method: "GET",
   1992 				url: "users/1/items?format=versions&since=5&includeTrashed=1",
   1993 				status: 200,
   1994 				headers: headers,
   1995 				json: {}
   1996 			});
   1997 			setResponse({
   1998 				method: "GET",
   1999 				url: "users/1/deleted?since=5",
   2000 				status: 200,
   2001 				headers: headers,
   2002 				json: {
   2003 					settings: ['tagColors'],
   2004 					collections: [collection.key],
   2005 					searches: [search.key],
   2006 					items: []
   2007 				}
   2008 			});
   2009 			yield engine._startDownload();
   2010 			
   2011 			// Make sure objects weren't deleted
   2012 			assert.ok(Zotero.SyncedSettings.get(library.id, 'tagColors'));
   2013 			assert.ok(Zotero.Collections.exists(collectionID));
   2014 			assert.ok(Zotero.Searches.exists(searchID));
   2015 		})
   2016 		
   2017 		it("should show conflict resolution window for conflicting remote deletions", function* () {
   2018 			var library = Zotero.Libraries.userLibrary;
   2019 			library.libraryVersion = 5;
   2020 			yield library.saveTx();
   2021 			({ engine, client, caller } = yield setup());
   2022 			
   2023 			// Create local unsynced items
   2024 			var item = createUnsavedDataObject('item');
   2025 			item.setField('title', 'A');
   2026 			item.synced = false;
   2027 			var itemID1 = yield item.saveTx({ skipSyncedUpdate: true });
   2028 			var itemKey1 = item.key;
   2029 			
   2030 			item = createUnsavedDataObject('item');
   2031 			item.setField('title', 'B');
   2032 			item.synced = false;
   2033 			var itemID2 = yield item.saveTx({ skipSyncedUpdate: true });
   2034 			var itemKey2 = item.key;
   2035 			
   2036 			var headers = {
   2037 				"Last-Modified-Version": 6
   2038 			};
   2039 			setResponse({
   2040 				method: "GET",
   2041 				url: "users/1/settings?since=5",
   2042 				status: 200,
   2043 				headers: headers,
   2044 				json: {}
   2045 			});
   2046 			setResponse({
   2047 				method: "GET",
   2048 				url: "users/1/collections?format=versions&since=5",
   2049 				status: 200,
   2050 				headers: headers,
   2051 				json: {}
   2052 			});
   2053 			setResponse({
   2054 				method: "GET",
   2055 				url: "users/1/searches?format=versions&since=5",
   2056 				status: 200,
   2057 				headers: headers,
   2058 				json: {}
   2059 			});
   2060 			setResponse({
   2061 				method: "GET",
   2062 				url: "users/1/items/top?format=versions&since=5&includeTrashed=1",
   2063 				status: 200,
   2064 				headers: headers,
   2065 				json: {}
   2066 			});
   2067 			setResponse({
   2068 				method: "GET",
   2069 				url: "users/1/items?format=versions&since=5&includeTrashed=1",
   2070 				status: 200,
   2071 				headers: headers,
   2072 				json: {}
   2073 			});
   2074 			setResponse({
   2075 				method: "GET",
   2076 				url: "users/1/deleted?since=5",
   2077 				status: 200,
   2078 				headers: headers,
   2079 				json: {
   2080 					settings: [],
   2081 					collections: [],
   2082 					searches: [],
   2083 					items: [itemKey1, itemKey2]
   2084 				}
   2085 			});
   2086 			
   2087 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   2088 				var doc = dialog.document;
   2089 				var wizard = doc.documentElement;
   2090 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   2091 				
   2092 				// 1 (accept remote deletion)
   2093 				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
   2094 				mergeGroup.rightpane.click();
   2095 				wizard.getButton('next').click();
   2096 				
   2097 				// 2 (ignore remote deletion)
   2098 				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
   2099 				wizard.getButton('finish').click();
   2100 			})
   2101 			yield engine._startDownload();
   2102 			yield crPromise;
   2103 			
   2104 			assert.isFalse(Zotero.Items.exists(itemID1));
   2105 			assert.isTrue(Zotero.Items.exists(itemID2));
   2106 		})
   2107 		
   2108 		
   2109 		it("should handle new remote item referencing locally deleted collection", async function () {
   2110 			var lastLibraryVersion = 5;
   2111 			var newLibraryVersion = 6;
   2112 			var library = Zotero.Libraries.userLibrary;
   2113 			library.libraryVersion = lastLibraryVersion;
   2114 			await library.saveTx();
   2115 			({ engine, client, caller } = await setup());
   2116 			
   2117 			// Create local deleted collection
   2118 			var collection = await createDataObject('collection');
   2119 			var collectionKey = collection.key;
   2120 			await collection.eraseTx();
   2121 			var itemKey = "AAAAAAAA";
   2122 			
   2123 			var headers = {
   2124 				"Last-Modified-Version": newLibraryVersion
   2125 			};
   2126 			setDefaultResponses({
   2127 				lastLibraryVersion,
   2128 				libraryVersion: newLibraryVersion
   2129 			});
   2130 			setResponse({
   2131 				method: "GET",
   2132 				url: `users/1/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
   2133 				status: 200,
   2134 				headers,
   2135 				json: {
   2136 					[itemKey]: newLibraryVersion
   2137 				}
   2138 			});
   2139 			setResponse({
   2140 				method: "GET",
   2141 				url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`,
   2142 				status: 200,
   2143 				headers,
   2144 				json: [
   2145 					makeItemJSON({
   2146 						key: itemKey,
   2147 						version: newLibraryVersion,
   2148 						itemType: "book",
   2149 						collections: [collectionKey]
   2150 					})
   2151 				]
   2152 			});
   2153 			
   2154 			await engine._startDownload();
   2155 			
   2156 			// Item should be skipped and added to queue, which will allow collection deletion to upload
   2157 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
   2158 			assert.sameMembers(keys, [itemKey]);
   2159 			
   2160 			// Collection should not be in sync queue
   2161 			assert.lengthOf(
   2162 				await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', library.id), 0
   2163 			);
   2164 		});
   2165 		
   2166 		
   2167 		it("should handle new remote item referencing locally missing collection", async function () {
   2168 			var lastLibraryVersion = 5;
   2169 			var newLibraryVersion = 6;
   2170 			var library = Zotero.Libraries.userLibrary;
   2171 			library.libraryVersion = lastLibraryVersion;
   2172 			await library.saveTx();
   2173 			({ engine, client, caller } = await setup());
   2174 			
   2175 			var collectionKey = 'AAAAAAAA';
   2176 			var itemKey = 'BBBBBBBB'
   2177 			
   2178 			var headers = {
   2179 				"Last-Modified-Version": newLibraryVersion
   2180 			};
   2181 			setDefaultResponses({
   2182 				lastLibraryVersion,
   2183 				libraryVersion: newLibraryVersion
   2184 			});
   2185 			setResponse({
   2186 				method: "GET",
   2187 				url: `users/1/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
   2188 				status: 200,
   2189 				headers,
   2190 				json: {
   2191 					[itemKey]: newLibraryVersion
   2192 				}
   2193 			});
   2194 			setResponse({
   2195 				method: "GET",
   2196 				url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`,
   2197 				status: 200,
   2198 				headers,
   2199 				json: [
   2200 					makeItemJSON({
   2201 						key: itemKey,
   2202 						version: newLibraryVersion,
   2203 						itemType: "book",
   2204 						collections: [collectionKey]
   2205 					})
   2206 				]
   2207 			});
   2208 			
   2209 			await engine._startDownload();
   2210 			
   2211 			// Item should be skipped and added to queue
   2212 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
   2213 			assert.sameMembers(keys, [itemKey]);
   2214 			
   2215 			// Collection should be in queue
   2216 			assert.sameMembers(
   2217 				await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', library.id),
   2218 				[collectionKey]
   2219 			);
   2220 		});
   2221 		
   2222 		
   2223 		it("should handle conflict with remote item referencing deleted local collection", async function () {
   2224 			var lastLibraryVersion = 5;
   2225 			var newLibraryVersion = 6;
   2226 			var library = Zotero.Libraries.userLibrary;
   2227 			library.libraryVersion = lastLibraryVersion;
   2228 			await library.saveTx();
   2229 			({ engine, client, caller } = await setup());
   2230 			
   2231 			// Create local deleted collection and item
   2232 			var collection = await createDataObject('collection');
   2233 			var collectionKey = collection.key;
   2234 			await collection.eraseTx();
   2235 			var item = await createDataObject('item');
   2236 			var itemResponseJSON = item.toResponseJSON();
   2237 			// Add collection to remote item
   2238 			itemResponseJSON.data.collections = [collectionKey];
   2239 			var itemKey = item.key;
   2240 			await item.eraseTx();
   2241 			
   2242 			var headers = {
   2243 				"Last-Modified-Version": newLibraryVersion
   2244 			};
   2245 			setDefaultResponses({
   2246 				lastLibraryVersion,
   2247 				libraryVersion: newLibraryVersion
   2248 			});
   2249 			setResponse({
   2250 				method: "GET",
   2251 				url: `users/1/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
   2252 				status: 200,
   2253 				headers,
   2254 				json: {
   2255 					[itemKey]: newLibraryVersion
   2256 				}
   2257 			});
   2258 			setResponse({
   2259 				method: "GET",
   2260 				url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`,
   2261 				status: 200,
   2262 				headers,
   2263 				json: [itemResponseJSON]
   2264 			});
   2265 			
   2266 			await engine._startDownload();
   2267 			
   2268 			// Item should be skipped and added to queue, which will allow collection deletion to upload
   2269 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
   2270 			assert.sameMembers(keys, [itemKey]);
   2271 		});
   2272 		
   2273 		
   2274 		it("should handle cancellation of conflict resolution window", function* () {
   2275 			var library = Zotero.Libraries.userLibrary;
   2276 			library.libraryVersion = 5;
   2277 			yield library.saveTx();
   2278 			({ engine, client, caller } = yield setup());
   2279 			
   2280 			var item = yield createDataObject('item');
   2281 			var itemID = yield item.saveTx();
   2282 			var itemKey = item.key;
   2283 			
   2284 			var headers = {
   2285 				"Last-Modified-Version": 6
   2286 			};
   2287 			setResponse({
   2288 				method: "GET",
   2289 				url: "users/1/settings?since=5",
   2290 				status: 200,
   2291 				headers: headers,
   2292 				json: {}
   2293 			});
   2294 			setResponse({
   2295 				method: "GET",
   2296 				url: "users/1/collections?format=versions&since=5",
   2297 				status: 200,
   2298 				headers: headers,
   2299 				json: {}
   2300 			});
   2301 			setResponse({
   2302 				method: "GET",
   2303 				url: "users/1/searches?format=versions&since=5",
   2304 				status: 200,
   2305 				headers: headers,
   2306 				json: {}
   2307 			});
   2308 			setResponse({
   2309 				method: "GET",
   2310 				url: "users/1/items/top?format=versions&since=5&includeTrashed=1",
   2311 				status: 200,
   2312 				headers: headers,
   2313 				json: {
   2314 					AAAAAAAA: 6,
   2315 					[itemKey]: 6
   2316 				}
   2317 			});
   2318 			setResponse({
   2319 				method: "GET",
   2320 				url: `users/1/items?format=json&itemKey=AAAAAAAA%2C${itemKey}&includeTrashed=1`,
   2321 				status: 200,
   2322 				headers: headers,
   2323 				json: [
   2324 					makeItemJSON({
   2325 						key: "AAAAAAAA",
   2326 						version: 6,
   2327 						itemType: "book",
   2328 						title: "B"
   2329 					}),
   2330 					makeItemJSON({
   2331 						key: itemKey,
   2332 						version: 6,
   2333 						itemType: "book",
   2334 						title: "B"
   2335 					})
   2336 				]
   2337 			});
   2338 			setResponse({
   2339 				method: "GET",
   2340 				url: "users/1/items?format=versions&since=5&includeTrashed=1",
   2341 				status: 200,
   2342 				headers: headers,
   2343 				json: {}
   2344 			});
   2345 			setResponse({
   2346 				method: "GET",
   2347 				url: "users/1/deleted?since=5",
   2348 				status: 200,
   2349 				headers: headers,
   2350 				json: {
   2351 					settings: [],
   2352 					collections: [],
   2353 					searches: [],
   2354 					items: []
   2355 				}
   2356 			});
   2357 			
   2358 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   2359 				var doc = dialog.document;
   2360 				var wizard = doc.documentElement;
   2361 				wizard.getButton('cancel').click();
   2362 			})
   2363 			var e = yield getPromiseError(engine._startDownload());
   2364 			yield crPromise
   2365 			assert.isTrue(e instanceof Zotero.Sync.UserCancelledException);
   2366 			
   2367 			// Non-conflicted item should be saved
   2368 			assert.ok(Zotero.Items.getIDFromLibraryAndKey(library.id, "AAAAAAAA"));
   2369 			
   2370 			// Conflicted item should be skipped and in queue
   2371 			assert.isFalse(Zotero.Items.exists(itemID));
   2372 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
   2373 			assert.sameMembers(keys, [itemKey]);
   2374 			
   2375 			// Library version should not have advanced
   2376 			assert.equal(library.libraryVersion, 5);
   2377 		});
   2378 		
   2379 		
   2380 		/**
   2381 		 * The CR window for remote deletions is triggered separately, so test separately
   2382 		 */
   2383 		it("should handle cancellation of remote deletion conflict resolution window", function* () {
   2384 			var library = Zotero.Libraries.userLibrary;
   2385 			library.libraryVersion = 5;
   2386 			yield library.saveTx();
   2387 			({ engine, client, caller } = yield setup());
   2388 			
   2389 			// Create local unsynced items
   2390 			var item = createUnsavedDataObject('item');
   2391 			item.setField('title', 'A');
   2392 			item.synced = false;
   2393 			var itemID1 = yield item.saveTx();
   2394 			var itemKey1 = item.key;
   2395 			
   2396 			item = createUnsavedDataObject('item');
   2397 			item.setField('title', 'B');
   2398 			item.synced = false;
   2399 			var itemID2 = yield item.saveTx();
   2400 			var itemKey2 = item.key;
   2401 			
   2402 			var headers = {
   2403 				"Last-Modified-Version": 6
   2404 			};
   2405 			setResponse({
   2406 				method: "GET",
   2407 				url: "users/1/settings?since=5",
   2408 				status: 200,
   2409 				headers,
   2410 				json: {}
   2411 			});
   2412 			setResponse({
   2413 				method: "GET",
   2414 				url: "users/1/collections?format=versions&since=5",
   2415 				status: 200,
   2416 				headers,
   2417 				json: {}
   2418 			});
   2419 			setResponse({
   2420 				method: "GET",
   2421 				url: "users/1/searches?format=versions&since=5",
   2422 				status: 200,
   2423 				headers,
   2424 				json: {}
   2425 			});
   2426 			setResponse({
   2427 				method: "GET",
   2428 				url: "users/1/items/top?format=versions&since=5&includeTrashed=1",
   2429 				status: 200,
   2430 				headers,
   2431 				json: {}
   2432 			});
   2433 			setResponse({
   2434 				method: "GET",
   2435 				url: "users/1/items?format=versions&since=5&includeTrashed=1",
   2436 				status: 200,
   2437 				headers,
   2438 				json: {}
   2439 			});
   2440 			setResponse({
   2441 				method: "GET",
   2442 				url: "users/1/deleted?since=5",
   2443 				status: 200,
   2444 				headers,
   2445 				json: {
   2446 					settings: [],
   2447 					collections: [],
   2448 					searches: [],
   2449 					items: [itemKey1, itemKey2]
   2450 				}
   2451 			});
   2452 			
   2453 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   2454 				var doc = dialog.document;
   2455 				var wizard = doc.documentElement;
   2456 				wizard.getButton('cancel').click();
   2457 			})
   2458 			var e = yield getPromiseError(engine._startDownload());
   2459 			yield crPromise;
   2460 			assert.isTrue(e instanceof Zotero.Sync.UserCancelledException);
   2461 			
   2462 			// Conflicted items should still exists
   2463 			assert.isTrue(Zotero.Items.exists(itemID1));
   2464 			assert.isTrue(Zotero.Items.exists(itemID2));
   2465 			
   2466 			// Library version should not have advanced
   2467 			assert.equal(library.libraryVersion, 5);
   2468 		});
   2469 		
   2470 		it("should restart if remote library version changes", function* () {
   2471 			var library = Zotero.Libraries.userLibrary;
   2472 			library.libraryVersion = 5;
   2473 			yield library.saveTx();
   2474 			({ engine, client, caller } = yield setup());
   2475 			
   2476 			var lastLibraryVersion = 5;
   2477 			var calls = 0;
   2478 			var t;
   2479 			server.respond(function (req) {
   2480 				if (req.url.startsWith(baseURL + "users/1/settings")) {
   2481 					calls++;
   2482 					if (calls == 2) {
   2483 						assert.isAbove(new Date() - t, 50);
   2484 					}
   2485 					t = new Date();
   2486 					req.respond(
   2487 						200,
   2488 						{
   2489 							"Last-Modified-Version": ++lastLibraryVersion
   2490 						},
   2491 						JSON.stringify({})
   2492 					);
   2493 					return;
   2494 				}
   2495 				else if (req.url.startsWith(baseURL + "users/1/searches")) {
   2496 					if (calls == 1) {
   2497 						t = new Date();
   2498 						req.respond(
   2499 							200,
   2500 							{
   2501 								// On the first pass, return a later library version to simulate data
   2502 								// being updated by a concurrent upload
   2503 								"Last-Modified-Version": lastLibraryVersion + 1
   2504 							},
   2505 							JSON.stringify([])
   2506 						);
   2507 						return;
   2508 					}
   2509 				}
   2510 				else if (req.url.startsWith(baseURL + "users/1/items")) {
   2511 					// Since /searches is called before /items and it should cause a reset,
   2512 					// /items shouldn't be called until the second pass
   2513 					if (calls < 1) {
   2514 						throw new Error("/users/1/items called in first pass");
   2515 					}
   2516 				}
   2517 				
   2518 				t = new Date();
   2519 				req.respond(
   2520 					200,
   2521 					{
   2522 						"Last-Modified-Version": lastLibraryVersion
   2523 					},
   2524 					JSON.stringify([])
   2525 				);
   2526 			});
   2527 			
   2528 			Zotero.Sync.Data.conflictDelayIntervals = [50, 70000];
   2529 			yield engine._startDownload();
   2530 			
   2531 			assert.equal(calls, 2);
   2532 			assert.equal(library.libraryVersion, lastLibraryVersion);
   2533 		});
   2534 	});
   2535 	
   2536 	
   2537 	describe("#_downloadUpdatedObjects()", function () {
   2538 		it("should include objects in sync queue", function* () {
   2539 			({ engine, client, caller } = yield setup());
   2540 			
   2541 			var libraryID = Zotero.Libraries.userLibraryID;
   2542 			var objectType = 'collection';
   2543 			var objectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   2544 			yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(
   2545 				objectType, libraryID, ["BBBBBBBB", "CCCCCCCC"]
   2546 			);
   2547 			yield Zotero.DB.queryAsync(
   2548 				"UPDATE syncQueue SET lastCheck=lastCheck-3600 "
   2549 					+ "WHERE syncObjectTypeID=? AND libraryID=? AND key IN (?, ?)",
   2550 				[objectTypeID, libraryID, 'BBBBBBBB', 'CCCCCCCC']
   2551 			);
   2552 			
   2553 			var headers = {
   2554 				"Last-Modified-Version": 5
   2555 			};
   2556 			setResponse({
   2557 				method: "GET",
   2558 				url: "users/1/collections?format=versions&since=1",
   2559 				status: 200,
   2560 				headers,
   2561 				json: {
   2562 					AAAAAAAA: 5,
   2563 					BBBBBBBB: 5
   2564 				}
   2565 			});
   2566 			
   2567 			var stub = sinon.stub(engine, "_downloadObjects");
   2568 			
   2569 			yield engine._downloadUpdatedObjects(objectType, 1, 5);
   2570 			
   2571 			assert.ok(stub.calledWith("collection", ["AAAAAAAA", "BBBBBBBB", "CCCCCCCC"]));
   2572 			stub.restore();
   2573 		});
   2574 	});
   2575 	
   2576 	
   2577 	describe("#_downloadObjects()", function () {
   2578 		it("should remove object from sync queue if missing from response", function* () {
   2579 			({ engine, client, caller } = yield setup({
   2580 				stopOnError: false
   2581 			}));
   2582 			var libraryID = Zotero.Libraries.userLibraryID;
   2583 			var objectType = 'collection';
   2584 			var objectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   2585 			yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(
   2586 				objectType, libraryID, ["BBBBBBBB", "CCCCCCCC"]
   2587 			);
   2588 			
   2589 			var headers = {
   2590 				"Last-Modified-Version": 5
   2591 			};
   2592 			setResponse({
   2593 				method: "GET",
   2594 				url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC",
   2595 				status: 200,
   2596 				headers,
   2597 				json: [
   2598 					makeCollectionJSON({
   2599 						key: "AAAAAAAA",
   2600 						version: 5,
   2601 						name: "A"
   2602 					}),
   2603 					makeCollectionJSON({
   2604 						key: "BBBBBBBB",
   2605 						version: 5
   2606 						// Missing 'name', which causes a save error
   2607 					})
   2608 				]
   2609 			});
   2610 			yield engine._downloadObjects(objectType, ["AAAAAAAA", "BBBBBBBB", "CCCCCCCC"]);
   2611 			
   2612 			// Missing object should have been removed, but invalid object should remain
   2613 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
   2614 			assert.sameMembers(keys, ['BBBBBBBB']);
   2615 		});
   2616 		
   2617 		
   2618 		it("should add items that exist remotely in a locally deleted, remotely modified collection back to collection", async function () {
   2619 			({ engine, client, caller } = await setup({
   2620 				stopOnError: false
   2621 			}));
   2622 			var libraryID = Zotero.Libraries.userLibraryID;
   2623 			
   2624 			var collection = await createDataObject('collection');
   2625 			var collectionKey = collection.key;
   2626 			await collection.eraseTx();
   2627 			var item1 = await createDataObject('item');
   2628 			var item2 = await createDataObject('item', { deleted: true });
   2629 			
   2630 			var headers = {
   2631 				"Last-Modified-Version": 5
   2632 			};
   2633 			setResponse({
   2634 				method: "GET",
   2635 				url: `users/1/collections?format=json&collectionKey=${collectionKey}`,
   2636 				status: 200,
   2637 				headers,
   2638 				json: [
   2639 					makeCollectionJSON({
   2640 						key: collectionKey,
   2641 						version: 5,
   2642 						name: "A"
   2643 					})
   2644 				]
   2645 			});
   2646 			setResponse({
   2647 				method: "GET",
   2648 				url: `users/1/collections/${collectionKey}/items/top?format=keys`,
   2649 				status: 200,
   2650 				headers,
   2651 				text: item1.key + "\n" + item2.key + "\n"
   2652 			});
   2653 			await engine._downloadObjects('collection', [collectionKey]);
   2654 			
   2655 			var collection = Zotero.Collections.getByLibraryAndKey(libraryID, collectionKey);
   2656 			assert.sameMembers(collection.getChildItems(true), [item1.id, item2.id]);
   2657 			// Item should be removed from trash
   2658 			assert.isFalse(item2.deleted);
   2659 		});
   2660 		
   2661 		
   2662 		it("should add locally deleted items that exist remotely in a locally deleted, remotely modified collection to sync queue and remove from delete log", async function () {
   2663 			({ engine, client, caller } = await setup({
   2664 				stopOnError: false
   2665 			}));
   2666 			var libraryID = Zotero.Libraries.userLibraryID;
   2667 			
   2668 			var collection = await createDataObject('collection');
   2669 			var collectionKey = collection.key;
   2670 			await collection.eraseTx();
   2671 			var item = await createDataObject('item');
   2672 			await item.eraseTx();
   2673 			
   2674 			var headers = {
   2675 				"Last-Modified-Version": 5
   2676 			};
   2677 			setResponse({
   2678 				method: "GET",
   2679 				url: `users/1/collections?format=json&collectionKey=${collectionKey}`,
   2680 				status: 200,
   2681 				headers,
   2682 				json: [
   2683 					makeCollectionJSON({
   2684 						key: collectionKey,
   2685 						version: 5,
   2686 						name: "A"
   2687 					})
   2688 				]
   2689 			});
   2690 			setResponse({
   2691 				method: "GET",
   2692 				url: `users/1/collections/${collectionKey}/items/top?format=keys`,
   2693 				status: 200,
   2694 				headers,
   2695 				text: item.key + "\n"
   2696 			});
   2697 			await engine._downloadObjects('collection', [collectionKey]);
   2698 			
   2699 			var collection = Zotero.Collections.getByLibraryAndKey(libraryID, collectionKey);
   2700 			
   2701 			assert.sameMembers(
   2702 				await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID),
   2703 				[item.key]
   2704 			);
   2705 			assert.isFalse(
   2706 				await Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, item.key)
   2707 			);
   2708 		});
   2709 	});
   2710 	
   2711 	
   2712 	describe("#_startUpload()", function () {
   2713 		it("shouldn't upload unsynced objects if present in sync queue", function* () {
   2714 			({ engine, client, caller } = yield setup());
   2715 			var libraryID = Zotero.Libraries.userLibraryID;
   2716 			var objectType = 'item';
   2717 			var obj = yield createDataObject(objectType);
   2718 			yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, libraryID, [obj.key]);
   2719 			var result = yield engine._startUpload();
   2720 			assert.equal(result, engine.UPLOAD_RESULT_NOTHING_TO_UPLOAD);
   2721 		});
   2722 		
   2723 		
   2724 		it("should prompt to reset library on 403 write response and reset on accept", function* () {
   2725 			var group = yield createGroup({
   2726 				libraryVersion: 5
   2727 			});
   2728 			var libraryID = group.libraryID;
   2729 			({ engine, client, caller } = yield setup({ libraryID }));
   2730 			
   2731 			var item = createUnsavedDataObject('item');
   2732 			item.libraryID = libraryID;
   2733 			item.setField('title', 'A');
   2734 			item.synced = false;
   2735 			var itemID = yield item.saveTx();
   2736 			
   2737 			var headers = {
   2738 				"Last-Modified-Version": 5
   2739 			};
   2740 			setResponse({
   2741 				method: "POST",
   2742 				url: `groups/${group.id}/items`,
   2743 				status: 403,
   2744 				headers,
   2745 				text: ""
   2746 			})
   2747 			
   2748 			var promise = waitForDialog(function (dialog) {
   2749 				var text = dialog.document.documentElement.textContent;
   2750 				assert.include(text, group.name);
   2751 			});
   2752 			
   2753 			var result = yield engine._startUpload();
   2754 			assert.equal(result, engine.UPLOAD_RESULT_RESTART);
   2755 			
   2756 			assert.isFalse(Zotero.Items.exists(itemID));
   2757 			
   2758 			// Library version should have been reset to trigger full sync
   2759 			assert.equal(group.libraryVersion, -1);
   2760 		});
   2761 		
   2762 		
   2763 		it("should prompt to reset library on 403 write response and skip on cancel", function* () {
   2764 			var group = yield createGroup({
   2765 				libraryVersion: 5
   2766 			});
   2767 			var libraryID = group.libraryID;
   2768 			({ engine, client, caller } = yield setup({ libraryID }));
   2769 			
   2770 			var item = createUnsavedDataObject('item');
   2771 			item.libraryID = libraryID;
   2772 			item.setField('title', 'A');
   2773 			item.synced = false;
   2774 			var itemID = yield item.saveTx();
   2775 			
   2776 			var headers = {
   2777 				"Last-Modified-Version": 5
   2778 			};
   2779 			setResponse({
   2780 				method: "POST",
   2781 				url: `groups/${group.id}/items`,
   2782 				status: 403,
   2783 				headers,
   2784 				text: ""
   2785 			})
   2786 			
   2787 			var promise = waitForDialog(function (dialog) {
   2788 				var text = dialog.document.documentElement.textContent;
   2789 				assert.include(text, group.name);
   2790 			}, "cancel");
   2791 			
   2792 			var result = yield engine._startUpload();
   2793 			assert.equal(result, engine.UPLOAD_RESULT_CANCEL);
   2794 			
   2795 			assert.isTrue(Zotero.Items.exists(itemID));
   2796 			
   2797 			// Library version shouldn't have changed
   2798 			assert.equal(group.libraryVersion, 5);
   2799 		});
   2800 		
   2801 		
   2802 		it("should trigger full sync on object conflict", function* () {
   2803 			({ engine, client, caller } = yield setup());
   2804 			
   2805 			var library = Zotero.Libraries.userLibrary;
   2806 			var libraryID = library.id;
   2807 			var lastLibraryVersion = 5;
   2808 			library.libraryVersion = lastLibraryVersion;
   2809 			yield library.saveTx();
   2810 			
   2811 			var item = createUnsavedDataObject('item');
   2812 			item.version = lastLibraryVersion;
   2813 			yield item.saveTx();
   2814 			
   2815 			setResponse({
   2816 				method: "POST",
   2817 				url: "users/1/items",
   2818 				status: 200,
   2819 				headers: {
   2820 					"Last-Modified-Version": lastLibraryVersion
   2821 				},
   2822 				json: {
   2823 					successful: {},
   2824 					unchanged: {},
   2825 					failed: {
   2826 						"0": {
   2827 							"code": 412,
   2828 							"message": `Item doesn't exist (expected version ${lastLibraryVersion}; `
   2829 								+ "use 0 instead)"
   2830 						}
   2831 					}
   2832 				}
   2833 			});
   2834 			
   2835 			var result = yield engine._startUpload();
   2836 			assert.equal(result, engine.UPLOAD_RESULT_OBJECT_CONFLICT);
   2837 		});
   2838 		
   2839 		
   2840 		// Note: This shouldn't be necessary, since collections are sorted top-down before uploading
   2841 		it("should mark local collection as unsynced if it doesn't exist when uploading collection", function* () {
   2842 			({ engine, client, caller } = yield setup());
   2843 			
   2844 			var library = Zotero.Libraries.userLibrary;
   2845 			var libraryID = library.id;
   2846 			var lastLibraryVersion = 5;
   2847 			library.libraryVersion = lastLibraryVersion;
   2848 			yield library.saveTx();
   2849 			
   2850 			var collection1 = createUnsavedDataObject('collection');
   2851 			// Set the collection as synced (though this shouldn't happen)
   2852 			collection1.synced = true;
   2853 			yield collection1.saveTx();
   2854 			var collection2 = yield createDataObject('collection', { collections: [collection1.id] });
   2855 			
   2856 			var called = 0;
   2857 			server.respond(function (req) {
   2858 				let requestJSON = JSON.parse(req.requestBody);
   2859 				
   2860 				if (called == 0) {
   2861 					assert.lengthOf(requestJSON, 1);
   2862 					assert.equal(requestJSON[0].key, collection2.key);
   2863 					req.respond(
   2864 						200,
   2865 						{
   2866 							"Last-Modified-Version": lastLibraryVersion
   2867 						},
   2868 						JSON.stringify({
   2869 							successful: {},
   2870 							unchanged: {},
   2871 							failed: {
   2872 								0: {
   2873 									code: 409,
   2874 									message: `Parent collection ${collection1.key} doesn't exist`,
   2875 									data: {
   2876 										collection: collection1.key
   2877 									}
   2878 								}
   2879 							}
   2880 						})
   2881 					);
   2882 				}
   2883 				called++;
   2884 			});
   2885 			
   2886 			var e = yield getPromiseError(engine._startUpload());
   2887 			assert.ok(e);
   2888 			assert.isFalse(collection1.synced);
   2889 		});
   2890 		
   2891 		
   2892 		it("should mark local collection as unsynced if it doesn't exist when uploading item", function* () {
   2893 			({ engine, client, caller } = yield setup());
   2894 			
   2895 			var library = Zotero.Libraries.userLibrary;
   2896 			var libraryID = library.id;
   2897 			var lastLibraryVersion = 5;
   2898 			library.libraryVersion = lastLibraryVersion;
   2899 			yield library.saveTx();
   2900 			
   2901 			var collection = createUnsavedDataObject('collection');
   2902 			// Set the collection as synced (though this shouldn't happen)
   2903 			collection.synced = true;
   2904 			yield collection.saveTx();
   2905 			var item = yield createDataObject('item', { collections: [collection.id] });
   2906 			
   2907 			var called = 0;
   2908 			server.respond(function (req) {
   2909 				let requestJSON = JSON.parse(req.requestBody);
   2910 				
   2911 				if (called == 0) {
   2912 					assert.lengthOf(requestJSON, 1);
   2913 					assert.equal(requestJSON[0].key, item.key);
   2914 					req.respond(
   2915 						200,
   2916 						{
   2917 							"Last-Modified-Version": lastLibraryVersion
   2918 						},
   2919 						JSON.stringify({
   2920 							successful: {},
   2921 							unchanged: {},
   2922 							failed: {
   2923 								0: {
   2924 									code: 409,
   2925 									message: `Collection ${collection.key} doesn't exist`,
   2926 									data: {
   2927 										collection: collection.key
   2928 									}
   2929 								}
   2930 							}
   2931 						})
   2932 					);
   2933 				}
   2934 				called++;
   2935 			});
   2936 			
   2937 			var e = yield getPromiseError(engine._startUpload());
   2938 			assert.ok(e);
   2939 			assert.isFalse(collection.synced);
   2940 		});
   2941 		
   2942 		
   2943 		it("should mark local parent item as unsynced if it doesn't exist when uploading child", function* () {
   2944 			({ engine, client, caller } = yield setup());
   2945 			
   2946 			var library = Zotero.Libraries.userLibrary;
   2947 			var libraryID = library.id;
   2948 			var lastLibraryVersion = 5;
   2949 			library.libraryVersion = lastLibraryVersion;
   2950 			yield library.saveTx();
   2951 			
   2952 			var item = createUnsavedDataObject('item');
   2953 			// Set the parent item as synced (though this shouldn't happen)
   2954 			item.synced = true;
   2955 			yield item.saveTx();
   2956 			var note = yield createDataObject('item', { itemType: 'note', parentID: item.id });
   2957 			
   2958 			var called = 0;
   2959 			server.respond(function (req) {
   2960 				let requestJSON = JSON.parse(req.requestBody);
   2961 				
   2962 				if (called == 0) {
   2963 					assert.lengthOf(requestJSON, 1);
   2964 					assert.equal(requestJSON[0].key, note.key);
   2965 					req.respond(
   2966 						200,
   2967 						{
   2968 							"Last-Modified-Version": lastLibraryVersion
   2969 						},
   2970 						JSON.stringify({
   2971 							successful: {},
   2972 							unchanged: {},
   2973 							failed: {
   2974 								0: {
   2975 									code: 409,
   2976 									message: `Parent item ${item.key} doesn't exist`,
   2977 									data: {
   2978 										parentItem: item.key
   2979 									}
   2980 								}
   2981 							}
   2982 						})
   2983 					);
   2984 				}
   2985 				else if (called == 1) {
   2986 					assert.lengthOf(requestJSON, 2);
   2987 					assert.sameMembers(requestJSON.map(o => o.key), [item.key, note.key]);
   2988 					req.respond(
   2989 						200,
   2990 						{
   2991 							"Last-Modified-Version": ++lastLibraryVersion
   2992 						},
   2993 						JSON.stringify({
   2994 							successful: {
   2995 								0: item.toResponseJSON(),
   2996 								1: note.toResponseJSON()
   2997 							},
   2998 							unchanged: {},
   2999 							failed: {}
   3000 						})
   3001 					);
   3002 				}
   3003 				called++;
   3004 			});
   3005 			
   3006 			var result = yield engine._startUpload();
   3007 			assert.equal(result, engine.UPLOAD_RESULT_SUCCESS);
   3008 			assert.equal(called, 2);
   3009 		});
   3010 		
   3011 		
   3012 		it("shouldn't retry failed child item if parent item failed during this sync", async function () {
   3013 			({ engine, client, caller } = await setup({
   3014 				stopOnError: false
   3015 			}));
   3016 			
   3017 			var library = Zotero.Libraries.userLibrary;
   3018 			var libraryID = library.id;
   3019 			var libraryVersion = 5;
   3020 			library.libraryVersion = libraryVersion;
   3021 			await library.saveTx();
   3022 			
   3023 			var item1 = await createDataObject('item');
   3024 			var item1JSON = item1.toResponseJSON();
   3025 			var tag = "A".repeat(300);
   3026 			var item2 = await createDataObject('item', { tags: [{ tag }] });
   3027 			var note = await createDataObject('item', { itemType: 'note', parentID: item2.id });
   3028 			
   3029 			var called = 0;
   3030 			server.respond(function (req) {
   3031 				let requestJSON = JSON.parse(req.requestBody);
   3032 				if (called == 0) {
   3033 					assert.lengthOf(requestJSON, 3);
   3034 					assert.equal(requestJSON[0].key, item1.key);
   3035 					assert.equal(requestJSON[1].key, item2.key);
   3036 					assert.equal(requestJSON[2].key, note.key);
   3037 					req.respond(
   3038 						200,
   3039 						{
   3040 							"Last-Modified-Version": ++libraryVersion
   3041 						},
   3042 						JSON.stringify({
   3043 							successful: {
   3044 								"0": Object.assign(item1JSON, { version: libraryVersion })
   3045 							},
   3046 							unchanged: {},
   3047 							failed: {
   3048 								1: {
   3049 									code: 413,
   3050 									message: `Tag '${"A".repeat(50)}…' too long`,
   3051 									data: {
   3052 										tag
   3053 									}
   3054 								},
   3055 								// Normally this would retry, but that might result in a 409
   3056 								// without the parent
   3057 								2: {
   3058 									code: 500,
   3059 									message: `An error occurred`
   3060 								}
   3061 							}
   3062 						})
   3063 					);
   3064 				}
   3065 				called++;
   3066 			});
   3067 			
   3068 			var spy = sinon.spy(engine, "onError");
   3069 			var result = await engine._startUpload();
   3070 			assert.equal(result, engine.UPLOAD_RESULT_SUCCESS);
   3071 			assert.equal(called, 1);
   3072 			assert.equal(spy.callCount, 2);
   3073 		});
   3074 		
   3075 		
   3076 		it("should show file-write-access-lost dialog on 403 for attachment upload in group", async function () {
   3077 			var group = await createGroup({
   3078 				filesEditable: true
   3079 			});
   3080 			var libraryID = group.libraryID;
   3081 			var libraryVersion = 5;
   3082 			group.libraryVersion = libraryVersion;
   3083 			await group.saveTx();
   3084 			
   3085 			({ engine, client, caller } = await setup({
   3086 				libraryID,
   3087 				stopOnError: false
   3088 			}));
   3089 			
   3090 			var item1 = await createDataObject('item', { libraryID });
   3091 			var item2 = await importFileAttachment(
   3092 				'test.png',
   3093 				{
   3094 					libraryID,
   3095 					parentID: item1.id,
   3096 					version: 5
   3097 				}
   3098 			);
   3099 			
   3100 			var called = 0;
   3101 			server.respond(function (req) {
   3102 				let requestJSON = JSON.parse(req.requestBody);
   3103 				if (called == 0) {
   3104 					req.respond(
   3105 						200,
   3106 						{
   3107 							"Last-Modified-Version": ++libraryVersion
   3108 						},
   3109 						JSON.stringify({
   3110 							successful: {
   3111 								0: item1.toResponseJSON({ version: libraryVersion })
   3112 							},
   3113 							unchanged: {},
   3114 							failed: {
   3115 								1: {
   3116 									code: 403,
   3117 									message: "File editing access denied"
   3118 								}
   3119 							}
   3120 						})
   3121 					);
   3122 				}
   3123 				else if (called == 1 && req.url == baseURL + `groups/${group.id}`) {
   3124 					req.respond(
   3125 						200,
   3126 						{
   3127 							"Last-Modified-Version": group.libraryVersion
   3128 						},
   3129 						JSON.stringify({
   3130 							id: group.id,
   3131 							version: group.libraryVersion,
   3132 							data: {
   3133 								id: group.id,
   3134 								version: group.libraryVersion,
   3135 								name: group.name,
   3136 								owner: 10,
   3137 								type: "Private",
   3138 								description: "",
   3139 								url: "",
   3140 								libraryEditing: "members",
   3141 								libraryReading: "all",
   3142 								fileEditing: "admins"
   3143 							}
   3144 						})
   3145 					);
   3146 				}
   3147 				called++;
   3148 			});
   3149 			
   3150 			var promise = waitForDialog();
   3151 			var spy = sinon.spy(engine, "onError");
   3152 			var result = await engine._startUpload();
   3153 			assert.isTrue(promise.isResolved());
   3154 			assert.equal(result, engine.UPLOAD_RESULT_RESTART);
   3155 			assert.equal(called, 2);
   3156 			assert.equal(spy.callCount, 0);
   3157 			
   3158 			assert.isFalse(group.filesEditable);
   3159 			
   3160 			assert.ok(Zotero.Items.get(item1.id));
   3161 			assert.isFalse(Zotero.Items.get(item2.id));
   3162 		});
   3163 	});
   3164 	
   3165 	
   3166 	describe("Conflict Resolution", function () {
   3167 		beforeEach(function* () {
   3168 			yield Zotero.DB.queryAsync("DELETE FROM syncCache");
   3169 		})
   3170 		
   3171 		after(function* () {
   3172 			yield Zotero.DB.queryAsync("DELETE FROM syncCache");
   3173 		})
   3174 		
   3175 		it("should show conflict resolution window on item conflicts", async function () {
   3176 			var libraryID = Zotero.Libraries.userLibraryID;
   3177 			({ engine, client, caller } = await setup());
   3178 			var type = 'item';
   3179 			var objects = [];
   3180 			var values = [];
   3181 			var dateAdded = Date.now() - 86400000;
   3182 			var responseJSON = [];
   3183 			
   3184 			for (let i = 0; i < 2; i++) {
   3185 				values.push({
   3186 					left: {},
   3187 					right: {}
   3188 				});
   3189 				
   3190 				// Create local object
   3191 				let obj = objects[i] = await createDataObject(
   3192 					type,
   3193 					{
   3194 						version: 10,
   3195 						dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
   3196 						// Set Date Modified values one minute apart to enforce order
   3197 						dateModified: Zotero.Date.dateToSQL(
   3198 							new Date(dateAdded + (i * 60000)), true
   3199 						)
   3200 					}
   3201 				);
   3202 				let jsonData = obj.toJSON();
   3203 				jsonData.key = obj.key;
   3204 				jsonData.version = 10;
   3205 				let json = {
   3206 					key: obj.key,
   3207 					version: jsonData.version,
   3208 					data: jsonData
   3209 				};
   3210 				// Save original version in cache
   3211 				await Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
   3212 				
   3213 				// Create updated JSON for download
   3214 				values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
   3215 				values[i].right.version = json.version = jsonData.version = 15;
   3216 				responseJSON.push(json);
   3217 				
   3218 				// Modify object locally
   3219 				await modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
   3220 				values[i].left.title = obj.getField('title');
   3221 				values[i].left.version = obj.getField('version');
   3222 			}
   3223 			
   3224 			setResponse({
   3225 				method: "GET",
   3226 				url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
   3227 					+ `&includeTrashed=1`,
   3228 				status: 200,
   3229 				headers: {
   3230 					"Last-Modified-Version": 15
   3231 				},
   3232 				json: responseJSON
   3233 			});
   3234 			
   3235 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3236 				var doc = dialog.document;
   3237 				var wizard = doc.documentElement;
   3238 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3239 				
   3240 				// 1 (remote)
   3241 				// Remote version should be selected by default
   3242 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3243 				wizard.getButton('next').click();
   3244 				
   3245 				// 2 (local)
   3246 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3247 				// Select local object
   3248 				mergeGroup.leftpane.click();
   3249 				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
   3250 				if (Zotero.isMac) {
   3251 					assert.isTrue(wizard.getButton('next').hidden);
   3252 					assert.isFalse(wizard.getButton('finish').hidden);
   3253 				}
   3254 				else {
   3255 					// TODO
   3256 				}
   3257 				wizard.getButton('finish').click();
   3258 			})
   3259 			await engine._downloadObjects('item', objects.map(o => o.key));
   3260 			await crPromise;
   3261 			
   3262 			assert.equal(objects[0].getField('title'), values[0].right.title);
   3263 			assert.equal(objects[1].getField('title'), values[1].left.title);
   3264 			assert.equal(objects[0].getField('version'), values[0].right.version);
   3265 			assert.equal(objects[1].getField('version'), values[1].right.version);
   3266 			
   3267 			// Cache versions should match remote
   3268 			for (let i = 0; i < 2; i++) {
   3269 				let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
   3270 					'item', libraryID, objects[i].key, values[i].right.version
   3271 				);
   3272 				assert.propertyVal(cacheJSON, 'version', values[i].right.version);
   3273 				assert.nestedPropertyVal(cacheJSON, 'data.title', values[i].right.title);
   3274 			}
   3275 			
   3276 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3277 			assert.lengthOf(keys, 0);
   3278 		});
   3279 		
   3280 		it("should show conflict resolution window on note conflicts", async function () {
   3281 			var libraryID = Zotero.Libraries.userLibraryID;
   3282 			({ engine, client, caller } = await setup());
   3283 			var type = 'item';
   3284 			var objects = [];
   3285 			var values = [];
   3286 			var dateAdded = Date.now() - 86400000;
   3287 			var responseJSON = [];
   3288 			
   3289 			for (let i = 0; i < 2; i++) {
   3290 				values.push({
   3291 					left: {},
   3292 					right: {}
   3293 				});
   3294 				
   3295 				// Create local object
   3296 				let obj = objects[i] = new Zotero.Item('note');
   3297 				obj.setNote(Zotero.Utilities.randomString());
   3298 				obj.version = 10;
   3299 				obj.dateAdded = Zotero.Date.dateToSQL(new Date(dateAdded), true);
   3300 				// Set Date Modified values one minute apart to enforce order
   3301 				obj.dateModified = Zotero.Date.dateToSQL(
   3302 					new Date(dateAdded + (i * 60000)), true
   3303 				);
   3304 				await obj.saveTx();
   3305 				
   3306 				let jsonData = obj.toJSON();
   3307 				jsonData.key = obj.key;
   3308 				jsonData.version = 10;
   3309 				let json = {
   3310 					key: obj.key,
   3311 					version: jsonData.version,
   3312 					data: jsonData
   3313 				};
   3314 				// Save original version in cache
   3315 				await Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
   3316 				
   3317 				// Create updated JSON for download
   3318 				values[i].right.note = jsonData.note = Zotero.Utilities.randomString();
   3319 				values[i].right.version = json.version = jsonData.version = 15;
   3320 				responseJSON.push(json);
   3321 				
   3322 				// Modify object locally
   3323 				obj.setNote(Zotero.Utilities.randomString());
   3324 				await obj.saveTx({
   3325 					skipDateModifiedUpdate: true
   3326 				});
   3327 				values[i].left.note = obj.getNote();
   3328 				values[i].left.version = obj.getField('version');
   3329 			}
   3330 			
   3331 			setResponse({
   3332 				method: "GET",
   3333 				url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
   3334 					+ `&includeTrashed=1`,
   3335 				status: 200,
   3336 				headers: {
   3337 					"Last-Modified-Version": 15
   3338 				},
   3339 				json: responseJSON
   3340 			});
   3341 			
   3342 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3343 				var doc = dialog.document;
   3344 				var wizard = doc.documentElement;
   3345 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3346 				
   3347 				// 1 (remote)
   3348 				// Remote version should be selected by default
   3349 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3350 				wizard.getButton('next').click();
   3351 				
   3352 				// 2 (local)
   3353 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3354 				// Select local object
   3355 				mergeGroup.leftpane.click();
   3356 				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
   3357 				if (Zotero.isMac) {
   3358 					assert.isTrue(wizard.getButton('next').hidden);
   3359 					assert.isFalse(wizard.getButton('finish').hidden);
   3360 				}
   3361 				else {
   3362 					// TODO
   3363 				}
   3364 				wizard.getButton('finish').click();
   3365 			});
   3366 			await engine._downloadObjects('item', objects.map(o => o.key));
   3367 			await crPromise;
   3368 			
   3369 			assert.equal(objects[0].getNote(), values[0].right.note);
   3370 			assert.equal(objects[1].getNote(), values[1].left.note);
   3371 			assert.equal(objects[0].version, values[0].right.version);
   3372 			assert.equal(objects[1].version, values[1].right.version);
   3373 			assert.isTrue(objects[0].synced);
   3374 			assert.isFalse(objects[1].synced);
   3375 			
   3376 			// Cache versions should match remote
   3377 			for (let i = 0; i < 2; i++) {
   3378 				let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
   3379 					'item', libraryID, objects[i].key, values[i].right.version
   3380 				);
   3381 				assert.propertyVal(cacheJSON, 'version', values[i].right.version);
   3382 				assert.nestedPropertyVal(cacheJSON, 'data.note', values[i].right.note);
   3383 			}
   3384 			
   3385 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3386 			assert.lengthOf(keys, 0);
   3387 		});
   3388 		
   3389 		it("should resolve all remaining conflicts with local version", async function () {
   3390 			var libraryID = Zotero.Libraries.userLibraryID;
   3391 			({ engine, client, caller } = await setup());
   3392 			var collectionA = await createDataObject('collection');
   3393 			var collectionB = await createDataObject('collection');
   3394 			var objects = [];
   3395 			var values = [];
   3396 			var responseJSON = [];
   3397 			var dateAdded = Date.now() - 86400000;
   3398 			for (let i = 0; i < 3; i++) {
   3399 				values.push({
   3400 					left: {},
   3401 					right: {}
   3402 				});
   3403 				
   3404 				// Create object in cache
   3405 				let obj = objects[i] = await createDataObject(
   3406 					'item',
   3407 					{
   3408 						version: 10,
   3409 						dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
   3410 						// Set Date Modified values one minute apart to enforce order
   3411 						dateModified: Zotero.Date.dateToSQL(
   3412 							new Date(dateAdded + (i * 60000)), true
   3413 						)
   3414 					}
   3415 				);
   3416 				let jsonData = obj.toJSON();
   3417 				jsonData.key = obj.key;
   3418 				jsonData.version = 10;
   3419 				let json = {
   3420 					key: obj.key,
   3421 					version: jsonData.version,
   3422 					data: jsonData
   3423 				};
   3424 				// Save original version in cache
   3425 				await Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
   3426 				
   3427 				// Create remote version
   3428 				values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
   3429 				values[i].right.publisher = jsonData.publisher = Zotero.Utilities.randomString();
   3430 				values[i].right.collections = jsonData.collections = [collectionB.key];
   3431 				values[i].right.version = json.version = jsonData.version = 15;
   3432 				responseJSON.push(json);
   3433 				
   3434 				// Modify object locally
   3435 				obj.setField('title', Zotero.Utilities.randomString());
   3436 				obj.setField('extra', Zotero.Utilities.randomString());
   3437 				obj.setCollections([collectionA.key]);
   3438 				await obj.saveTx({
   3439 					skipDateModifiedUpdate: true
   3440 				});
   3441 				values[i].left.title = obj.getField('title');
   3442 				values[i].left.extra = obj.getField('extra');
   3443 				values[i].left.collections = [collectionA.key];
   3444 				values[i].left.version = obj.version;
   3445 			}
   3446 			
   3447 			setResponse({
   3448 				method: "GET",
   3449 				url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
   3450 					+ `&includeTrashed=1`,
   3451 				status: 200,
   3452 				headers: {
   3453 					"Last-Modified-Version": 15
   3454 				},
   3455 				json: responseJSON
   3456 			});
   3457 			
   3458 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3459 				var doc = dialog.document;
   3460 				var wizard = doc.documentElement;
   3461 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3462 				var resolveAll = doc.getElementById('resolve-all');
   3463 				
   3464 				// 1 (remote)
   3465 				// Remote version should be selected by default
   3466 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3467 				assert.equal(
   3468 					resolveAll.label,
   3469 					Zotero.getString('sync.conflict.resolveAllRemoteFields')
   3470 				);
   3471 				wizard.getButton('next').click();
   3472 				
   3473 				// 2 (local and Resolve All checkbox)
   3474 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3475 				mergeGroup.leftpane.click();
   3476 				assert.equal(
   3477 					resolveAll.label,
   3478 					Zotero.getString('sync.conflict.resolveAllLocalFields')
   3479 				);
   3480 				resolveAll.click();
   3481 				
   3482 				if (Zotero.isMac) {
   3483 					assert.isTrue(wizard.getButton('next').hidden);
   3484 					assert.isFalse(wizard.getButton('finish').hidden);
   3485 				}
   3486 				else {
   3487 					// TODO
   3488 				}
   3489 				wizard.getButton('finish').click();
   3490 			})
   3491 			await engine._downloadObjects('item', objects.map(o => o.key));
   3492 			await crPromise;
   3493 			
   3494 			// First object should match remote
   3495 			assert.equal(objects[0].getField('title'), values[0].right.title);
   3496 			assert.equal(objects[0].version, values[0].right.version);
   3497 			assert.isTrue(objects[0].synced);
   3498 			
   3499 			// Remaining objects should be marked as unsynced, with remote versions but original values,
   3500 			// as if they were saved and then modified
   3501 			assert.isFalse(objects[1].synced);
   3502 			assert.equal(objects[1].version, values[1].right.version);
   3503 			assert.equal(objects[1].getField('title'), values[1].left.title);
   3504 			assert.isFalse(objects[2].synced);
   3505 			assert.equal(objects[2].getField('title'), values[2].left.title);
   3506 			assert.equal(objects[2].version, values[2].right.version);
   3507 			
   3508 			// All cache versions should match remote
   3509 			for (let i = 0; i < 3; i++) {
   3510 				let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
   3511 					'item', libraryID, objects[i].key, values[i].right.version
   3512 				);
   3513 				assert.propertyVal(cacheJSON, 'version', values[i].right.version);
   3514 				assert.nestedPropertyVal(cacheJSON, 'data.title', values[i].right.title);
   3515 			}
   3516 			
   3517 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3518 			assert.lengthOf(keys, 0);
   3519 		});
   3520 		
   3521 		
   3522 		it("should resolve all remaining conflicts with remote version", async function () {
   3523 			var libraryID = Zotero.Libraries.userLibraryID;
   3524 			({ engine, client, caller } = await setup());
   3525 			var objects = [];
   3526 			var values = [];
   3527 			var responseJSON = [];
   3528 			var dateAdded = Date.now() - 86400000;
   3529 			for (let i = 0; i < 3; i++) {
   3530 				values.push({
   3531 					left: {},
   3532 					right: {}
   3533 				});
   3534 				
   3535 				// Create object in cache
   3536 				let obj = objects[i] = await createDataObject(
   3537 					'item',
   3538 					{
   3539 						version: 10,
   3540 						dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
   3541 						// Set Date Modified values one minute apart to enforce order
   3542 						dateModified: Zotero.Date.dateToSQL(
   3543 							new Date(dateAdded + (i * 60000)), true
   3544 						)
   3545 					}
   3546 				);
   3547 				let jsonData = obj.toJSON();
   3548 				jsonData.key = obj.key;
   3549 				jsonData.version = 10;
   3550 				let json = {
   3551 					key: obj.key,
   3552 					version: jsonData.version,
   3553 					data: jsonData
   3554 				};
   3555 				// Save original version in cache
   3556 				await Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
   3557 				
   3558 				// Create remote version
   3559 				values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
   3560 				values[i].right.version = json.version = jsonData.version = 15;
   3561 				responseJSON.push(json);
   3562 				
   3563 				// Modify object locally
   3564 				await modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
   3565 				values[i].left.title = obj.getField('title');
   3566 				values[i].left.version = obj.version;
   3567 			}
   3568 			
   3569 			setResponse({
   3570 				method: "GET",
   3571 				url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
   3572 					+ `&includeTrashed=1`,
   3573 				status: 200,
   3574 				headers: {
   3575 					"Last-Modified-Version": 15
   3576 				},
   3577 				json: responseJSON
   3578 			});
   3579 			
   3580 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3581 				var doc = dialog.document;
   3582 				var wizard = doc.documentElement;
   3583 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3584 				var resolveAll = doc.getElementById('resolve-all');
   3585 				
   3586 				// 1 (remote)
   3587 				// Remote version should be selected by default
   3588 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3589 				assert.equal(
   3590 					resolveAll.label,
   3591 					Zotero.getString('sync.conflict.resolveAllRemoteFields')
   3592 				);
   3593 				wizard.getButton('next').click();
   3594 				
   3595 				// 2 click Resolve All checkbox
   3596 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3597 				assert.equal(
   3598 					resolveAll.label,
   3599 					Zotero.getString('sync.conflict.resolveAllRemoteFields')
   3600 				);
   3601 				resolveAll.click();
   3602 				
   3603 				if (Zotero.isMac) {
   3604 					assert.isTrue(wizard.getButton('next').hidden);
   3605 					assert.isFalse(wizard.getButton('finish').hidden);
   3606 				}
   3607 				else {
   3608 					// TODO
   3609 				}
   3610 				wizard.getButton('finish').click();
   3611 			})
   3612 			await engine._downloadObjects('item', objects.map(o => o.key));
   3613 			await crPromise;
   3614 			
   3615 			assert.equal(objects[0].getField('title'), values[0].right.title);
   3616 			assert.equal(objects[0].version, values[0].right.version);
   3617 			assert.isTrue(objects[0].synced);
   3618 			assert.equal(objects[1].getField('title'), values[1].right.title);
   3619 			assert.equal(objects[1].version, values[1].right.version);
   3620 			assert.isTrue(objects[1].synced);
   3621 			assert.equal(objects[2].getField('title'), values[2].right.title);
   3622 			assert.equal(objects[2].version, values[2].right.version);
   3623 			assert.isTrue(objects[2].synced);
   3624 			
   3625 			var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3626 			assert.lengthOf(keys, 0);
   3627 		});
   3628 		
   3629 		
   3630 		// Note: Conflicts with remote deletions are handled in _startDownload()
   3631 		it("should handle local item deletion, keeping deletion", function* () {
   3632 			var libraryID = Zotero.Libraries.userLibraryID;
   3633 			({ engine, client, caller } = yield setup());
   3634 			var type = 'item';
   3635 			var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
   3636 			var responseJSON = [];
   3637 			
   3638 			// Create object, generate JSON, and delete
   3639 			var obj = yield createDataObject(type, { version: 10 });
   3640 			var jsonData = obj.toJSON();
   3641 			var key = jsonData.key = obj.key;
   3642 			jsonData.version = 10;
   3643 			let json = {
   3644 				key: obj.key,
   3645 				version: jsonData.version,
   3646 				data: jsonData
   3647 			};
   3648 			// Delete object locally
   3649 			yield obj.eraseTx();
   3650 			
   3651 			json.version = jsonData.version = 15;
   3652 			jsonData.title = Zotero.Utilities.randomString();
   3653 			responseJSON.push(json);
   3654 			
   3655 			setResponse({
   3656 				method: "GET",
   3657 				url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`,
   3658 				status: 200,
   3659 				headers: {
   3660 					"Last-Modified-Version": 15
   3661 				},
   3662 				json: responseJSON
   3663 			});
   3664 			
   3665 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3666 				var doc = dialog.document;
   3667 				var wizard = doc.documentElement;
   3668 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3669 				
   3670 				// Remote version should be selected by default
   3671 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3672 				assert.ok(mergeGroup.leftpane.pane.onclick);
   3673 				// Select local deleted version
   3674 				mergeGroup.leftpane.pane.click();
   3675 				wizard.getButton('finish').click();
   3676 			})
   3677 			yield engine._downloadObjects('item', [obj.key]);
   3678 			yield crPromise;
   3679 			
   3680 			obj = objectsClass.getByLibraryAndKey(libraryID, key);
   3681 			assert.isFalse(obj);
   3682 			
   3683 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3684 			assert.lengthOf(keys, 0);
   3685 		})
   3686 		
   3687 		it("should handle local child note deletion, keeping deletion", function* () {
   3688 			var libraryID = Zotero.Libraries.userLibraryID;
   3689 			({ engine, client, caller } = yield setup());
   3690 			var responseJSON = [];
   3691 			
   3692 			var parent = yield createDataObject('item');
   3693 			
   3694 			// Create object, generate JSON, and delete
   3695 			var obj = new Zotero.Item('note');
   3696 			obj.parentItemID = parent.id;
   3697 			obj.setNote(Zotero.Utilities.randomString());
   3698 			obj.version = 10;
   3699 			yield obj.saveTx();
   3700 			var jsonData = obj.toJSON();
   3701 			var key = jsonData.key = obj.key;
   3702 			jsonData.version = 10;
   3703 			let json = {
   3704 				key: obj.key,
   3705 				version: jsonData.version,
   3706 				data: jsonData
   3707 			};
   3708 			// Delete object locally
   3709 			yield obj.eraseTx();
   3710 			
   3711 			json.version = jsonData.version = 15;
   3712 			jsonData.note = Zotero.Utilities.randomString();
   3713 			responseJSON.push(json);
   3714 			
   3715 			setResponse({
   3716 				method: "GET",
   3717 				url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`,
   3718 				status: 200,
   3719 				headers: {
   3720 					"Last-Modified-Version": 15
   3721 				},
   3722 				json: responseJSON
   3723 			});
   3724 			
   3725 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3726 				var doc = dialog.document;
   3727 				var wizard = doc.documentElement;
   3728 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3729 				
   3730 				// Remote version should be selected by default
   3731 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3732 				assert.ok(mergeGroup.leftpane.pane.onclick);
   3733 				// Select local deleted version
   3734 				mergeGroup.leftpane.pane.click();
   3735 				wizard.getButton('finish').click();
   3736 			});
   3737 			yield engine._downloadObjects('item', [obj.key]);
   3738 			yield crPromise;
   3739 			
   3740 			obj = Zotero.Items.getByLibraryAndKey(libraryID, key);
   3741 			assert.isFalse(obj);
   3742 			
   3743 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3744 			assert.lengthOf(keys, 0);
   3745 		});
   3746 		
   3747 		it("should restore locally deleted item", function* () {
   3748 			var libraryID = Zotero.Libraries.userLibraryID;
   3749 			({ engine, client, caller } = yield setup());
   3750 			var type = 'item';
   3751 			var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
   3752 			var responseJSON = [];
   3753 			
   3754 			// Create object, generate JSON, and delete
   3755 			var obj = yield createDataObject(type, { version: 10 });
   3756 			var jsonData = obj.toJSON();
   3757 			var key = jsonData.key = obj.key;
   3758 			jsonData.version = 10;
   3759 			let json = {
   3760 				key: obj.key,
   3761 				version: jsonData.version,
   3762 				data: jsonData
   3763 			};
   3764 			yield obj.eraseTx();
   3765 			
   3766 			json.version = jsonData.version = 15;
   3767 			jsonData.title = Zotero.Utilities.randomString();
   3768 			responseJSON.push(json);
   3769 			
   3770 			setResponse({
   3771 				method: "GET",
   3772 				url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
   3773 				status: 200,
   3774 				headers: {
   3775 					"Last-Modified-Version": 15
   3776 				},
   3777 				json: responseJSON
   3778 			});
   3779 			
   3780 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   3781 				var doc = dialog.document;
   3782 				var wizard = doc.documentElement;
   3783 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   3784 				
   3785 				assert.isTrue(doc.getElementById('resolve-all').hidden);
   3786 				
   3787 				// Remote version should be selected by default
   3788 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
   3789 				wizard.getButton('finish').click();
   3790 			})
   3791 			yield engine._downloadObjects('item', [key]);
   3792 			yield crPromise;
   3793 			
   3794 			obj = objectsClass.getByLibraryAndKey(libraryID, key);
   3795 			assert.ok(obj);
   3796 			assert.equal(obj.getField('title'), jsonData.title);
   3797 			
   3798 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3799 			assert.lengthOf(keys, 0);
   3800 		});
   3801 		
   3802 		it("should handle local deletion and remote move to trash", function* () {
   3803 			var libraryID = Zotero.Libraries.userLibraryID;
   3804 			({ engine, client, caller } = yield setup());
   3805 			var type = 'item';
   3806 			var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
   3807 			var responseJSON = [];
   3808 			
   3809 			// Create object, generate JSON, and delete
   3810 			var obj = yield createDataObject(type, { version: 10 });
   3811 			var jsonData = obj.toJSON();
   3812 			var key = jsonData.key = obj.key;
   3813 			jsonData.version = 10;
   3814 			let json = {
   3815 				key: obj.key,
   3816 				version: jsonData.version,
   3817 				data: jsonData
   3818 			};
   3819 			yield obj.eraseTx();
   3820 			
   3821 			json.version = jsonData.version = 15;
   3822 			jsonData.deleted = true;
   3823 			responseJSON.push(json);
   3824 			
   3825 			setResponse({
   3826 				method: "GET",
   3827 				url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
   3828 				status: 200,
   3829 				headers: {
   3830 					"Last-Modified-Version": 15
   3831 				},
   3832 				json: responseJSON
   3833 			});
   3834 			
   3835 			yield engine._downloadObjects('item', [key]);
   3836 			
   3837 			assert.isFalse(objectsClass.exists(libraryID, key));
   3838 			
   3839 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3840 			assert.lengthOf(keys, 0);
   3841 			
   3842 			// Deletion should still be in sync delete log for uploading
   3843 			assert.ok(yield Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, key));
   3844 		});
   3845 		
   3846 		it("should handle remote move to trash and local deletion", function* () {
   3847 			var libraryID = Zotero.Libraries.userLibraryID;
   3848 			({ engine, client, caller } = yield setup());
   3849 			var type = 'item';
   3850 			var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
   3851 			var responseJSON = [];
   3852 			
   3853 			// Create trashed object
   3854 			var obj = createUnsavedDataObject(type);
   3855 			obj.deleted = true;
   3856 			yield obj.saveTx();
   3857 			
   3858 			setResponse({
   3859 				method: "GET",
   3860 				url: `users/1/deleted?since=10`,
   3861 				status: 200,
   3862 				headers: {
   3863 					"Last-Modified-Version": 15
   3864 				},
   3865 				json: {
   3866 					collections: [],
   3867 					searches: [],
   3868 					items: [obj.key],
   3869 				}
   3870 			});
   3871 			
   3872 			yield engine._downloadDeletions(10, 15);
   3873 			
   3874 			// Local object should have been deleted
   3875 			assert.isFalse(objectsClass.exists(libraryID, obj.key));
   3876 			
   3877 			var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
   3878 			assert.lengthOf(keys, 0);
   3879 			
   3880 			// Deletion shouldn't be in sync delete log
   3881 			assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, obj.key));
   3882 		});
   3883 	});
   3884 	
   3885 	
   3886 	describe("#_upgradeCheck()", function () {
   3887 		it("should upgrade a library last synced with the classic sync architecture", function* () {
   3888 			var userLibraryID = Zotero.Libraries.userLibraryID;
   3889 			({ engine, client, caller } = yield setup());
   3890 			
   3891 			var types = Zotero.DataObjectUtilities.getTypes();
   3892 			var objects = {};
   3893 			
   3894 			// Create objects added before the last classic sync time,
   3895 			// which should end up marked as synced
   3896 			for (let type of types) {
   3897 				objects[type] = [yield createDataObject(type)];
   3898 			}
   3899 			
   3900 			var time1 = "2015-05-01 01:23:45";
   3901 			yield Zotero.DB.queryAsync("UPDATE collections SET clientDateModified=?", time1);
   3902 			yield Zotero.DB.queryAsync("UPDATE savedSearches SET clientDateModified=?", time1);
   3903 			yield Zotero.DB.queryAsync("UPDATE items SET clientDateModified=?", time1);
   3904 			
   3905 			// Create objects added after the last sync time, which should be ignored and
   3906 			// therefore end up marked as unsynced
   3907 			for (let type of types) {
   3908 				objects[type].push(yield createDataObject(type));
   3909 			}
   3910 			
   3911 			var objectJSON = {};
   3912 			for (let type of types) {
   3913 				objectJSON[type] = [];
   3914 			}
   3915 			
   3916 			// Create JSON for objects created remotely after the last sync time,
   3917 			// which should be ignored
   3918 			objectJSON.collection.push(makeCollectionJSON({
   3919 				key: Zotero.DataObjectUtilities.generateKey(),
   3920 				version: 20,
   3921 				name: Zotero.Utilities.randomString()
   3922 			}));
   3923 			objectJSON.search.push(makeSearchJSON({
   3924 				key: Zotero.DataObjectUtilities.generateKey(),
   3925 				version: 20,
   3926 				name: Zotero.Utilities.randomString()
   3927 			}));
   3928 			objectJSON.item.push(makeItemJSON({
   3929 				key: Zotero.DataObjectUtilities.generateKey(),
   3930 				version: 20,
   3931 				itemType: "book",
   3932 				title: Zotero.Utilities.randomString()
   3933 			}));
   3934 			
   3935 			var lastSyncTime = Zotero.Date.toUnixTimestamp(
   3936 				Zotero.Date.sqlToDate("2015-05-02 00:00:00", true)
   3937 			);
   3938 			yield Zotero.DB.queryAsync(
   3939 				"INSERT INTO version VALUES ('lastlocalsync', ?1), ('lastremotesync', ?1)",
   3940 				lastSyncTime
   3941 			);
   3942 			
   3943 			var headers = {
   3944 				"Last-Modified-Version": 20
   3945 			}
   3946 			for (let type of types) {
   3947 				var suffix = type == 'item' ? '&includeTrashed=1' : '';
   3948 				
   3949 				var json = {};
   3950 				json[objects[type][0].key] = 10;
   3951 				json[objectJSON[type][0].key] = objectJSON[type][0].version;
   3952 				setResponse({
   3953 					method: "GET",
   3954 					url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
   3955 						+ "?format=versions" + suffix,
   3956 					status: 200,
   3957 					headers: headers,
   3958 					json: json
   3959 				});
   3960 				json = {};
   3961 				json[objectJSON[type][0].key] = objectJSON[type][0].version;
   3962 				setResponse({
   3963 					method: "GET",
   3964 					url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
   3965 						+ "?format=versions&sincetime=" + lastSyncTime + suffix,
   3966 					status: 200,
   3967 					headers: headers,
   3968 					json: json
   3969 				});
   3970 			}
   3971 			var versionResults = yield engine._upgradeCheck();
   3972 			
   3973 			// Objects 1 should be marked as synced, with versions from the server
   3974 			// Objects 2 should be marked as unsynced
   3975 			for (let type of types) {
   3976 				var synced = yield Zotero.Sync.Data.Local.getSynced(type, userLibraryID);
   3977 				assert.deepEqual(synced, [objects[type][0].key]);
   3978 				assert.equal(objects[type][0].version, 10);
   3979 				var unsynced = yield Zotero.Sync.Data.Local.getUnsynced(type, userLibraryID);
   3980 				assert.deepEqual(unsynced, [objects[type][1].id]);
   3981 				
   3982 				assert.equal(versionResults[type].libraryVersion, headers["Last-Modified-Version"]);
   3983 				assert.property(versionResults[type].versions, objectJSON[type][0].key);
   3984 			}
   3985 			
   3986 			assert.equal(Zotero.Libraries.getVersion(userLibraryID), -1);
   3987 		})
   3988 	})
   3989 	
   3990 	describe("#_fullSync()", function () {
   3991 		it("should download missing/updated local objects and flag remotely missing local objects for upload", function* () {
   3992 			var userLibraryID = Zotero.Libraries.userLibraryID;
   3993 			({ engine, client, caller } = yield setup());
   3994 			
   3995 			var types = Zotero.DataObjectUtilities.getTypes();
   3996 			var objects = {};
   3997 			var objectJSON = {};
   3998 			for (let type of types) {
   3999 				objectJSON[type] = [];
   4000 			}
   4001 			
   4002 			for (let type of types) {
   4003 				// Create object with outdated version, which should be updated
   4004 				let obj = createUnsavedDataObject(type);
   4005 				obj.synced = true;
   4006 				obj.version = 5;
   4007 				yield obj.saveTx();
   4008 				objects[type] = [obj];
   4009 				
   4010 				objectJSON[type].push(makeJSONFunctions[type]({
   4011 					key: obj.key,
   4012 					version: 20,
   4013 					name: Zotero.Utilities.randomString()
   4014 				}));
   4015 				
   4016 				// Create JSON for object that exists remotely and not locally,
   4017 				// which should be downloaded
   4018 				objectJSON[type].push(makeJSONFunctions[type]({
   4019 					key: Zotero.DataObjectUtilities.generateKey(),
   4020 					version: 20,
   4021 					name: Zotero.Utilities.randomString()
   4022 				}));
   4023 				
   4024 				// Create object marked as synced that doesn't exist remotely,
   4025 				// which should be flagged for upload
   4026 				obj = createUnsavedDataObject(type);
   4027 				obj.synced = true;
   4028 				obj.version = 10;
   4029 				yield obj.saveTx();
   4030 				objects[type].push(obj);
   4031 				
   4032 				// Create object marked as synced that doesn't exist remotely but is in the
   4033 				// remote delete log, which should be deleted locally
   4034 				obj = createUnsavedDataObject(type);
   4035 				obj.synced = true;
   4036 				obj.version = 10;
   4037 				yield obj.saveTx();
   4038 				objects[type].push(obj);
   4039 			}
   4040 			
   4041 			var headers = {
   4042 				"Last-Modified-Version": 20
   4043 			}
   4044 			setResponse({
   4045 				method: "GET",
   4046 				url: "users/1/settings",
   4047 				status: 200,
   4048 				headers: headers,
   4049 				json: {
   4050 					tagColors: {
   4051 						value: [
   4052 							{
   4053 								name: "A",
   4054 								color: "#CC66CC"
   4055 							}
   4056 						],
   4057 						version: 2
   4058 					}
   4059 				}
   4060 			});
   4061 			let deletedJSON = {};
   4062 			for (let type of types) {
   4063 				let suffix = type == 'item' ? '&includeTrashed=1' : '';
   4064 				let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
   4065 				
   4066 				var json = {};
   4067 				json[objectJSON[type][0].key] = objectJSON[type][0].version;
   4068 				json[objectJSON[type][1].key] = objectJSON[type][1].version;
   4069 				setResponse({
   4070 					method: "GET",
   4071 					url: "users/1/" + plural
   4072 						+ "?format=versions" + suffix,
   4073 					status: 200,
   4074 					headers: headers,
   4075 					json: json
   4076 				});
   4077 				
   4078 				setResponse({
   4079 					method: "GET",
   4080 					url: "users/1/" + plural
   4081 						+ "?format=json"
   4082 						+ "&" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key
   4083 						+ suffix,
   4084 					status: 200,
   4085 					headers: headers,
   4086 					json: objectJSON[type]
   4087 				});
   4088 				
   4089 				deletedJSON[plural] = [objects[type][2].key];
   4090 			}
   4091 			setResponse({
   4092 				method: "GET",
   4093 				url: "users/1/deleted?since=0",
   4094 				status: 200,
   4095 				headers: headers,
   4096 				json: deletedJSON
   4097 			});
   4098 			yield engine._fullSync();
   4099 			
   4100 			// Check settings
   4101 			var setting = Zotero.SyncedSettings.get(userLibraryID, "tagColors");
   4102 			assert.lengthOf(setting, 1);
   4103 			assert.equal(setting[0].name, 'A');
   4104 			var settingMetadata = Zotero.SyncedSettings.getMetadata(userLibraryID, "tagColors");
   4105 			assert.equal(settingMetadata.version, 2);
   4106 			assert.isTrue(settingMetadata.synced);
   4107 			
   4108 			// Check objects
   4109 			for (let type of types) {
   4110 				// Objects 1 should be updated with version from server
   4111 				assert.equal(objects[type][0].version, 20);
   4112 				assert.isTrue(objects[type][0].synced);
   4113 				
   4114 				// JSON objects 1 should be created locally with version from server
   4115 				let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
   4116 				let obj = objectsClass.getByLibraryAndKey(userLibraryID, objectJSON[type][0].key);
   4117 				assert.equal(obj.version, 20);
   4118 				assert.isTrue(obj.synced);
   4119 				yield assertInCache(obj);
   4120 				
   4121 				// JSON objects 2 should be marked as unsynced, with their version reset to 0
   4122 				assert.equal(objects[type][1].version, 0);
   4123 				assert.isFalse(objects[type][1].synced);
   4124 				
   4125 				// JSON objects 3 should be deleted and not in the delete log
   4126 				assert.isFalse(objectsClass.getByLibraryAndKey(userLibraryID, objects[type][2].key));
   4127 				assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
   4128 					type, userLibraryID, objects[type][2].key
   4129 				));
   4130 			}
   4131 		});
   4132 		
   4133 		it("should reprocess remote deletions", function* () {
   4134 			var userLibraryID = Zotero.Libraries.userLibraryID;
   4135 			({ engine, client, caller } = yield setup());
   4136 			
   4137 			var types = Zotero.DataObjectUtilities.getTypes();
   4138 			var objects = {};
   4139 			var objectIDs = {};
   4140 			
   4141 			for (let type of types) {
   4142 				// Create object marked as synced that's in the remote delete log, which should be
   4143 				// deleted locally
   4144 				let obj = createUnsavedDataObject(type);
   4145 				obj.synced = true;
   4146 				obj.version = 5;
   4147 				yield obj.saveTx();
   4148 				objects[type] = [obj];
   4149 				objectIDs[type] = [obj.id];
   4150 				
   4151 				// Create object marked as unsynced that's in the remote delete log, which should
   4152 				// trigger a conflict in the case of items and otherwise reset version to 0
   4153 				obj = createUnsavedDataObject(type);
   4154 				obj.synced = false;
   4155 				obj.version = 5;
   4156 				yield obj.saveTx();
   4157 				objects[type].push(obj);
   4158 				objectIDs[type].push(obj.id);
   4159 			}
   4160 			
   4161 			var headers = {
   4162 				"Last-Modified-Version": 20
   4163 			}
   4164 			setResponse({
   4165 				method: "GET",
   4166 				url: "users/1/settings",
   4167 				status: 200,
   4168 				headers,
   4169 				json: {}
   4170 			});
   4171 			let deletedJSON = {};
   4172 			for (let type of types) {
   4173 				let suffix = type == 'item' ? '&includeTrashed=1' : '';
   4174 				let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
   4175 				setResponse({
   4176 					method: "GET",
   4177 					url: "users/1/" + plural + "?format=versions" + suffix,
   4178 					status: 200,
   4179 					headers,
   4180 					json: {}
   4181 				});
   4182 				deletedJSON[plural] = objects[type].map(o => o.key);
   4183 			}
   4184 			setResponse({
   4185 				method: "GET",
   4186 				url: "users/1/deleted?since=0",
   4187 				status: 200,
   4188 				headers: headers,
   4189 				json: deletedJSON
   4190 			});
   4191 			
   4192 			// Apply remote deletions
   4193 			var crPromise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
   4194 				var doc = dialog.document;
   4195 				var wizard = doc.documentElement;
   4196 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
   4197 				
   4198 				// Should be one conflict for each object type; select local
   4199 				var numConflicts = Object.keys(objects).length;
   4200 				for (let i = 0; i < numConflicts; i++) {
   4201 					assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
   4202 					
   4203 					if (i < numConflicts - 1) {
   4204 						wizard.getButton('next').click();
   4205 					}
   4206 					else {
   4207 						wizard.getButton('finish').click();
   4208 					}
   4209 				}
   4210 			});
   4211 			
   4212 			yield engine._fullSync();
   4213 			yield crPromise;
   4214 			
   4215 			// Check objects
   4216 			for (let type of types) {
   4217 				let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
   4218 				
   4219 				// Objects 0 should be deleted
   4220 				assert.isFalse(objectsClass.exists(objectIDs[type][0]));
   4221 				
   4222 				// Objects 1 should be marked for reupload
   4223 				assert.isTrue(objectsClass.exists(objectIDs[type][1]));
   4224 				assert.strictEqual(objects[type][1].version, 0);
   4225 				assert.strictEqual(objects[type][1].synced, false);
   4226 			}
   4227 		});
   4228 	});
   4229 	
   4230 	
   4231 	describe("#_restoreToServer()", function () {
   4232 		it("should delete remote objects that don't exist locally and upload all local objects", async function () {
   4233 			({ engine, client, caller } = await setup());
   4234 			var library = Zotero.Libraries.userLibrary;
   4235 			var libraryID = library.id;
   4236 			var lastLibraryVersion = 10;
   4237 			library.libraryVersion = library.storageVersion = lastLibraryVersion;
   4238 			await library.saveTx();
   4239 			lastLibraryVersion = 20;
   4240 			
   4241 			var postData = {};
   4242 			var deleteData = {};
   4243 			
   4244 			var types = Zotero.DataObjectUtilities.getTypes();
   4245 			var objects = {};
   4246 			var objectJSON = {};
   4247 			for (let type of types) {
   4248 				objectJSON[type] = [];
   4249 			}
   4250 			
   4251 			var obj;
   4252 			for (let type of types) {
   4253 				objects[type] = [null];
   4254 				// Create JSON for object that exists remotely and not locally,
   4255 				// which should be deleted
   4256 				objectJSON[type].push(makeJSONFunctions[type]({
   4257 					key: Zotero.DataObjectUtilities.generateKey(),
   4258 					version: lastLibraryVersion,
   4259 					name: Zotero.Utilities.randomString()
   4260 				}));
   4261 				
   4262 				// All other objects should be uploaded
   4263 				
   4264 				// Object with outdated version
   4265 				obj = await createDataObject(type, { synced: true, version: 5 });
   4266 				objects[type].push(obj);
   4267 				objectJSON[type].push(makeJSONFunctions[type]({
   4268 					key: obj.key,
   4269 					version: lastLibraryVersion,
   4270 					name: Zotero.Utilities.randomString()
   4271 				}));
   4272 				
   4273 				// Object marked as synced that doesn't exist remotely
   4274 				obj = await createDataObject(type, { synced: true, version: 10 });
   4275 				objects[type].push(obj);
   4276 				objectJSON[type].push(makeJSONFunctions[type]({
   4277 					key: obj.key,
   4278 					version: lastLibraryVersion,
   4279 					name: Zotero.Utilities.randomString()
   4280 				}));
   4281 				
   4282 				// Object marked as synced that doesn't exist remotely
   4283 				// but is in the remote delete log
   4284 				obj = await createDataObject(type, { synced: true, version: 10 });
   4285 				objects[type].push(obj);
   4286 				objectJSON[type].push(makeJSONFunctions[type]({
   4287 					key: obj.key,
   4288 					version: lastLibraryVersion,
   4289 					name: Zotero.Utilities.randomString()
   4290 				}));
   4291 			}
   4292 			
   4293 			// Child attachment
   4294 			obj = await importFileAttachment(
   4295 				'test.png',
   4296 				{
   4297 					parentID: objects.item[1].id,
   4298 					synced: true,
   4299 					version: 5
   4300 				}
   4301 			);
   4302 			obj.attachmentSyncedModificationTime = new Date().getTime();
   4303 			obj.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804';
   4304 			obj.attachmentSyncState = Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC;
   4305 			await obj.saveTx();
   4306 			objects.item.push(obj);
   4307 			objectJSON.item.push(makeJSONFunctions.item({
   4308 				key: obj.key,
   4309 				version: lastLibraryVersion,
   4310 				name: Zotero.Utilities.randomString(),
   4311 				itemType: 'attachment'
   4312 			}));
   4313 			
   4314 			for (let type of types) {
   4315 				let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
   4316 				let suffix = type == 'item' ? '&includeTrashed=1' : '';
   4317 				
   4318 				let json = {};
   4319 				json[objectJSON[type][0].key] = objectJSON[type][0].version;
   4320 				json[objectJSON[type][1].key] = objectJSON[type][1].version;
   4321 				setResponse({
   4322 					method: "GET",
   4323 					url: `users/1/${plural}?format=versions${suffix}`,
   4324 					status: 200,
   4325 					headers: {
   4326 						"Last-Modified-Version": lastLibraryVersion
   4327 					},
   4328 					json
   4329 				});
   4330 				
   4331 				deleteData[type] = {
   4332 					expectedVersion: lastLibraryVersion++,
   4333 					keys: [objectJSON[type][0].key]
   4334 				};
   4335 			}
   4336 			
   4337 			await Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: 2 });
   4338 			var settingsJSON = { testSetting: { value: { foo: 2 } } }
   4339 			postData.setting = {
   4340 				expectedVersion: lastLibraryVersion++
   4341 			};
   4342 			
   4343 			for (let type of types) {
   4344 				postData[type] = {
   4345 					expectedVersion: lastLibraryVersion++
   4346 				};
   4347 			}
   4348 			
   4349 			server.respond(function (req) {
   4350 				try {
   4351 				
   4352 				let plural = req.url.match(/users\/\d+\/([a-z]+e?s)/)[1];
   4353 				let type = Zotero.DataObjectUtilities.getObjectTypeSingular(plural);
   4354 				// Deletions
   4355 				if (req.method == "DELETE") {
   4356 					let data = deleteData[type];
   4357 					let version = data.expectedVersion + 1;
   4358 					if (req.url == baseURL + `users/1/${plural}?${type}Key=${data.keys.join(',')}`) {
   4359 						req.respond(
   4360 							204,
   4361 							{
   4362 								"Last-Modified-Version": version
   4363 							},
   4364 							""
   4365 						);
   4366 					}
   4367 				}
   4368 				// Settings
   4369 				else if (req.method == "POST" && req.url.match(/users\/\d+\/settings/)) {
   4370 					let data = postData.setting;
   4371 					assert.equal(
   4372 						req.requestHeaders["If-Unmodified-Since-Version"],
   4373 						data.expectedVersion
   4374 					);
   4375 					let version = data.expectedVersion + 1;
   4376 					let json = JSON.parse(req.requestBody);
   4377 					assert.deepEqual(json, settingsJSON);
   4378 					req.respond(
   4379 						204,
   4380 						{
   4381 							"Last-Modified-Version": version
   4382 						},
   4383 						""
   4384 					);
   4385 				}
   4386 				// Uploads
   4387 				else if (req.method == "POST") {
   4388 					let data = postData[type];
   4389 					assert.equal(
   4390 						req.requestHeaders["If-Unmodified-Since-Version"],
   4391 						data.expectedVersion
   4392 					);
   4393 					let version = data.expectedVersion + 1;
   4394 					let json = JSON.parse(req.requestBody);
   4395 					let o1 = json.find(o => o.key == objectJSON[type][1].key);
   4396 					assert.notProperty(o1, 'version');
   4397 					let o2 = json.find(o => o.key == objectJSON[type][2].key);
   4398 					assert.notProperty(o2, 'version');
   4399 					let o3 = json.find(o => o.key == objectJSON[type][3].key);
   4400 					assert.notProperty(o3, 'version');
   4401 					let response = {
   4402 						successful: {
   4403 							"0": Object.assign(objectJSON[type][1], { version }),
   4404 							"1": Object.assign(objectJSON[type][2], { version }),
   4405 							"2": Object.assign(objectJSON[type][3], { version })
   4406 						},
   4407 						unchanged: {},
   4408 						failed: {}
   4409 					};
   4410 					if (type == 'item') {
   4411 						let o = json.find(o => o.key == objectJSON.item[4].key);
   4412 						assert.notProperty(o, 'version');
   4413 						// Attachment items should include storage properties
   4414 						assert.propertyVal(o, 'mtime', objects.item[4].attachmentSyncedModificationTime);
   4415 						assert.propertyVal(o, 'md5', objects.item[4].attachmentSyncedHash);
   4416 						response.successful["3"] = Object.assign(objectJSON[type][4], { version })
   4417 					}
   4418 					req.respond(
   4419 						200,
   4420 						{
   4421 							"Last-Modified-Version": version
   4422 						},
   4423 						JSON.stringify(response)
   4424 					);
   4425 				}
   4426 				
   4427 				}
   4428 				catch (e) {
   4429 					Zotero.logError(e);
   4430 					throw e;
   4431 				}
   4432 			});
   4433 			
   4434 			await engine._restoreToServer();
   4435 			
   4436 			// Check settings
   4437 			var setting = Zotero.SyncedSettings.get(libraryID, "testSetting");
   4438 			assert.deepEqual(setting, { foo: 2 });
   4439 			var settingMetadata = Zotero.SyncedSettings.getMetadata(libraryID, "testSetting");
   4440 			assert.equal(settingMetadata.version, postData.setting.expectedVersion + 1);
   4441 			assert.isTrue(settingMetadata.synced);
   4442 			
   4443 			// Objects should all be marked as synced and in the cache
   4444 			for (let type of types) {
   4445 				let version = postData[type].expectedVersion + 1;
   4446 				for (let i = 1; i <= 3; i++) {
   4447 					assert.equal(objects[type][i].version, version);
   4448 					assert.isTrue(objects[type][i].synced);
   4449 					await assertInCache(objects[type][i]);
   4450 				}
   4451 			}
   4452 			
   4453 			// Files should be marked as unsynced
   4454 			assert.equal(
   4455 				objects.item[4].attachmentSyncState,
   4456 				Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD
   4457 			);
   4458 		});
   4459 	});
   4460 })