www

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

syncRunnerTest.js (34108B)


      1 "use strict";
      2 
      3 describe("Zotero.Sync.Runner", 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 userLibraryID, runner, caller, server, stub, spy;
      9 	
     10 	var responses = {
     11 		keyInfo: {
     12 			fullAccess: {
     13 				method: "GET",
     14 				url: "keys/current",
     15 				status: 200,
     16 				json: {
     17 					key: apiKey,
     18 					userID: 1,
     19 					username: "Username",
     20 					access: {
     21 						user: {
     22 							library: true,
     23 							files: true,
     24 							notes: true,
     25 							write: true
     26 						},
     27 						groups: {
     28 							all: {
     29 								library: true,
     30 								write: true
     31 							}
     32 						}
     33 					}
     34 				}
     35 			}
     36 		},
     37 		userGroups: {
     38 			groupVersions: {
     39 				method: "GET",
     40 				url: "users/1/groups?format=versions",
     41 				json: {
     42 					"1623562": 10,
     43 					"2694172": 11
     44 				}
     45 			},
     46 			groupVersionsEmpty: {
     47 				method: "GET",
     48 				url: "users/1/groups?format=versions",
     49 				json: {}
     50 			},
     51 			groupVersionsOnlyMemberGroup: {
     52 				method: "GET",
     53 				url: "users/1/groups?format=versions",
     54 				json: {
     55 					"2694172": 11
     56 				}
     57 			}
     58 		},
     59 		groups: {
     60 			ownerGroup: {
     61 				method: "GET",
     62 				url: "groups/1623562",
     63 				json: {
     64 					id: 1623562,
     65 					version: 10,
     66 					data: {
     67 						id: 1623562,
     68 						version: 10,
     69 						name: "Group Name",
     70 						description: "<p>Test group</p>",
     71 						owner: 1,
     72 						type: "Private",
     73 						libraryEditing: "members",
     74 						libraryReading: "all",
     75 						fileEditing: "members",
     76 						admins: [],
     77 						members: []
     78 					}
     79 				}
     80 			},
     81 			memberGroup: {
     82 				method: "GET",
     83 				url: "groups/2694172",
     84 				json: {
     85 					id: 2694172,
     86 					version: 11,
     87 					data: {
     88 						id: 2694172,
     89 						version: 11,
     90 						name: "Group Name 2",
     91 						description: "<p>Test group</p>",
     92 						owner: 123456,
     93 						type: "Private",
     94 						libraryEditing: "admins",
     95 						libraryReading: "all",
     96 						fileEditing: "admins",
     97 						admins: [],
     98 						members: [1]
     99 					}
    100 				}
    101 			}
    102 		}
    103 	};
    104 	
    105 	//
    106 	// Helper functions
    107 	//
    108 	function setResponse(response) {
    109 		setHTTPResponse(server, baseURL, response, responses);
    110 	}
    111 	
    112 	
    113 	//
    114 	// Tests
    115 	//
    116 	beforeEach(function* () {
    117 		yield resetDB({
    118 			thisArg: this,
    119 			skipBundledFiles: true
    120 		});
    121 		
    122 		userLibraryID = Zotero.Libraries.userLibraryID;
    123 		
    124 		Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
    125 		server = sinon.fakeServer.create();
    126 		server.autoRespond = true;
    127 		
    128 		runner = new Zotero.Sync.Runner_Module({ baseURL, apiKey });
    129 		
    130 		Components.utils.import("resource://zotero/concurrentCaller.js");
    131 		caller = new ConcurrentCaller(1);
    132 		caller.setLogger(msg => Zotero.debug(msg));
    133 		caller.stopOnError = true;
    134 		caller.onError = function (e) {
    135 			Zotero.logError(e);
    136 			if (options.onError) {
    137 				options.onError(e);
    138 			}
    139 			if (e.fatal) {
    140 				caller.stop();
    141 				throw e;
    142 			}
    143 		};
    144 		
    145 		yield Zotero.Users.setCurrentUserID(1);
    146 		yield Zotero.Users.setCurrentUsername("A");
    147 	})
    148 	afterEach(function () {
    149 		if (stub) stub.restore();
    150 		if (spy) spy.restore();
    151 	})
    152 	after(function () {
    153 		Zotero.HTTP.mock = null;
    154 	})
    155 	
    156 	describe("#checkAccess()", function () {
    157 		it("should check key access", function* () {
    158 			setResponse('keyInfo.fullAccess');
    159 			var json = yield runner.checkAccess(runner.getAPIClient({ apiKey }));
    160 			var compare = {};
    161 			Object.assign(compare, responses.keyInfo.fullAccess.json);
    162 			delete compare.key;
    163 			assert.deepEqual(json, compare);
    164 		})
    165 	})
    166 	
    167 	describe("#checkLibraries()", function () {
    168 		beforeEach(function* () {
    169 			Zotero.Prefs.clear('sync.librariesToSkip');
    170 		});
    171 		
    172 		afterEach(function* () {
    173 			Zotero.Prefs.clear('sync.librariesToSkip');
    174 			
    175 			var group = Zotero.Groups.get(responses.groups.ownerGroup.json.id);
    176 			if (group) {
    177 				yield group.eraseTx();
    178 			}
    179 			group = Zotero.Groups.get(responses.groups.memberGroup.json.id);
    180 			if (group) {
    181 				yield group.eraseTx();
    182 			}
    183 		})
    184 		
    185 		it("should check library access and versions without library list", function* () {
    186 			// Create group with same id and version as groups response
    187 			var groupData = responses.groups.ownerGroup;
    188 			var group1 = yield createGroup({
    189 				id: groupData.json.id,
    190 				version: groupData.json.version
    191 			});
    192 			groupData = responses.groups.memberGroup;
    193 			var group2 = yield createGroup({
    194 				id: groupData.json.id,
    195 				version: groupData.json.version
    196 			});
    197 			
    198 			setResponse('userGroups.groupVersions');
    199 			var libraries = yield runner.checkLibraries(
    200 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    201 			);
    202 			assert.lengthOf(libraries, 3);
    203 			assert.sameMembers(
    204 				libraries,
    205 				[userLibraryID, group1.libraryID, group2.libraryID]
    206 			);
    207 		})
    208 		
    209 		it("should check library access and versions with library list", function* () {
    210 			// Create groups with same id and version as groups response
    211 			var groupData = responses.groups.ownerGroup;
    212 			var group1 = yield createGroup({
    213 				id: groupData.json.id,
    214 				version: groupData.json.version
    215 			});
    216 			groupData = responses.groups.memberGroup;
    217 			var group2 = yield createGroup({
    218 				id: groupData.json.id,
    219 				version: groupData.json.version
    220 			});
    221 			
    222 			setResponse('userGroups.groupVersions');
    223 			var libraries = yield runner.checkLibraries(
    224 				runner.getAPIClient({ apiKey }),
    225 				false,
    226 				responses.keyInfo.fullAccess.json,
    227 				[userLibraryID]
    228 			);
    229 			assert.lengthOf(libraries, 1);
    230 			assert.sameMembers(libraries, [userLibraryID]);
    231 			
    232 			var libraries = yield runner.checkLibraries(
    233 				runner.getAPIClient({ apiKey }),
    234 				false,
    235 				responses.keyInfo.fullAccess.json,
    236 				[userLibraryID]
    237 			);
    238 			assert.lengthOf(libraries, 1);
    239 			assert.sameMembers(libraries, [userLibraryID]);
    240 			
    241 			var libraries = yield runner.checkLibraries(
    242 				runner.getAPIClient({ apiKey }),
    243 				false,
    244 				responses.keyInfo.fullAccess.json,
    245 				[group1.libraryID]
    246 			);
    247 			assert.lengthOf(libraries, 1);
    248 			assert.sameMembers(libraries, [group1.libraryID]);
    249 		})
    250 		
    251 		it("should filter out nonexistent skipped libraries if library list not provided", function* () {
    252 			var unskippedGroupID = responses.groups.ownerGroup.json.id;
    253 			var skippedGroupID = responses.groups.memberGroup.json.id;
    254 			Zotero.Prefs.set('sync.librariesToSkip', `["L4", "G${skippedGroupID}"]`);
    255 			
    256 			setResponse('userGroups.groupVersions');
    257 			setResponse('groups.ownerGroup');
    258 			setResponse('groups.memberGroup');
    259 			var libraries = yield runner.checkLibraries(
    260 				runner.getAPIClient({ apiKey }),
    261 				false,
    262 				responses.keyInfo.fullAccess.json
    263 			);
    264 			
    265 			var group = Zotero.Groups.get(unskippedGroupID);
    266 			assert.lengthOf(libraries, 2);
    267 			assert.sameMembers(libraries, [userLibraryID, group.libraryID]);
    268 			
    269 			assert.isFalse(Zotero.Groups.get(skippedGroupID));
    270 		});
    271 		
    272 		it("should filter out existing skipped libraries if library list not provided", function* () {
    273 			var unskippedGroupID = responses.groups.ownerGroup.json.id;
    274 			var skippedGroupID = responses.groups.memberGroup.json.id;
    275 			Zotero.Prefs.set('sync.librariesToSkip', `["L4", "G${skippedGroupID}"]`);
    276 			
    277 			var skippedGroup = yield createGroup({
    278 				id: skippedGroupID,
    279 				version: responses.groups.memberGroup.json.version - 1
    280 			});
    281 			
    282 			setResponse('userGroups.groupVersions');
    283 			setResponse('groups.ownerGroup');
    284 			setResponse('groups.memberGroup');
    285 			var libraries = yield runner.checkLibraries(
    286 				runner.getAPIClient({ apiKey }),
    287 				false,
    288 				responses.keyInfo.fullAccess.json
    289 			);
    290 			
    291 			var group = Zotero.Groups.get(unskippedGroupID);
    292 			assert.lengthOf(libraries, 2);
    293 			assert.sameMembers(libraries, [userLibraryID, group.libraryID]);
    294 			
    295 			assert.equal(skippedGroup.version, responses.groups.memberGroup.json.version - 1);
    296 		});
    297 		
    298 		it("should filter out remotely missing archived libraries if library list not provided", function* () {
    299 			var ownerGroupID = responses.groups.ownerGroup.json.id;
    300 			var archivedGroupID = 162512451; // nonexistent group id
    301 			
    302 			var ownerGroup = yield createGroup({
    303 				id: ownerGroupID,
    304 				version: responses.groups.ownerGroup.json.version
    305 			});
    306 			var archivedGroup = yield createGroup({
    307 				id: archivedGroupID,
    308 				editable: false,
    309 				archived: true
    310 			});
    311 			
    312 			setResponse('userGroups.groupVersions');
    313 			setResponse('groups.memberGroup');
    314 			var libraries = yield runner.checkLibraries(
    315 				runner.getAPIClient({ apiKey }),
    316 				false,
    317 				responses.keyInfo.fullAccess.json
    318 			);
    319 			
    320 			assert.lengthOf(libraries, 3);
    321 			assert.sameMembers(
    322 				libraries,
    323 				[
    324 					userLibraryID,
    325 					ownerGroup.libraryID,
    326 					// Nonexistent group should've been created
    327 					Zotero.Groups.getLibraryIDFromGroupID(responses.groups.memberGroup.json.id)
    328 				]
    329 			);
    330 		});
    331 		
    332 		it("should unarchive library if available remotely", function* () {
    333 			var syncedGroupID = responses.groups.ownerGroup.json.id;
    334 			var archivedGroupID = responses.groups.memberGroup.json.id;
    335 			
    336 			var syncedGroup = yield createGroup({
    337 				id: syncedGroupID,
    338 				version: responses.groups.ownerGroup.json.version
    339 			});
    340 			var archivedGroup = yield createGroup({
    341 				id: archivedGroupID,
    342 				version: responses.groups.memberGroup.json.version - 1,
    343 				editable: false,
    344 				archived: true
    345 			});
    346 			
    347 			setResponse('userGroups.groupVersions');
    348 			setResponse('groups.ownerGroup');
    349 			setResponse('groups.memberGroup');
    350 			var libraries = yield runner.checkLibraries(
    351 				runner.getAPIClient({ apiKey }),
    352 				false,
    353 				responses.keyInfo.fullAccess.json
    354 			);
    355 			
    356 			assert.lengthOf(libraries, 3);
    357 			assert.sameMembers(
    358 				libraries,
    359 				[userLibraryID, syncedGroup.libraryID, archivedGroup.libraryID]
    360 			);
    361 			assert.isFalse(archivedGroup.archived);
    362 		});
    363 		
    364 		it("shouldn't filter out skipped libraries if library list is provided", function* () {
    365 			var groupData = responses.groups.memberGroup;
    366 			var group = yield createGroup({
    367 				id: groupData.json.id,
    368 				version: groupData.json.version
    369 			});
    370 			
    371 			Zotero.Prefs.set('sync.librariesToSkip', `["L4", "G${group.id}"]`);
    372 			
    373 			setResponse('userGroups.groupVersions');
    374 			setResponse('groups.ownerGroup');
    375 			setResponse('groups.memberGroup');
    376 			var libraries = yield runner.checkLibraries(
    377 				runner.getAPIClient({ apiKey }),
    378 				false,
    379 				responses.keyInfo.fullAccess.json,
    380 				[userLibraryID, group.libraryID]
    381 			);
    382 			
    383 			assert.lengthOf(libraries, 2);
    384 			assert.sameMembers(libraries, [userLibraryID, group.libraryID]);
    385 		});
    386 		
    387 		it("should update outdated group metadata", function* () {
    388 			// Create groups with same id as groups response but earlier versions
    389 			var groupData1 = responses.groups.ownerGroup;
    390 			var group1 = yield createGroup({
    391 				id: groupData1.json.id,
    392 				version: groupData1.json.version - 1,
    393 				editable: false
    394 			});
    395 			var groupData2 = responses.groups.memberGroup;
    396 			var group2 = yield createGroup({
    397 				id: groupData2.json.id,
    398 				version: groupData2.json.version - 1,
    399 				editable: true
    400 			});
    401 			
    402 			setResponse('userGroups.groupVersions');
    403 			setResponse('groups.ownerGroup');
    404 			setResponse('groups.memberGroup');
    405 			// Simulate acceptance of library reset for group 2 editable change
    406 			var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess")
    407 				.returns(Zotero.Promise.resolve(true));
    408 			
    409 			var libraries = yield runner.checkLibraries(
    410 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    411 			);
    412 			
    413 			assert.ok(stub.calledTwice);
    414 			stub.restore();
    415 			assert.lengthOf(libraries, 3);
    416 			assert.sameMembers(
    417 				libraries,
    418 				[userLibraryID, group1.libraryID, group2.libraryID]
    419 			);
    420 			
    421 			assert.equal(group1.name, groupData1.json.data.name);
    422 			assert.equal(group1.version, groupData1.json.version);
    423 			assert.isTrue(group1.editable);
    424 			assert.equal(group2.name, groupData2.json.data.name);
    425 			assert.equal(group2.version, groupData2.json.version);
    426 			assert.isFalse(group2.editable);
    427 		})
    428 		
    429 		it("should update outdated group metadata for group created with classic sync", function* () {
    430 			var groupData1 = responses.groups.ownerGroup;
    431 			var group1 = yield createGroup({
    432 				id: groupData1.json.id,
    433 				version: 0,
    434 				editable: false
    435 			});
    436 			var groupData2 = responses.groups.memberGroup;
    437 			var group2 = yield createGroup({
    438 				id: groupData2.json.id,
    439 				version: 0,
    440 				editable: true
    441 			});
    442 			
    443 			yield Zotero.DB.queryAsync(
    444 				"UPDATE groups SET version=0 WHERE groupID IN (?, ?)", [group1.id, group2.id]
    445 			);
    446 			yield Zotero.Libraries.init();
    447 			group1 = Zotero.Groups.get(group1.id);
    448 			group2 = Zotero.Groups.get(group2.id);
    449 			
    450 			setResponse('userGroups.groupVersions');
    451 			setResponse('groups.ownerGroup');
    452 			setResponse('groups.memberGroup');
    453 			// Simulate acceptance of library reset for group 2 editable change
    454 			var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess")
    455 				.returns(Zotero.Promise.resolve(true));
    456 			
    457 			var libraries = yield runner.checkLibraries(
    458 				runner.getAPIClient({ apiKey }),
    459 				false,
    460 				responses.keyInfo.fullAccess.json,
    461 				[group1.libraryID, group2.libraryID]
    462 			);
    463 			
    464 			assert.ok(stub.calledTwice);
    465 			stub.restore();
    466 			assert.lengthOf(libraries, 2);
    467 			assert.sameMembers(libraries, [group1.libraryID, group2.libraryID]);
    468 			
    469 			assert.equal(group1.name, groupData1.json.data.name);
    470 			assert.equal(group1.version, groupData1.json.version);
    471 			assert.isTrue(group1.editable);
    472 			assert.equal(group2.name, groupData2.json.data.name);
    473 			assert.equal(group2.version, groupData2.json.version);
    474 			assert.isFalse(group2.editable);
    475 		})
    476 		
    477 		it("should create locally missing groups", function* () {
    478 			setResponse('userGroups.groupVersions');
    479 			setResponse('groups.ownerGroup');
    480 			setResponse('groups.memberGroup');
    481 			var libraries = yield runner.checkLibraries(
    482 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    483 			);
    484 			assert.lengthOf(libraries, 3);
    485 			var groupData1 = responses.groups.ownerGroup;
    486 			var group1 = Zotero.Groups.get(groupData1.json.id);
    487 			var groupData2 = responses.groups.memberGroup;
    488 			var group2 = Zotero.Groups.get(groupData2.json.id);
    489 			assert.ok(group1);
    490 			assert.ok(group2);
    491 			assert.sameMembers(
    492 				libraries,
    493 				[userLibraryID, group1.libraryID, group2.libraryID]
    494 			);
    495 			assert.equal(group1.name, groupData1.json.data.name);
    496 			assert.isTrue(group1.editable);
    497 			assert.equal(group2.name, groupData2.json.data.name);
    498 			assert.isFalse(group2.editable);
    499 		})
    500 		
    501 		it("should delete remotely missing groups", function* () {
    502 			var groupData1 = responses.groups.ownerGroup;
    503 			var group1 = yield createGroup({ id: groupData1.json.id, version: groupData1.json.version });
    504 			var groupData2 = responses.groups.memberGroup;
    505 			var group2 = yield createGroup({ id: groupData2.json.id, version: groupData2.json.version });
    506 			
    507 			setResponse('userGroups.groupVersionsOnlyMemberGroup');
    508 			waitForDialog(function (dialog) {
    509 				var text = dialog.document.documentElement.textContent;
    510 				assert.include(text, group1.name);
    511 			});
    512 			var libraries = yield runner.checkLibraries(
    513 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    514 			);
    515 			assert.lengthOf(libraries, 2);
    516 			assert.sameMembers(libraries, [userLibraryID, group2.libraryID]);
    517 			assert.isFalse(Zotero.Groups.exists(groupData1.json.id));
    518 			assert.isTrue(Zotero.Groups.exists(groupData2.json.id));
    519 		})
    520 		
    521 		it("should keep remotely missing groups", function* () {
    522 			var group1 = yield createGroup({ editable: true, filesEditable: true });
    523 			var group2 = yield createGroup({ editable: true, filesEditable: true });
    524 			
    525 			setResponse('userGroups.groupVersionsEmpty');
    526 			var called = 0;
    527 			var otherGroup;
    528 			waitForDialog(function (dialog) {
    529 				called++;
    530 				var text = dialog.document.documentElement.textContent;
    531 				if (text.includes(group1.name)) {
    532 					otherGroup = group2;
    533 				}
    534 				else if (text.includes(group2.name)) {
    535 					otherGroup = group1;
    536 				}
    537 				else {
    538 					throw new Error("Dialog text does not include either group name");
    539 				}
    540 				
    541 				waitForDialog(function (dialog) {
    542 					called++;
    543 					var text = dialog.document.documentElement.textContent;
    544 					assert.include(text, otherGroup.name);
    545 				}, "extra1");
    546 			}, "extra1");
    547 			var libraries = yield runner.checkLibraries(
    548 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    549 			);
    550 			assert.equal(called, 2);
    551 			assert.lengthOf(libraries, 1);
    552 			assert.sameMembers(libraries, [userLibraryID]);
    553 			// Groups should still exist but be read-only and archived
    554 			[group1, group2].forEach((group) => {
    555 				assert.isTrue(Zotero.Groups.exists(group.id));
    556 				assert.isTrue(group.archived);
    557 				assert.isFalse(group.editable);
    558 				assert.isFalse(group.filesEditable);
    559 			});
    560 		})
    561 		
    562 		it("should cancel sync with remotely missing groups", function* () {
    563 			var groupData = responses.groups.ownerGroup;
    564 			var group = yield createGroup({ id: groupData.json.id, version: groupData.json.version });
    565 			
    566 			setResponse('userGroups.groupVersionsEmpty');
    567 			waitForDialog(function (dialog) {
    568 				var text = dialog.document.documentElement.textContent;
    569 				assert.include(text, group.name);
    570 			}, "cancel");
    571 			var libraries = yield runner.checkLibraries(
    572 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    573 			);
    574 			assert.lengthOf(libraries, 0);
    575 			assert.isTrue(Zotero.Groups.exists(groupData.json.id));
    576 		})
    577 		
    578 		it("should prompt to revert local changes on loss of library write access", function* () {
    579 			var group = yield createGroup({
    580 				version: 1,
    581 				libraryVersion: 2
    582 			});
    583 			var libraryID = group.libraryID;
    584 			
    585 			setResponse({
    586 				method: "GET",
    587 				url: "users/1/groups?format=versions",
    588 				status: 200,
    589 				headers: {
    590 					"Last-Modified-Version": 3
    591 				},
    592 				json: {
    593 					[group.id]: 3
    594 				}
    595 			});
    596 			setResponse({
    597 				method: "GET",
    598 				url: "groups/" + group.id,
    599 				status: 200,
    600 				headers: {
    601 					"Last-Modified-Version": 3
    602 				},
    603 				json: {
    604 					id: group.id,
    605 					version: 2,
    606 					data: {
    607 						// Make group read-only
    608 						id: group.id,
    609 						version: 2,
    610 						name: group.name,
    611 						description: group.description,
    612 						owner: 2,
    613 						type: "Private",
    614 						libraryEditing: "admins",
    615 						libraryReading: "all",
    616 						fileEditing: "admins",
    617 						admins: [],
    618 						members: [1]
    619 					}
    620 				}
    621 			});
    622 			
    623 			// First, test cancelling
    624 			var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess")
    625 				.returns(Zotero.Promise.resolve(false));
    626 			var libraries = yield runner.checkLibraries(
    627 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    628 			);
    629 			assert.notInclude(libraries, group.libraryID);
    630 			assert.isTrue(stub.calledOnce);
    631 			assert.isTrue(group.editable);
    632 			stub.reset();
    633 			
    634 			// Next, reset
    635 			stub.returns(Zotero.Promise.resolve(true));
    636 			libraries = yield runner.checkLibraries(
    637 				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
    638 			);
    639 			assert.include(libraries, group.libraryID);
    640 			assert.isTrue(stub.calledOnce);
    641 			assert.isFalse(group.editable);
    642 			
    643 			stub.restore();
    644 		});
    645 	})
    646 
    647 	describe("#sync()", function () {
    648 		it("should perform a sync across all libraries and update library versions", function* () {
    649 			setResponse('keyInfo.fullAccess');
    650 			setResponse('userGroups.groupVersions');
    651 			setResponse('groups.ownerGroup');
    652 			setResponse('groups.memberGroup');
    653 			// My Library
    654 			setResponse({
    655 				method: "GET",
    656 				url: "users/1/settings",
    657 				status: 200,
    658 				headers: {
    659 					"Last-Modified-Version": 5
    660 				},
    661 				json: []
    662 			});
    663 			setResponse({
    664 				method: "GET",
    665 				url: "users/1/collections?format=versions",
    666 				status: 200,
    667 				headers: {
    668 					"Last-Modified-Version": 5
    669 				},
    670 				json: []
    671 			});
    672 			setResponse({
    673 				method: "GET",
    674 				url: "users/1/searches?format=versions",
    675 				status: 200,
    676 				headers: {
    677 					"Last-Modified-Version": 5
    678 				},
    679 				json: []
    680 			});
    681 			setResponse({
    682 				method: "GET",
    683 				url: "users/1/items/top?format=versions&includeTrashed=1",
    684 				status: 200,
    685 				headers: {
    686 					"Last-Modified-Version": 5
    687 				},
    688 				json: []
    689 			});
    690 			setResponse({
    691 				method: "GET",
    692 				url: "users/1/items?format=versions&includeTrashed=1",
    693 				status: 200,
    694 				headers: {
    695 					"Last-Modified-Version": 5
    696 				},
    697 				json: []
    698 			});
    699 			setResponse({
    700 				method: "GET",
    701 				url: "users/1/deleted?since=0",
    702 				status: 200,
    703 				headers: {
    704 					"Last-Modified-Version": 5
    705 				},
    706 				json: []
    707 			});
    708 			// Group library 1
    709 			setResponse({
    710 				method: "GET",
    711 				url: "groups/1623562/settings",
    712 				status: 200,
    713 				headers: {
    714 					"Last-Modified-Version": 15
    715 				},
    716 				json: []
    717 			});
    718 			setResponse({
    719 				method: "GET",
    720 				url: "groups/1623562/collections?format=versions",
    721 				status: 200,
    722 				headers: {
    723 					"Last-Modified-Version": 15
    724 				},
    725 				json: []
    726 			});
    727 			setResponse({
    728 				method: "GET",
    729 				url: "groups/1623562/searches?format=versions",
    730 				status: 200,
    731 				headers: {
    732 					"Last-Modified-Version": 15
    733 				},
    734 				json: []
    735 			});
    736 			setResponse({
    737 				method: "GET",
    738 				url: "groups/1623562/items/top?format=versions&includeTrashed=1",
    739 				status: 200,
    740 				headers: {
    741 					"Last-Modified-Version": 15
    742 				},
    743 				json: []
    744 			});
    745 			setResponse({
    746 				method: "GET",
    747 				url: "groups/1623562/items?format=versions&includeTrashed=1",
    748 				status: 200,
    749 				headers: {
    750 					"Last-Modified-Version": 15
    751 				},
    752 				json: []
    753 			});
    754 			setResponse({
    755 				method: "GET",
    756 				url: "groups/1623562/deleted?since=0",
    757 				status: 200,
    758 				headers: {
    759 					"Last-Modified-Version": 15
    760 				},
    761 				json: []
    762 			});
    763 			// Group library 2
    764 			setResponse({
    765 				method: "GET",
    766 				url: "groups/2694172/settings",
    767 				status: 200,
    768 				headers: {
    769 					"Last-Modified-Version": 20
    770 				},
    771 				json: []
    772 			});
    773 			setResponse({
    774 				method: "GET",
    775 				url: "groups/2694172/collections?format=versions",
    776 				status: 200,
    777 				headers: {
    778 					"Last-Modified-Version": 20
    779 				},
    780 				json: []
    781 			});
    782 			setResponse({
    783 				method: "GET",
    784 				url: "groups/2694172/searches?format=versions",
    785 				status: 200,
    786 				headers: {
    787 					"Last-Modified-Version": 20
    788 				},
    789 				json: []
    790 			});
    791 			setResponse({
    792 				method: "GET",
    793 				url: "groups/2694172/items/top?format=versions&includeTrashed=1",
    794 				status: 200,
    795 				headers: {
    796 					"Last-Modified-Version": 20
    797 				},
    798 				json: []
    799 			});
    800 			setResponse({
    801 				method: "GET",
    802 				url: "groups/2694172/items?format=versions&includeTrashed=1",
    803 				status: 200,
    804 				headers: {
    805 					"Last-Modified-Version": 20
    806 				},
    807 				json: []
    808 			});
    809 			setResponse({
    810 				method: "GET",
    811 				url: "groups/2694172/deleted?since=0",
    812 				status: 200,
    813 				headers: {
    814 					"Last-Modified-Version": 20
    815 				},
    816 				json: []
    817 			});
    818 			// Full-text syncing
    819 			setResponse({
    820 				method: "GET",
    821 				url: "users/1/fulltext?format=versions",
    822 				status: 200,
    823 				headers: {
    824 					"Last-Modified-Version": 5
    825 				},
    826 				json: {}
    827 			});
    828 			setResponse({
    829 				method: "GET",
    830 				url: "groups/1623562/fulltext?format=versions",
    831 				status: 200,
    832 				headers: {
    833 					"Last-Modified-Version": 15
    834 				},
    835 				json: {}
    836 			});
    837 			setResponse({
    838 				method: "GET",
    839 				url: "groups/2694172/fulltext?format=versions",
    840 				status: 200,
    841 				headers: {
    842 					"Last-Modified-Version": 20
    843 				},
    844 				json: {}
    845 			});
    846 			
    847 			var startTime = new Date().getTime();
    848 			
    849 			yield runner.sync({
    850 				onError: e => { throw e },
    851 			});
    852 			
    853 			// Check local library versions
    854 			assert.equal(
    855 				Zotero.Libraries.getVersion(userLibraryID),
    856 				5
    857 			);
    858 			assert.equal(
    859 				Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(1623562)),
    860 				15
    861 			);
    862 			assert.equal(
    863 				Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(2694172)),
    864 				20
    865 			);
    866 			
    867 			// Last sync time should be within the last few seconds
    868 			var lastSyncTime = Zotero.Sync.Data.Local.getLastSyncTime();
    869 			assert.isAbove(lastSyncTime.getTime(), startTime);
    870 			assert.isBelow(lastSyncTime.getTime(), new Date().getTime());
    871 		})
    872 		
    873 		
    874 		it("should handle user-initiated cancellation", function* () {
    875 			setResponse('keyInfo.fullAccess');
    876 			setResponse('userGroups.groupVersions');
    877 			setResponse('groups.ownerGroup');
    878 			setResponse('groups.memberGroup');
    879 			
    880 			var stub = sinon.stub(Zotero.Sync.Data.Engine.prototype, "start");
    881 			
    882 			stub.onCall(0).returns(Zotero.Promise.resolve());
    883 			var e = new Zotero.Sync.UserCancelledException();
    884 			e.handledRejection = true;
    885 			stub.onCall(1).returns(Zotero.Promise.reject(e));
    886 			// Shouldn't be reached
    887 			stub.onCall(2).throws();
    888 			
    889 			yield runner.sync({
    890 				onError: e => { throw e },
    891 			});
    892 			
    893 			stub.restore();
    894 		});
    895 		
    896 		
    897 		it("should handle user-initiated cancellation for current library", function* () {
    898 			setResponse('keyInfo.fullAccess');
    899 			setResponse('userGroups.groupVersions');
    900 			setResponse('groups.ownerGroup');
    901 			setResponse('groups.memberGroup');
    902 			
    903 			var stub = sinon.stub(Zotero.Sync.Data.Engine.prototype, "start");
    904 			
    905 			stub.returns(Zotero.Promise.resolve());
    906 			var e = new Zotero.Sync.UserCancelledException(true);
    907 			e.handledRejection = true;
    908 			stub.onCall(1).returns(Zotero.Promise.reject(e));
    909 			
    910 			yield runner.sync({
    911 				onError: e => { throw e },
    912 			});
    913 			
    914 			assert.equal(stub.callCount, 3);
    915 			stub.restore();
    916 		});
    917 	})
    918 	
    919 	
    920 	describe("#createAPIKeyFromCredentials()", function() {
    921 		var data = {
    922 			name: "Automatic Zotero Client Key",
    923 			username: "Username",
    924 			access: {
    925 				user: {
    926 					library: true,
    927 					files: true,
    928 					notes: true,
    929 					write: true
    930 				},
    931 				groups: {
    932 					all: {
    933 						library: true,
    934 						write: true
    935 					}
    936 				}
    937 			}
    938 		};
    939 		var correctPostData = Object.assign({password: 'correctPassword'}, data);
    940 		var incorrectPostData = Object.assign({password: 'incorrectPassword'}, data);
    941 		var responseData = Object.assign({userID: 1, key: apiKey}, data);
    942 
    943 		it("should return json with key when credentials valid", function* () {
    944 			server.respond(function (req) {
    945 				if (req.method == "POST") {
    946 					var json = JSON.parse(req.requestBody);
    947 					assert.deepEqual(json, correctPostData);
    948 					req.respond(201, {}, JSON.stringify(responseData));
    949 				}
    950 			});
    951 
    952 			var json = yield runner.createAPIKeyFromCredentials('Username', 'correctPassword');
    953 			assert.equal(json.key, apiKey);
    954 		});
    955 
    956 		it("should return false when credentials invalid", function* () {
    957 			server.respond(function (req) {
    958 				if (req.method == "POST") {
    959 					var json = JSON.parse(req.requestBody);
    960 					assert.deepEqual(json, incorrectPostData);
    961 					req.respond(403);
    962 				}
    963 			});
    964 
    965 			var key = yield runner.createAPIKeyFromCredentials('Username', 'incorrectPassword');
    966 			assert.isFalse(key);
    967 		});
    968 	});
    969 
    970 	describe("#deleteAPIKey()", function() {
    971 		it("should send DELETE request with correct key", function* (){
    972 			Zotero.Sync.Data.Local.setAPIKey(apiKey);
    973 
    974 			server.respond(function (req) {
    975 				if (req.method == "DELETE") {
    976 					assert.propertyVal(req.requestHeaders, 'Zotero-API-Key', apiKey);
    977 					assert.equal(req.url, baseURL + "keys/current");
    978 				}
    979 				req.respond(204);
    980 			});
    981 
    982 			yield runner.deleteAPIKey();
    983 		});
    984 	});
    985 	
    986 	
    987 	describe("Error Handling", function () {
    988 		var win;
    989 		
    990 		afterEach(function () {
    991 			if (win) {
    992 				win.close();
    993 			}
    994 		});
    995 		
    996 		it("should show the sync error icon on error", function* () {
    997 			let library = Zotero.Libraries.userLibrary;
    998 			library.libraryVersion = 5;
    999 			yield library.save();
   1000 			
   1001 			setResponse('keyInfo.fullAccess');
   1002 			setResponse('userGroups.groupVersionsEmpty');
   1003 			// My Library
   1004 			setResponse({
   1005 				method: "GET",
   1006 				url: "users/1/settings",
   1007 				status: 200,
   1008 				headers: {
   1009 					"Last-Modified-Version": 5
   1010 				},
   1011 				json: {
   1012 					INVALID: true // TODO: Find a cleaner error
   1013 				}
   1014 			});
   1015 			
   1016 			spy = sinon.spy(runner, "updateIcons");
   1017 			yield runner.sync();
   1018 			assert.isTrue(spy.calledTwice);
   1019 			assert.isArray(spy.args[1][0]);
   1020 			assert.lengthOf(spy.args[1][0], 1);
   1021 			// Not an instance of Error for some reason
   1022 			var error = spy.args[1][0][0];
   1023 			assert.equal(Object.getPrototypeOf(error).constructor.name, "Error");
   1024 		});
   1025 		
   1026 		
   1027 		it("should show a custom button in the error panel", function* () {
   1028 			win = yield loadZoteroPane();
   1029 			var libraryID = Zotero.Libraries.userLibraryID;
   1030 			
   1031 			setResponse({
   1032 				method: "GET",
   1033 				url: "keys/current",
   1034 				status: 403,
   1035 				headers: {},
   1036 				text: "Invalid Key"
   1037 			});
   1038 			yield runner.sync({
   1039 				background: true
   1040 			});
   1041 			
   1042 			var doc = win.document;
   1043 			var errorIcon = doc.getElementById('zotero-tb-sync-error');
   1044 			assert.isFalse(errorIcon.hidden);
   1045 			errorIcon.click();
   1046 			var panel = win.document.getElementById('zotero-sync-error-panel');
   1047 			var buttons = panel.getElementsByTagName('button');
   1048 			assert.lengthOf(buttons, 1);
   1049 			assert.equal(buttons[0].label, Zotero.getString('sync.openSyncPreferences'));
   1050 		});
   1051 		
   1052 		
   1053 		it("should show a button in error panel to select a too-long note", function* () {
   1054 			win = yield loadZoteroPane();
   1055 			var doc = win.document;
   1056 			
   1057 			var text = "".padStart(256, "a");
   1058 			var item = yield createDataObject('item', { itemType: 'note', note: text });
   1059 			
   1060 			setResponse('keyInfo.fullAccess');
   1061 			setResponse('userGroups.groupVersions');
   1062 			setResponse('groups.ownerGroup');
   1063 			setResponse('groups.memberGroup');
   1064 			
   1065 			server.respond(function (req) {
   1066 				if (req.method == "POST" && req.url == baseURL + "users/1/items") {
   1067 					req.respond(
   1068 						200,
   1069 						{
   1070 							"Last-Modified-Version": 5
   1071 						},
   1072 						JSON.stringify({
   1073 							successful: {},
   1074 							success: {},
   1075 							unchanged: {},
   1076 							failed: {
   1077 								"0": {
   1078 									code: 413,
   1079 									message: `Note ${Zotero.Utilities.ellipsize(text, 100)} too long`
   1080 								}
   1081 							}
   1082 						})
   1083 					);
   1084 				}
   1085 			});
   1086 			
   1087 			yield runner.sync({ libraries: [Zotero.Libraries.userLibraryID] });
   1088 			
   1089 			var errorIcon = doc.getElementById('zotero-tb-sync-error');
   1090 			assert.isFalse(errorIcon.hidden);
   1091 			errorIcon.click();
   1092 			var panel = win.document.getElementById('zotero-sync-error-panel');
   1093 			assert.include(panel.innerHTML, text.substr(0, 10));
   1094 			var buttons = panel.getElementsByTagName('button');
   1095 			assert.lengthOf(buttons, 1);
   1096 			assert.include(buttons[0].label, Zotero.getString('pane.items.showItemInLibrary'));
   1097 		});
   1098 		
   1099 		
   1100 		// TODO: Test multiple long tags and tags across libraries
   1101 		describe("Long Tag Fixer", function () {
   1102 			it("should split a tag", function* () {
   1103 				win = yield loadZoteroPane();
   1104 				
   1105 				var item = yield createDataObject('item');
   1106 				var tag = "title;feeling;matter;drum;treatment;caring;earthy;shrill;unit;obedient;hover;healthy;cheap;clever;wren;wicked;clip;shoe;jittery;shape;clear;dime;increase;complete;level;milk;false;infamous;lamentable;measure;cuddly;tasteless;peace;top;pencil;caption;unusual;depressed;frantic";
   1107 				item.addTag(tag, 1);
   1108 				yield item.saveTx();
   1109 				
   1110 				setResponse('keyInfo.fullAccess');
   1111 				setResponse('userGroups.groupVersions');
   1112 				setResponse('groups.ownerGroup');
   1113 				setResponse('groups.memberGroup');
   1114 				
   1115 				server.respond(function (req) {
   1116 					if (req.method == "POST" && req.url == baseURL + "users/1/items") {
   1117 						var json = JSON.parse(req.requestBody);
   1118 						if (json[0].tags.length == 1) {
   1119 							req.respond(
   1120 								200,
   1121 								{
   1122 									"Last-Modified-Version": 5
   1123 								},
   1124 								JSON.stringify({
   1125 									successful: {},
   1126 									success: {},
   1127 									unchanged: {},
   1128 									failed: {
   1129 										"0": {
   1130 											code: 413,
   1131 											message: "Tag 'title;feeling;matter;drum;treatment;caring;earthy;shrill;unit;obedient;hover…' is too long to sync",
   1132 											data: {
   1133 												tag
   1134 											}
   1135 										}
   1136 									}
   1137 								})
   1138 							);
   1139 						}
   1140 						else {
   1141 							let itemJSON = item.toResponseJSON();
   1142 							itemJSON.version = 6;
   1143 							itemJSON.data.version = 6;
   1144 							
   1145 							req.respond(
   1146 								200,
   1147 								{
   1148 									"Last-Modified-Version": 6
   1149 								},
   1150 								JSON.stringify({
   1151 									successful: {
   1152 										"0": itemJSON
   1153 									},
   1154 									success: {
   1155 										"0": json[0].key
   1156 									},
   1157 									unchanged: {},
   1158 									failed: {}
   1159 								})
   1160 							);
   1161 						}
   1162 					}
   1163 				});
   1164 				
   1165 				waitForDialog(null, 'accept', 'chrome://zotero/content/longTagFixer.xul');
   1166 				yield runner.sync({ libraries: [Zotero.Libraries.userLibraryID] });
   1167 				
   1168 				assert.isFalse(Zotero.Tags.getID(tag));
   1169 				assert.isNumber(Zotero.Tags.getID('feeling'));
   1170 			});
   1171 			
   1172 			it("should delete a tag", function* () {
   1173 				win = yield loadZoteroPane();
   1174 				
   1175 				var item = yield createDataObject('item');
   1176 				var tag = "title;feeling;matter;drum;treatment;caring;earthy;shrill;unit;obedient;hover;healthy;cheap;clever;wren;wicked;clip;shoe;jittery;shape;clear;dime;increase;complete;level;milk;false;infamous;lamentable;measure;cuddly;tasteless;peace;top;pencil;caption;unusual;depressed;frantic";
   1177 				item.addTag(tag, 1);
   1178 				yield item.saveTx();
   1179 				
   1180 				setResponse('keyInfo.fullAccess');
   1181 				setResponse('userGroups.groupVersions');
   1182 				setResponse('groups.ownerGroup');
   1183 				setResponse('groups.memberGroup');
   1184 				
   1185 				server.respond(function (req) {
   1186 					if (req.method == "POST" && req.url == baseURL + "users/1/items") {
   1187 						var json = JSON.parse(req.requestBody);
   1188 						if (json[0].tags.length == 1) {
   1189 							req.respond(
   1190 								200,
   1191 								{
   1192 									"Last-Modified-Version": 5
   1193 								},
   1194 								JSON.stringify({
   1195 									successful: {},
   1196 									success: {},
   1197 									unchanged: {},
   1198 									failed: {
   1199 										"0": {
   1200 											code: 413,
   1201 											message: "Tag 'title;feeling;matter;drum;treatment;caring;earthy;shrill;unit;obedient;hover…' is too long to sync",
   1202 											data: {
   1203 												tag
   1204 											}
   1205 										}
   1206 									}
   1207 								})
   1208 							);
   1209 						}
   1210 						else {
   1211 							let itemJSON = item.toResponseJSON();
   1212 							itemJSON.version = 6;
   1213 							itemJSON.data.version = 6;
   1214 							
   1215 							req.respond(
   1216 								200,
   1217 								{
   1218 									"Last-Modified-Version": 6
   1219 								},
   1220 								JSON.stringify({
   1221 									successful: {
   1222 										"0": itemJSON
   1223 									},
   1224 									success: {
   1225 										"0": json[0].key
   1226 									},
   1227 									unchanged: {},
   1228 									failed: {}
   1229 								})
   1230 							);
   1231 						}
   1232 					}
   1233 				});
   1234 				
   1235 				waitForDialog(function (dialog) {
   1236 					dialog.Zotero_Long_Tag_Fixer.switchMode(2);
   1237 				}, 'accept', 'chrome://zotero/content/longTagFixer.xul');
   1238 				yield runner.sync({ libraries: [Zotero.Libraries.userLibraryID] });
   1239 				
   1240 				assert.isFalse(Zotero.Tags.getID(tag));
   1241 				assert.isFalse(Zotero.Tags.getID('feeling'));
   1242 			});
   1243 		});
   1244 	});
   1245 })